mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-04-05 16:00:59 +00:00
Compare commits
No commits in common. "master" and "1.6.0" have entirely different histories.
56 changed files with 2766 additions and 3378 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
# Apply ruff linter rules and standardize code style
|
|
||||||
c4d7cedeb8b7a8bded8db9a658ae635195071ce3
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload packages to Jazzband
|
- name: Upload packages to Jazzband
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
with:
|
with:
|
||||||
user: jazzband
|
user: jazzband
|
||||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
|
|
|
||||||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
|
|
@ -1,30 +1,24 @@
|
||||||
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
||||||
name: test
|
name: test
|
||||||
|
|
||||||
"on":
|
"on": [push, pull_request, workflow_dispatch]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-matrix:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
django-version: ['4.2', '5.1', '5.2']
|
django-version: ["3.2", "4.2", "5.0"]
|
||||||
exclude:
|
exclude:
|
||||||
# Exclude Python 3.9 with Django 5.1 and 5.2
|
- django-version: "3.2"
|
||||||
- python-version: '3.9'
|
python-version: "3.11"
|
||||||
django-version: '5.1'
|
- django-version: "3.2"
|
||||||
- python-version: '3.9'
|
python-version: "3.12"
|
||||||
django-version: '5.2'
|
- django-version: "5.0"
|
||||||
# Exclude Python 3.13 with Django 4.2
|
python-version: "3.8"
|
||||||
- python-version: '3.13'
|
- django-version: "5.0"
|
||||||
django-version: '4.2'
|
python-version: "3.9"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -36,7 +30,6 @@ jobs:
|
||||||
- name: Install Poetry
|
- name: Install Poetry
|
||||||
uses: snok/install-poetry@v1
|
uses: snok/install-poetry@v1
|
||||||
with:
|
with:
|
||||||
version: 1.8.4
|
|
||||||
virtualenvs-create: true
|
virtualenvs-create: true
|
||||||
virtualenvs-in-project: true
|
virtualenvs-in-project: true
|
||||||
installer-parallel: true
|
installer-parallel: true
|
||||||
|
|
@ -60,6 +53,6 @@ jobs:
|
||||||
poetry run pip check
|
poetry run pip check
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# See https://pre-commit.com for more information
|
# See https://pre-commit.com for more information
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
|
@ -9,15 +9,12 @@ repos:
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/psf/black
|
||||||
# Ruff version.
|
rev: 24.2.0
|
||||||
rev: v0.11.12
|
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter.
|
- id: black
|
||||||
- id: ruff
|
language_version: python3
|
||||||
args: [ --fix ]
|
entry: black --target-version=py36
|
||||||
# Run the formatter.
|
|
||||||
- id: ruff-format
|
|
||||||
|
|
||||||
- repo: https://github.com/remastr/pre-commit-django-check-migrations
|
- repo: https://github.com/remastr/pre-commit-django-check-migrations
|
||||||
rev: v0.1.0
|
rev: v0.1.0
|
||||||
|
|
|
||||||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -2,41 +2,6 @@
|
||||||
|
|
||||||
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||||
|
|
||||||
## 1.8.1 (2025-06-02)
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- Added support for Django 5.2
|
|
||||||
- Updated dependencies to their latest versions
|
|
||||||
|
|
||||||
## 1.8.0 (2025-02-24)
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- Add database constraints to Value model for data integrity by @Dresdn in https://github.com/jazzband/django-eav2/pull/706
|
|
||||||
- Fix for issue #648: Ensure choices are valid (value, label) tuples by @altimore in https://github.com/jazzband/django-eav2/pull/707
|
|
||||||
|
|
||||||
## 1.7.1 (2024-09-01)
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
* Restore backward compatibility for Attribute creation with invalid slugs by @Dresdn in https://github.com/jazzband/django-eav2/pull/639
|
|
||||||
|
|
||||||
## 1.7.0 (2024-09-01)
|
|
||||||
|
|
||||||
### What's Changed
|
|
||||||
|
|
||||||
- Enhance slug validation for Python identifier compliance
|
|
||||||
- Migrate to ruff
|
|
||||||
- Drop support for Django 3.2
|
|
||||||
- Add support for Django 5.1
|
|
||||||
|
|
||||||
## 1.6.1 (2024-06-23)
|
|
||||||
|
|
||||||
### What's Changed
|
|
||||||
|
|
||||||
- Ensure eav.register() Maintains Manager Order by @Dresdn in https://github.com/jazzband/django-eav2/pull/595
|
|
||||||
- Update downstream dependencies by @Dresdn in https://github.com/jazzband/django-eav2/pull/597
|
|
||||||
|
|
||||||
## 1.6.0 (2024-03-14)
|
## 1.6.0 (2024-03-14)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,30 @@
|
||||||
#
|
#
|
||||||
# More information on the configuration options is available at:
|
# More information on the configuration options is available at:
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from typing import Dict
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from sphinx.ext.autodoc import between
|
from sphinx.ext.autodoc import between
|
||||||
|
|
||||||
# For discovery of Python modules
|
sys.path.insert(0, os.path.abspath('.'))
|
||||||
sys.path.insert(0, str(Path().cwd()))
|
sys.path.insert(0, os.path.abspath('../../'))
|
||||||
|
|
||||||
# For finding the django_settings.py file
|
|
||||||
sys.path.insert(0, str(Path("../../").resolve()))
|
|
||||||
|
|
||||||
|
|
||||||
# Pass settings into configure.
|
# Pass settings into configure.
|
||||||
settings.configure(
|
settings.configure(
|
||||||
INSTALLED_APPS=[
|
INSTALLED_APPS=[
|
||||||
"django.contrib.admin",
|
'django.contrib.admin',
|
||||||
"django.contrib.auth",
|
'django.contrib.auth',
|
||||||
"django.contrib.contenttypes",
|
'django.contrib.contenttypes',
|
||||||
"django.contrib.sessions",
|
'django.contrib.sessions',
|
||||||
"django.contrib.messages",
|
'django.contrib.messages',
|
||||||
"django.contrib.staticfiles",
|
'django.contrib.staticfiles',
|
||||||
"eav",
|
'eav',
|
||||||
],
|
],
|
||||||
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"),
|
SECRET_KEY=os.environ.get('DJANGO_SECRET_KEY', 'this-is-not-s3cur3'),
|
||||||
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
|
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,22 +34,22 @@ django.setup()
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
project = "Django EAV 2"
|
project = 'Django EAV 2'
|
||||||
copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001
|
copyright = '2018, Iwo Herka and team at MAKIMO'
|
||||||
author = "-"
|
author = '-'
|
||||||
|
|
||||||
# The short X.Y version
|
# The short X.Y version
|
||||||
version = ""
|
version = ''
|
||||||
# The full version, including alpha/beta/rc tags
|
# The full version, including alpha/beta/rc tags
|
||||||
release = "0.10.0"
|
release = '0.10.0'
|
||||||
|
|
||||||
|
|
||||||
def setup(app):
|
def setup(app):
|
||||||
"""Use the configuration file itself as an extension."""
|
"""Use the configuration file itself as an extension."""
|
||||||
app.connect(
|
app.connect(
|
||||||
"autodoc-process-docstring",
|
'autodoc-process-docstring',
|
||||||
between(
|
between(
|
||||||
"^.*IGNORE.*$",
|
'^.*IGNORE.*$',
|
||||||
exclude=True,
|
exclude=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -64,57 +59,57 @@ def setup(app):
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
|
|
||||||
extensions = [
|
extensions = [
|
||||||
"sphinx.ext.napoleon",
|
'sphinx.ext.napoleon',
|
||||||
"sphinx.ext.autodoc",
|
'sphinx.ext.autodoc',
|
||||||
"sphinx.ext.intersphinx",
|
'sphinx.ext.intersphinx',
|
||||||
"sphinx.ext.coverage",
|
'sphinx.ext.coverage',
|
||||||
"sphinx.ext.viewcode",
|
'sphinx.ext.viewcode',
|
||||||
"sphinx_rtd_theme",
|
'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.
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
pygments_style = "sphinx"
|
pygments_style = 'sphinx'
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
html_theme = "sphinx_rtd_theme"
|
html_theme = 'sphinx_rtd_theme'
|
||||||
|
|
||||||
html_static_path = ["_static"]
|
html_static_path = ['_static']
|
||||||
|
|
||||||
html_sidebars = {
|
html_sidebars = {
|
||||||
"index": ["sidebarintro.html", "localtoc.html"],
|
'index': ['sidebarintro.html', 'localtoc.html'],
|
||||||
"**": [
|
'**': [
|
||||||
"sidebarintro.html",
|
'sidebarintro.html',
|
||||||
"localtoc.html",
|
'localtoc.html',
|
||||||
"relations.html",
|
'relations.html',
|
||||||
"searchbox.html",
|
'searchbox.html',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlhelp_basename = "DjangoEAV2doc"
|
htmlhelp_basename = 'DjangoEAV2doc'
|
||||||
|
|
||||||
|
|
||||||
# -- Options for LaTeX output ------------------------------------------------
|
# -- Options for LaTeX output ------------------------------------------------
|
||||||
|
|
||||||
latex_elements: dict[str, str] = {}
|
latex_elements: Dict[str, str] = {}
|
||||||
|
|
||||||
# Grouping the document tree into LaTeX files. List of tuples
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
# (source start file, target name, title,
|
# (source start file, target name, title,
|
||||||
# author, documentclass [howto, manual, or own class]).
|
# author, documentclass [howto, manual, or own class]).
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
(master_doc, "DjangoEAV2.tex", "Django EAV 2 Documentation", "-", "manual"),
|
(master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', '-', 'manual'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,8 +120,8 @@ latex_documents = [
|
||||||
man_pages = [
|
man_pages = [
|
||||||
(
|
(
|
||||||
master_doc,
|
master_doc,
|
||||||
"djangoeav2",
|
'djangoeav2',
|
||||||
"Django EAV 2 Documentation",
|
'Django EAV 2 Documentation',
|
||||||
[author],
|
[author],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
|
|
@ -141,12 +136,12 @@ man_pages = [
|
||||||
texinfo_documents = [
|
texinfo_documents = [
|
||||||
(
|
(
|
||||||
master_doc,
|
master_doc,
|
||||||
"DjangoEAV2",
|
'DjangoEAV2',
|
||||||
"Django EAV 2 Documentation",
|
'Django EAV 2 Documentation',
|
||||||
author,
|
author,
|
||||||
"DjangoEAV2",
|
'DjangoEAV2',
|
||||||
"One line description of project.",
|
'One line description of project.',
|
||||||
"Miscellaneous",
|
'Miscellaneous',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -155,7 +150,7 @@ texinfo_documents = [
|
||||||
# -- Options for intersphinx extension ---------------------------------------
|
# -- Options for intersphinx extension ---------------------------------------
|
||||||
|
|
||||||
# Example configuration for intersphinx: refer to the Python standard library.
|
# 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 ---------------------------------------------------
|
# -- Autodoc configuration ---------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
49
eav/admin.py
49
eav/admin.py
|
|
@ -1,8 +1,6 @@
|
||||||
"""This module contains classes used for admin integration."""
|
"""This module contains classes used for admin integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
|
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
||||||
|
|
@ -11,13 +9,8 @@ from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
|
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
|
||||||
|
|
||||||
some_attribute = ClassVar[Dict[str, str]]
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEntityAdmin(ModelAdmin):
|
class BaseEntityAdmin(ModelAdmin):
|
||||||
"""Custom admin model to support dynamic EAV fieldsets.
|
"""Custom admin model to support dynamic EAV fieldsets.
|
||||||
|
|
@ -33,7 +26,7 @@ class BaseEntityAdmin(ModelAdmin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
eav_fieldset_title: str = "EAV Attributes"
|
eav_fieldset_title: str = "EAV Attributes"
|
||||||
eav_fieldset_description: str | None = None
|
eav_fieldset_description: Optional[str] = None
|
||||||
|
|
||||||
def render_change_form(self, request, context, *args, **kwargs):
|
def render_change_form(self, request, context, *args, **kwargs):
|
||||||
"""Dynamically modifies the admin form to include EAV fields.
|
"""Dynamically modifies the admin form to include EAV fields.
|
||||||
|
|
@ -52,7 +45,7 @@ class BaseEntityAdmin(ModelAdmin):
|
||||||
Returns:
|
Returns:
|
||||||
HttpResponse object representing the rendered change form.
|
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.
|
# Identify EAV fields based on the form instance's configuration.
|
||||||
eav_fields = self._get_eav_fields(form.instance)
|
eav_fields = self._get_eav_fields(form.instance)
|
||||||
|
|
@ -62,7 +55,7 @@ class BaseEntityAdmin(ModelAdmin):
|
||||||
return super().render_change_form(request, context, *args, **kwargs)
|
return super().render_change_form(request, context, *args, **kwargs)
|
||||||
|
|
||||||
# Get the non-EAV fieldsets and then append our own
|
# 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))
|
fieldsets.append(self._get_eav_fieldset(eav_fields))
|
||||||
|
|
||||||
# Reconstruct the admin form with updated fieldsets.
|
# Reconstruct the admin form with updated fieldsets.
|
||||||
|
|
@ -72,18 +65,18 @@ class BaseEntityAdmin(ModelAdmin):
|
||||||
# Clear prepopulated fields on a view-only form to avoid a crash.
|
# Clear prepopulated fields on a view-only form to avoid a crash.
|
||||||
(
|
(
|
||||||
self.prepopulated_fields
|
self.prepopulated_fields
|
||||||
if self.has_change_permission(request, kwargs["obj"])
|
if self.has_change_permission(request, kwargs['obj'])
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
readonly_fields=self.readonly_fields,
|
readonly_fields=self.readonly_fields,
|
||||||
model_admin=self,
|
model_admin=self,
|
||||||
)
|
)
|
||||||
media = mark_safe(context["media"] + adminform.media) # noqa: S308
|
media = mark_safe(context['media'] + adminform.media)
|
||||||
context.update(adminform=adminform, media=media)
|
context.update(adminform=adminform, media=media)
|
||||||
|
|
||||||
return super().render_change_form(request, context, *args, **kwargs)
|
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.
|
"""Retrieves a list of EAV field slugs for the given instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -92,24 +85,24 @@ class BaseEntityAdmin(ModelAdmin):
|
||||||
Returns:
|
Returns:
|
||||||
A list of strings representing the slugs of EAV fields.
|
A list of strings representing the slugs of EAV fields.
|
||||||
"""
|
"""
|
||||||
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
entity = getattr(instance, instance._eav_config_cls.eav_attr)
|
||||||
return list(entity.get_all_attributes().values_list("slug", flat=True))
|
return list(entity.get_all_attributes().values_list('slug', flat=True))
|
||||||
|
|
||||||
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
|
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
|
||||||
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
|
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
|
||||||
|
|
||||||
Generates a list representing a fieldset specifically for Entity-Attribute-Value
|
Generates a list representing a fieldset specifically for Entity-Attribute-Value (EAV) fields,
|
||||||
(EAV) fields, intended to be appended to the admin form's fieldsets
|
intended to be appended to the admin form's fieldsets configuration. This facilitates the
|
||||||
configuration. This facilitates the dynamic inclusion of EAV fields within the
|
dynamic inclusion of EAV fields within the Django admin interface by creating a designated
|
||||||
Django admin interface by creating a designated section for these attributes.
|
section for these attributes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
eav_fields (List[str]): A list of slugs representing the EAV fields to be
|
eav_fields (List[str]): A list of slugs representing the EAV fields to be included
|
||||||
included in the EAV Attributes fieldset.
|
in the EAV Attributes fieldset.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
self.eav_fieldset_title,
|
self.eav_fieldset_title,
|
||||||
{"fields": eav_fields, "description": self.eav_fieldset_description},
|
{'fields': eav_fields, 'description': self.eav_fieldset_description},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,9 +114,9 @@ class BaseEntityInlineFormSet(BaseInlineFormSet):
|
||||||
def add_fields(self, form, index):
|
def add_fields(self, form, index):
|
||||||
if self.instance:
|
if self.instance:
|
||||||
setattr(form.instance, self.fk.name, self.instance)
|
setattr(form.instance, self.fk.name, self.instance)
|
||||||
form._build_dynamic_fields() # noqa: SLF001
|
form._build_dynamic_fields()
|
||||||
|
|
||||||
super().add_fields(form, index)
|
super(BaseEntityInlineFormSet, self).add_fields(form, index)
|
||||||
|
|
||||||
|
|
||||||
class BaseEntityInline(InlineModelAdmin):
|
class BaseEntityInline(InlineModelAdmin):
|
||||||
|
|
@ -154,12 +147,12 @@ class BaseEntityInline(InlineModelAdmin):
|
||||||
instance = self.model(**kw)
|
instance = self.model(**kw)
|
||||||
form = formset.form(request.POST, instance=instance)
|
form = formset.form(request.POST, instance=instance)
|
||||||
|
|
||||||
return [(None, {"fields": form.fields.keys()})]
|
return [(None, {'fields': form.fields.keys()})]
|
||||||
|
|
||||||
|
|
||||||
class AttributeAdmin(ModelAdmin):
|
class AttributeAdmin(ModelAdmin):
|
||||||
list_display = ("name", "slug", "datatype", "description")
|
list_display = ('name', 'slug', 'datatype', 'description')
|
||||||
prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Attribute, AttributeAdmin)
|
admin.site.register(Attribute, AttributeAdmin)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ def register_eav(**kwargs):
|
||||||
|
|
||||||
def _model_eav_wrapper(model_class):
|
def _model_eav_wrapper(model_class):
|
||||||
if not issubclass(model_class, Model):
|
if not issubclass(model_class, Model):
|
||||||
raise TypeError("Wrapped class must subclass Model.")
|
raise ValueError('Wrapped class must subclass Model.')
|
||||||
register(model_class, **kwargs)
|
register(model_class, **kwargs)
|
||||||
return model_class
|
return model_class
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
class IllegalAssignmentException(Exception): # noqa: N818
|
class IllegalAssignmentException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class EavDatatypeField(models.CharField):
|
||||||
:class:`~eav.models.Attribute` that is already used by
|
:class:`~eav.models.Attribute` that is already used by
|
||||||
:class:`~eav.models.Value` objects.
|
:class:`~eav.models.Value` objects.
|
||||||
"""
|
"""
|
||||||
super().validate(value, instance)
|
super(EavDatatypeField, self).validate(value, instance)
|
||||||
|
|
||||||
if not instance.pk:
|
if not instance.pk:
|
||||||
return
|
return
|
||||||
|
|
@ -31,9 +31,8 @@ class EavDatatypeField(models.CharField):
|
||||||
if instance.value_set.count():
|
if instance.value_set.count():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_(
|
_(
|
||||||
"You cannot change the datatype of an "
|
'You cannot change the datatype of an attribute that is already in use.'
|
||||||
+ "attribute that is already in use.",
|
)
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,21 +42,21 @@ class CSVField(models.TextField): # (models.Field):
|
||||||
|
|
||||||
def __init__(self, separator=";", *args, **kwargs):
|
def __init__(self, separator=";", *args, **kwargs):
|
||||||
self.separator = separator
|
self.separator = separator
|
||||||
kwargs.setdefault("default", "")
|
kwargs.setdefault('default', "")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def deconstruct(self):
|
def deconstruct(self):
|
||||||
name, path, args, kwargs = super().deconstruct()
|
name, path, args, kwargs = super().deconstruct()
|
||||||
if self.separator != self.default_separator:
|
if self.separator != self.default_separator:
|
||||||
kwargs["separator"] = self.separator
|
kwargs['separator'] = self.separator
|
||||||
return name, path, args, kwargs
|
return name, path, args, kwargs
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
def formfield(self, **kwargs):
|
||||||
defaults = {"form_class": CSVFormField}
|
defaults = {'form_class': CSVFormField}
|
||||||
defaults.update(kwargs)
|
defaults.update(kwargs)
|
||||||
return super().formfield(**defaults)
|
return super().formfield(**defaults)
|
||||||
|
|
||||||
def from_db_value(self, value, expression, connection):
|
def from_db_value(self, value, expression, connection, context=None):
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
return value.split(self.separator)
|
return value.split(self.separator)
|
||||||
|
|
@ -74,9 +73,8 @@ class CSVField(models.TextField): # (models.Field):
|
||||||
return ""
|
return ""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
return self.separator.join(value)
|
return self.separator.join(value)
|
||||||
return value
|
|
||||||
|
|
||||||
def value_to_string(self, obj):
|
def value_to_string(self, obj):
|
||||||
value = self.value_from_object(obj)
|
value = self.value_from_object(obj)
|
||||||
|
|
|
||||||
73
eav/forms.py
73
eav/forms.py
|
|
@ -1,9 +1,6 @@
|
||||||
"""This module contains forms used for admin integration."""
|
"""This module contains forms used for admin integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from django.contrib.admin.widgets import AdminSplitDateTime
|
from django.contrib.admin.widgets import AdminSplitDateTime
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
@ -24,14 +21,14 @@ from eav.widgets import CSVWidget
|
||||||
|
|
||||||
|
|
||||||
class CSVFormField(Field):
|
class CSVFormField(Field):
|
||||||
message = _("Enter comma-separated-values. eg: one;two;three.")
|
message = _('Enter comma-separated-values. eg: one;two;three.')
|
||||||
code = "invalid"
|
code = 'invalid'
|
||||||
widget = CSVWidget
|
widget = CSVWidget
|
||||||
default_separator = ";"
|
default_separator = ";"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs.pop("max_length", None)
|
kwargs.pop('max_length', None)
|
||||||
self.separator = kwargs.pop("separator", self.default_separator)
|
self.separator = kwargs.pop('separator', self.default_separator)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
|
|
@ -41,8 +38,9 @@ class CSVFormField(Field):
|
||||||
|
|
||||||
def validate(self, field_value):
|
def validate(self, field_value):
|
||||||
super().validate(field_value)
|
super().validate(field_value)
|
||||||
|
try:
|
||||||
if not isinstance(field_value, list):
|
isinstance(field_value, list)
|
||||||
|
except ValidationError:
|
||||||
raise ValidationError(self.message, code=self.code)
|
raise ValidationError(self.message, code=self.code)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,20 +70,20 @@ class BaseDynamicEntityForm(ModelForm):
|
||||||
===== =============
|
===== =============
|
||||||
"""
|
"""
|
||||||
|
|
||||||
FIELD_CLASSES: ClassVar[dict[str, Field]] = {
|
FIELD_CLASSES = {
|
||||||
"text": CharField,
|
'text': CharField,
|
||||||
"float": FloatField,
|
'float': FloatField,
|
||||||
"int": IntegerField,
|
'int': IntegerField,
|
||||||
"date": SplitDateTimeField,
|
'date': SplitDateTimeField,
|
||||||
"bool": BooleanField,
|
'bool': BooleanField,
|
||||||
"enum": ChoiceField,
|
'enum': ChoiceField,
|
||||||
"json": JSONField,
|
'json': JSONField,
|
||||||
"csv": CSVFormField,
|
'csv': CSVFormField,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(data, *args, **kwargs)
|
super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs)
|
||||||
config_cls = self.instance._eav_config_cls # noqa: SLF001
|
config_cls = self.instance._eav_config_cls
|
||||||
self.entity = getattr(self.instance, config_cls.eav_attr)
|
self.entity = getattr(self.instance, config_cls.eav_attr)
|
||||||
self._build_dynamic_fields()
|
self._build_dynamic_fields()
|
||||||
|
|
||||||
|
|
@ -97,35 +95,35 @@ class BaseDynamicEntityForm(ModelForm):
|
||||||
value = getattr(self.entity, attribute.slug)
|
value = getattr(self.entity, attribute.slug)
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
"label": attribute.name.capitalize(),
|
'label': attribute.name.capitalize(),
|
||||||
"required": attribute.required,
|
'required': attribute.required,
|
||||||
"help_text": attribute.help_text,
|
'help_text': attribute.help_text,
|
||||||
"validators": attribute.get_validators(),
|
'validators': attribute.get_validators(),
|
||||||
}
|
}
|
||||||
|
|
||||||
datatype = attribute.datatype
|
datatype = attribute.datatype
|
||||||
|
|
||||||
if datatype == attribute.TYPE_ENUM:
|
if datatype == attribute.TYPE_ENUM:
|
||||||
values = attribute.get_choices().values_list("id", "value")
|
values = attribute.get_choices().values_list('id', 'value')
|
||||||
choices = [("", ""), ("-----", "-----"), *list(values)]
|
choices = [('', '-----')] + list(values)
|
||||||
defaults.update({"choices": choices})
|
defaults.update({'choices': choices})
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
defaults.update({"initial": value.pk})
|
defaults.update({'initial': value.pk})
|
||||||
|
|
||||||
elif datatype == attribute.TYPE_DATE:
|
elif datatype == attribute.TYPE_DATE:
|
||||||
defaults.update({"widget": AdminSplitDateTime})
|
defaults.update({'widget': AdminSplitDateTime})
|
||||||
elif datatype == attribute.TYPE_OBJECT:
|
elif datatype == attribute.TYPE_OBJECT:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
MappedField = self.FIELD_CLASSES[datatype] # noqa: N806
|
MappedField = self.FIELD_CLASSES[datatype]
|
||||||
self.fields[attribute.slug] = MappedField(**defaults)
|
self.fields[attribute.slug] = MappedField(**defaults)
|
||||||
|
|
||||||
# Fill initial data (if attribute was already defined).
|
# Fill initial data (if attribute was already defined).
|
||||||
if value and datatype != attribute.TYPE_ENUM:
|
if value and not datatype == attribute.TYPE_ENUM:
|
||||||
self.initial[attribute.slug] = value
|
self.initial[attribute.slug] = value
|
||||||
|
|
||||||
def save(self, *, commit=True):
|
def save(self, commit=True):
|
||||||
"""
|
"""
|
||||||
Saves this ``form``'s cleaned_data into model instance
|
Saves this ``form``'s cleaned_data into model instance
|
||||||
``self.instance`` and related EAV attributes. Returns ``instance``.
|
``self.instance`` and related EAV attributes. Returns ``instance``.
|
||||||
|
|
@ -133,20 +131,23 @@ class BaseDynamicEntityForm(ModelForm):
|
||||||
if self.errors:
|
if self.errors:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
_(
|
_(
|
||||||
"The %s could not be saved because the data didn't validate.",
|
'The %s could not be saved because the data'
|
||||||
|
'didn\'t validate.' % self.instance._meta.object_name
|
||||||
)
|
)
|
||||||
% self.instance._meta.object_name, # noqa: SLF001
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create entity instance, don't save yet.
|
# Create entity instance, don't save yet.
|
||||||
instance = super().save(commit=False)
|
instance = super(BaseDynamicEntityForm, self).save(commit=False)
|
||||||
|
|
||||||
# Assign attributes.
|
# Assign attributes.
|
||||||
for attribute in self.entity.get_all_attributes():
|
for attribute in self.entity.get_all_attributes():
|
||||||
value = self.cleaned_data.get(attribute.slug)
|
value = self.cleaned_data.get(attribute.slug)
|
||||||
|
|
||||||
if attribute.datatype == attribute.TYPE_ENUM:
|
if attribute.datatype == attribute.TYPE_ENUM:
|
||||||
value = attribute.enum_group.values.get(pk=value) if value else None
|
if value:
|
||||||
|
value = attribute.enum_group.values.get(pk=value)
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
|
||||||
setattr(self.entity, attribute.slug, value)
|
setattr(self.entity, attribute.slug, value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
These values map to `models.Value` as potential fields to use to relate
|
||||||
to the proper entity via the correct PK type.
|
to the proper entity via the correct PK type.
|
||||||
"""
|
"""
|
||||||
if isinstance(entity_cls._meta.pk, UUIDField): # noqa: SLF001
|
if isinstance(entity_cls._meta.pk, UUIDField):
|
||||||
return "entity_uuid"
|
return 'entity_uuid'
|
||||||
return "entity_id"
|
return 'entity_id'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import uuid
|
import uuid
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
@ -23,7 +24,7 @@ _FIELD_MAPPING = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_pk_format() -> models.Field:
|
def get_pk_format() -> Type[models.Field]:
|
||||||
"""
|
"""
|
||||||
Get the primary key field format based on the Django settings.
|
Get the primary key field format based on the Django settings.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
@ -9,53 +7,16 @@ from django.utils.text import slugify
|
||||||
SLUGFIELD_MAX_LENGTH: Final = 50
|
SLUGFIELD_MAX_LENGTH: Final = 50
|
||||||
|
|
||||||
|
|
||||||
def non_identifier_chars() -> dict[str, str]:
|
def generate_slug(name: str) -> str:
|
||||||
"""Generate a mapping of non-identifier characters to their Unicode representations.
|
"""Generates a valid slug based on ``name``."""
|
||||||
|
slug = slugify(name, allow_unicode=False)
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, str]: A dictionary where keys are special characters and values
|
|
||||||
are their Unicode representations.
|
|
||||||
"""
|
|
||||||
# Start with all printable characters
|
|
||||||
all_chars = string.printable
|
|
||||||
|
|
||||||
# Filter out characters that are valid in Python identifiers
|
|
||||||
special_chars = [
|
|
||||||
char for char in all_chars if not char.isalnum() and char not in ["_", " "]
|
|
||||||
]
|
|
||||||
|
|
||||||
return {char: f"u{ord(char):04x}" for char in special_chars}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_slug(value: str) -> str:
|
|
||||||
"""Generate a valid slug based on the given value.
|
|
||||||
|
|
||||||
This function converts the input value into a Python-identifier-friendly slug.
|
|
||||||
It handles special characters, ensures a valid Python identifier, and truncates
|
|
||||||
the result to fit within the maximum allowed length.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (str): The input string to generate a slug from.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: A valid Python identifier slug, with a maximum
|
|
||||||
length of SLUGFIELD_MAX_LENGTH.
|
|
||||||
"""
|
|
||||||
for char, replacement in non_identifier_chars().items():
|
|
||||||
value = value.replace(char, replacement)
|
|
||||||
|
|
||||||
# Use slugify to create a URL-friendly base slug.
|
|
||||||
slug = slugify(value, allow_unicode=False).replace("-", "_")
|
|
||||||
|
|
||||||
# If slugify returns an empty string, generate a fallback
|
|
||||||
# slug to ensure it's never empty.
|
|
||||||
if not slug:
|
if not slug:
|
||||||
|
# Fallback to ensure a slug is always generated by using a random one
|
||||||
chars = string.ascii_lowercase + string.digits
|
chars = string.ascii_lowercase + string.digits
|
||||||
randstr = "".join(secrets.choice(chars) for _ in range(8))
|
randstr = ''.join(secrets.choice(chars) for _ in range(8))
|
||||||
slug = f"rand_{randstr}"
|
slug = 'rand-{0}'.format(randstr)
|
||||||
|
|
||||||
# Ensure the slug doesn't start with a digit to make it a valid Python identifier.
|
slug = slug.encode('utf-8', 'surrogateescape').decode()
|
||||||
if slug[0].isdigit():
|
|
||||||
slug = "_" + slug
|
|
||||||
|
|
||||||
return slug[:SLUGFIELD_MAX_LENGTH]
|
return slug[:SLUGFIELD_MAX_LENGTH]
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ class EntityManager(models.Manager):
|
||||||
Parse eav attributes out of *kwargs*, then try to create and save
|
Parse eav attributes out of *kwargs*, then try to create and save
|
||||||
the object, then assign and save it's eav attributes.
|
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:
|
if not config_cls or config_cls.manager_only:
|
||||||
return super().create(**kwargs)
|
return super(EntityManager, self).create(**kwargs)
|
||||||
|
|
||||||
prefix = f"{config_cls.eav_attr}__"
|
prefix = '%s__' % config_cls.eav_attr
|
||||||
new_kwargs = {}
|
new_kwargs = {}
|
||||||
eav_kwargs = {}
|
eav_kwargs = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,220 +8,215 @@ import eav.fields
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
"""Initial migration for the Attribute, EnumGroup, EnumValue, and Value models."""
|
"""Initial migration that creates the Attribute, EnumGroup, EnumValue, and Value models."""
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Attribute",
|
name='Attribute',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"name",
|
'name',
|
||||||
models.CharField(
|
models.CharField(
|
||||||
help_text="User-friendly attribute name",
|
help_text='User-friendly attribute name',
|
||||||
max_length=100,
|
max_length=100,
|
||||||
verbose_name="Name",
|
verbose_name='Name',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"slug",
|
'slug',
|
||||||
models.SlugField(
|
models.SlugField(
|
||||||
help_text="Short unique attribute label",
|
help_text='Short unique attribute label',
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Slug",
|
verbose_name='Slug',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"description",
|
'description',
|
||||||
models.CharField(
|
models.CharField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Short description",
|
help_text='Short description',
|
||||||
max_length=256,
|
max_length=256,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Description",
|
verbose_name='Description',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"datatype",
|
'datatype',
|
||||||
eav.fields.EavDatatypeField(
|
eav.fields.EavDatatypeField(
|
||||||
choices=[
|
choices=[
|
||||||
("text", "Text"),
|
('text', 'Text'),
|
||||||
("date", "Date"),
|
('date', 'Date'),
|
||||||
("float", "Float"),
|
('float', 'Float'),
|
||||||
("int", "Integer"),
|
('int', 'Integer'),
|
||||||
("bool", "True / False"),
|
('bool', 'True / False'),
|
||||||
("object", "Django Object"),
|
('object', 'Django Object'),
|
||||||
("enum", "Multiple Choice"),
|
('enum', 'Multiple Choice'),
|
||||||
],
|
],
|
||||||
max_length=6,
|
max_length=6,
|
||||||
verbose_name="Data Type",
|
verbose_name='Data Type',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"created",
|
'created',
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
default=django.utils.timezone.now,
|
default=django.utils.timezone.now,
|
||||||
editable=False,
|
editable=False,
|
||||||
verbose_name="Created",
|
verbose_name='Created',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"modified",
|
'modified',
|
||||||
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
models.DateTimeField(auto_now=True, verbose_name='Modified'),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"required",
|
'required',
|
||||||
models.BooleanField(default=False, verbose_name="Required"),
|
models.BooleanField(default=False, verbose_name='Required'),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"display_order",
|
'display_order',
|
||||||
models.PositiveIntegerField(
|
models.PositiveIntegerField(
|
||||||
default=1,
|
default=1, verbose_name='Display order'
|
||||||
verbose_name="Display order",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["name"],
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="EnumGroup",
|
name='EnumGroup',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"name",
|
'name',
|
||||||
models.CharField(max_length=100, unique=True, verbose_name="Name"),
|
models.CharField(max_length=100, unique=True, verbose_name='Name'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="EnumValue",
|
name='EnumValue',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"value",
|
'value',
|
||||||
models.CharField(
|
models.CharField(
|
||||||
db_index=True,
|
db_index=True, max_length=50, unique=True, verbose_name='Value'
|
||||||
max_length=50,
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Value",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Value",
|
name='Value',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("entity_id", models.IntegerField()),
|
('entity_id', models.IntegerField()),
|
||||||
("value_text", models.TextField(blank=True, null=True)),
|
('value_text', models.TextField(blank=True, null=True)),
|
||||||
("value_float", models.FloatField(blank=True, null=True)),
|
('value_float', models.FloatField(blank=True, null=True)),
|
||||||
("value_int", models.IntegerField(blank=True, null=True)),
|
('value_int', models.IntegerField(blank=True, null=True)),
|
||||||
("value_date", models.DateTimeField(blank=True, null=True)),
|
('value_date', models.DateTimeField(blank=True, null=True)),
|
||||||
("value_bool", models.NullBooleanField()),
|
('value_bool', models.NullBooleanField()),
|
||||||
("generic_value_id", models.IntegerField(blank=True, null=True)),
|
('generic_value_id', models.IntegerField(blank=True, null=True)),
|
||||||
(
|
(
|
||||||
"created",
|
'created',
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
default=django.utils.timezone.now,
|
default=django.utils.timezone.now, verbose_name='Created'
|
||||||
verbose_name="Created",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"modified",
|
'modified',
|
||||||
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
models.DateTimeField(auto_now=True, verbose_name='Modified'),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"attribute",
|
'attribute',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
to="eav.Attribute",
|
to='eav.Attribute',
|
||||||
verbose_name="Attribute",
|
verbose_name='Attribute',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"entity_ct",
|
'entity_ct',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
related_name="value_entities",
|
related_name='value_entities',
|
||||||
to="contenttypes.ContentType",
|
to='contenttypes.ContentType',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"generic_value_ct",
|
'generic_value_ct',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
related_name="value_values",
|
related_name='value_values',
|
||||||
to="contenttypes.ContentType",
|
to='contenttypes.ContentType',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"value_enum",
|
'value_enum',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
related_name="eav_values",
|
related_name='eav_values',
|
||||||
to="eav.EnumValue",
|
to='eav.EnumValue',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="enumgroup",
|
model_name='enumgroup',
|
||||||
name="values",
|
name='values',
|
||||||
field=models.ManyToManyField(to="eav.EnumValue", verbose_name="Enum group"),
|
field=models.ManyToManyField(to='eav.EnumValue', verbose_name='Enum group'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="enum_group",
|
name='enum_group',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
to="eav.EnumGroup",
|
to='eav.EnumGroup',
|
||||||
verbose_name="Choice Group",
|
verbose_name='Choice Group',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ class Migration(migrations.Migration):
|
||||||
"""Add entity_ct field to Attribute model."""
|
"""Add entity_ct field to Attribute model."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
("eav", "0001_initial"),
|
('eav', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="entity_ct",
|
name='entity_ct',
|
||||||
field=models.ManyToManyField(blank=True, to="contenttypes.ContentType"),
|
field=models.ManyToManyField(blank=True, to='contenttypes.ContentType'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ import eav.fields
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0002_add_entity_ct_field"),
|
('eav', '0002_add_entity_ct_field'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_json",
|
name='value_json',
|
||||||
field=JSONField(
|
field=JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
|
|
@ -24,21 +24,21 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="datatype",
|
name='datatype',
|
||||||
field=eav.fields.EavDatatypeField(
|
field=eav.fields.EavDatatypeField(
|
||||||
choices=[
|
choices=[
|
||||||
("text", "Text"),
|
('text', 'Text'),
|
||||||
("date", "Date"),
|
('date', 'Date'),
|
||||||
("float", "Float"),
|
('float', 'Float'),
|
||||||
("int", "Integer"),
|
('int', 'Integer'),
|
||||||
("bool", "True / False"),
|
('bool', 'True / False'),
|
||||||
("object", "Django Object"),
|
('object', 'Django Object'),
|
||||||
("enum", "Multiple Choice"),
|
('enum', 'Multiple Choice'),
|
||||||
("json", "JSON Object"),
|
('json', 'JSON Object'),
|
||||||
],
|
],
|
||||||
max_length=6,
|
max_length=6,
|
||||||
verbose_name="Data Type",
|
verbose_name='Data Type',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ from django.db import migrations, models
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0003_auto_20210404_2209"),
|
('eav', '0003_auto_20210404_2209'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_bool",
|
name='value_bool',
|
||||||
field=models.BooleanField(blank=True, null=True),
|
field=models.BooleanField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,32 @@ import eav.fields
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0004_alter_value_value_bool"),
|
('eav', '0004_alter_value_value_bool'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_csv",
|
name='value_csv',
|
||||||
field=eav.fields.CSVField(blank=True, default="", null=True),
|
field=eav.fields.CSVField(blank=True, default="", null=True),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="datatype",
|
name='datatype',
|
||||||
field=eav.fields.EavDatatypeField(
|
field=eav.fields.EavDatatypeField(
|
||||||
choices=[
|
choices=[
|
||||||
("text", "Text"),
|
('text', 'Text'),
|
||||||
("date", "Date"),
|
('date', 'Date'),
|
||||||
("float", "Float"),
|
('float', 'Float'),
|
||||||
("int", "Integer"),
|
('int', 'Integer'),
|
||||||
("bool", "True / False"),
|
('bool', 'True / False'),
|
||||||
("object", "Django Object"),
|
('object', 'Django Object'),
|
||||||
("enum", "Multiple Choice"),
|
('enum', 'Multiple Choice'),
|
||||||
("json", "JSON Object"),
|
('json', 'JSON Object'),
|
||||||
("csv", "Comma-Separated-Value"),
|
('csv', 'Comma-Separated-Value'),
|
||||||
],
|
],
|
||||||
max_length=6,
|
max_length=6,
|
||||||
verbose_name="Data Type",
|
verbose_name='Data Type',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,18 @@ class Migration(migrations.Migration):
|
||||||
"""Creates UUID field to map to Entity FK."""
|
"""Creates UUID field to map to Entity FK."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0005_auto_20210510_1305"),
|
('eav', '0005_auto_20210510_1305'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="entity_uuid",
|
name='entity_uuid',
|
||||||
field=models.UUIDField(blank=True, null=True),
|
field=models.UUIDField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="entity_id",
|
name='entity_id',
|
||||||
field=models.IntegerField(blank=True, null=True),
|
field=models.IntegerField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ class Migration(migrations.Migration):
|
||||||
"""Convert Value.value_int to BigInteger."""
|
"""Convert Value.value_int to BigInteger."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0006_add_entity_uuid"),
|
('eav', '0006_add_entity_uuid'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_int",
|
name='value_int',
|
||||||
field=models.BigIntegerField(blank=True, null=True),
|
field=models.BigIntegerField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,17 @@ class Migration(migrations.Migration):
|
||||||
"""Use Django SlugField() for Attribute.slug."""
|
"""Use Django SlugField() for Attribute.slug."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0007_alter_value_value_int"),
|
('eav', '0007_alter_value_value_int'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="slug",
|
name='slug',
|
||||||
field=models.SlugField(
|
field=models.SlugField(
|
||||||
help_text="Short unique attribute label",
|
help_text='Short unique attribute label',
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Slug",
|
verbose_name='Slug',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,171 +8,171 @@ class Migration(migrations.Migration):
|
||||||
"""Define verbose naming for models and fields."""
|
"""Define verbose naming for models and fields."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
("eav", "0008_use_native_slugfield"),
|
('eav', '0008_use_native_slugfield'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="attribute",
|
name='attribute',
|
||||||
options={
|
options={
|
||||||
"ordering": ["name"],
|
'ordering': ['name'],
|
||||||
"verbose_name": "Attribute",
|
'verbose_name': 'Attribute',
|
||||||
"verbose_name_plural": "Attributes",
|
'verbose_name_plural': 'Attributes',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="enumgroup",
|
name='enumgroup',
|
||||||
options={
|
options={
|
||||||
"verbose_name": "EnumGroup",
|
'verbose_name': 'EnumGroup',
|
||||||
"verbose_name_plural": "EnumGroups",
|
'verbose_name_plural': 'EnumGroups',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="enumvalue",
|
name='enumvalue',
|
||||||
options={
|
options={
|
||||||
"verbose_name": "EnumValue",
|
'verbose_name': 'EnumValue',
|
||||||
"verbose_name_plural": "EnumValues",
|
'verbose_name_plural': 'EnumValues',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="value",
|
name='value',
|
||||||
options={"verbose_name": "Value", "verbose_name_plural": "Values"},
|
options={'verbose_name': 'Value', 'verbose_name_plural': 'Values'},
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="entity_ct",
|
name='entity_ct',
|
||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
blank=True,
|
blank=True,
|
||||||
to="contenttypes.contenttype",
|
to='contenttypes.contenttype',
|
||||||
verbose_name="Entity content type",
|
verbose_name='Entity content type',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="entity_ct",
|
name='entity_ct',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
on_delete=models.deletion.PROTECT,
|
on_delete=models.deletion.PROTECT,
|
||||||
related_name="value_entities",
|
related_name='value_entities',
|
||||||
to="contenttypes.contenttype",
|
to='contenttypes.contenttype',
|
||||||
verbose_name="Entity ct",
|
verbose_name='Entity ct',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="entity_id",
|
name='entity_id',
|
||||||
field=models.IntegerField(
|
field=models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Entity id",
|
verbose_name='Entity id',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="entity_uuid",
|
name='entity_uuid',
|
||||||
field=models.UUIDField(
|
field=models.UUIDField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Entity uuid",
|
verbose_name='Entity uuid',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="generic_value_ct",
|
name='generic_value_ct',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.deletion.PROTECT,
|
on_delete=models.deletion.PROTECT,
|
||||||
related_name="value_values",
|
related_name='value_values',
|
||||||
to="contenttypes.contenttype",
|
to='contenttypes.contenttype',
|
||||||
verbose_name="Generic value content type",
|
verbose_name='Generic value content type',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="generic_value_id",
|
name='generic_value_id',
|
||||||
field=models.IntegerField(
|
field=models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Generic value id",
|
verbose_name='Generic value id',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_bool",
|
name='value_bool',
|
||||||
field=models.BooleanField(
|
field=models.BooleanField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value bool",
|
verbose_name='Value bool',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_csv",
|
name='value_csv',
|
||||||
field=CSVField(
|
field=CSVField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default='',
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value CSV",
|
verbose_name='Value CSV',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_date",
|
name='value_date',
|
||||||
field=models.DateTimeField(
|
field=models.DateTimeField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value date",
|
verbose_name='Value date',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_enum",
|
name='value_enum',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.deletion.PROTECT,
|
on_delete=models.deletion.PROTECT,
|
||||||
related_name="eav_values",
|
related_name='eav_values',
|
||||||
to="eav.enumvalue",
|
to='eav.enumvalue',
|
||||||
verbose_name="Value enum",
|
verbose_name='Value enum',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_float",
|
name='value_float',
|
||||||
field=models.FloatField(
|
field=models.FloatField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value float",
|
verbose_name='Value float',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_int",
|
name='value_int',
|
||||||
field=models.BigIntegerField(
|
field=models.BigIntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value int",
|
verbose_name='Value int',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_json",
|
name='value_json',
|
||||||
field=models.JSONField(
|
field=models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
encoder=DjangoJSONEncoder,
|
encoder=DjangoJSONEncoder,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value JSON",
|
verbose_name='Value JSON',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="value_text",
|
name='value_text',
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Value text",
|
verbose_name='Value text',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ class Migration(migrations.Migration):
|
||||||
"""Migration to use BigAutoField as default for all models."""
|
"""Migration to use BigAutoField as default for all models."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("eav", "0009_enchance_naming"),
|
('eav', '0009_enchance_naming'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attribute",
|
model_name='attribute',
|
||||||
name="id",
|
name='id',
|
||||||
field=models.BigAutoField(
|
field=models.BigAutoField(
|
||||||
editable=False,
|
editable=False,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
|
|
@ -19,8 +19,8 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="enumgroup",
|
model_name='enumgroup',
|
||||||
name="id",
|
name='id',
|
||||||
field=models.BigAutoField(
|
field=models.BigAutoField(
|
||||||
editable=False,
|
editable=False,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
|
|
@ -28,8 +28,8 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="enumvalue",
|
model_name='enumvalue',
|
||||||
name="id",
|
name='id',
|
||||||
field=models.BigAutoField(
|
field=models.BigAutoField(
|
||||||
editable=False,
|
editable=False,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
|
|
@ -37,8 +37,8 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="value",
|
model_name='value',
|
||||||
name="id",
|
name='id',
|
||||||
field=models.BigAutoField(
|
field=models.BigAutoField(
|
||||||
editable=False,
|
editable=False,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
"""Update default values and meta options for Attribute and Value models."""
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("eav", "0010_dynamic_pk_type_for_models"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="attribute",
|
|
||||||
options={
|
|
||||||
"ordering": ("name",),
|
|
||||||
"verbose_name": "Attribute",
|
|
||||||
"verbose_name_plural": "Attributes",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="attribute",
|
|
||||||
name="description",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
default="",
|
|
||||||
help_text="Short description",
|
|
||||||
max_length=256,
|
|
||||||
verbose_name="Description",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="value",
|
|
||||||
name="value_text",
|
|
||||||
field=models.TextField(blank=True, default="", verbose_name="Value text"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
"""
|
|
||||||
Add uniqueness and integrity constraints to the Value model.
|
|
||||||
|
|
||||||
This migration adds database-level constraints to ensure:
|
|
||||||
1. Each entity (identified by UUID) can have only one value per attribute
|
|
||||||
2. Each entity (identified by integer ID) can have only one value per attribute
|
|
||||||
3. Each value must use either entity_id OR entity_uuid, never both or neither
|
|
||||||
|
|
||||||
These constraints ensure data integrity by preventing duplicate attribute values
|
|
||||||
for the same entity and enforcing the XOR relationship between the two types of
|
|
||||||
entity identification (integer ID vs UUID).
|
|
||||||
"""
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("eav", "0011_update_defaults_and_meta"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("entity_ct", "attribute", "entity_uuid"),
|
|
||||||
name="unique_entity_uuid_per_attribute",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("entity_ct", "attribute", "entity_id"),
|
|
||||||
name="unique_entity_id_per_attribute",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
check=models.Q(
|
|
||||||
models.Q(
|
|
||||||
("entity_id__isnull", False),
|
|
||||||
("entity_uuid__isnull", True),
|
|
||||||
),
|
|
||||||
models.Q(
|
|
||||||
("entity_id__isnull", True),
|
|
||||||
("entity_uuid__isnull", False),
|
|
||||||
),
|
|
||||||
_connector="OR",
|
|
||||||
),
|
|
||||||
name="ensure_entity_id_xor_entity_uuid",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -17,9 +17,9 @@ from .value import Value
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Attribute",
|
"Attribute",
|
||||||
"EAVModelMeta",
|
|
||||||
"Entity",
|
|
||||||
"EnumGroup",
|
"EnumGroup",
|
||||||
"EnumValue",
|
"EnumValue",
|
||||||
"Value",
|
"Value",
|
||||||
|
"Entity",
|
||||||
|
"EAVModelMeta",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
# ruff: noqa: UP007
|
# ruff: noqa: UP007
|
||||||
|
|
||||||
from __future__ import annotations
|
from typing import TYPE_CHECKING, Optional, Tuple # noqa: UP035
|
||||||
|
|
||||||
import warnings
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
@ -82,36 +79,40 @@ class Attribute(models.Model):
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
ynu.values.add(yes, no, unknown)
|
ynu.values.add(yes, no, unknown)
|
||||||
|
|
||||||
Attribute.objects.create(name='has fever?',
|
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
||||||
datatype=Attribute.TYPE_ENUM,
|
|
||||||
enum_group=ynu
|
|
||||||
)
|
|
||||||
# = <Attribute: has fever? (Multiple Choice)>
|
# = <Attribute: has fever? (Multiple Choice)>
|
||||||
|
|
||||||
.. warning:: Once an Attribute has been used by an entity, you can not
|
.. warning:: Once an Attribute has been used by an entity, you can not
|
||||||
change it's datatype.
|
change it's datatype.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TYPE_TEXT = "text"
|
objects = AttributeManager()
|
||||||
TYPE_FLOAT = "float"
|
|
||||||
TYPE_INT = "int"
|
class Meta:
|
||||||
TYPE_DATE = "date"
|
ordering = ['name']
|
||||||
TYPE_BOOLEAN = "bool"
|
verbose_name = _('Attribute')
|
||||||
TYPE_OBJECT = "object"
|
verbose_name_plural = _('Attributes')
|
||||||
TYPE_ENUM = "enum"
|
|
||||||
TYPE_JSON = "json"
|
TYPE_TEXT = 'text'
|
||||||
TYPE_CSV = "csv"
|
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 = (
|
DATATYPE_CHOICES = (
|
||||||
(TYPE_TEXT, _("Text")),
|
(TYPE_TEXT, _('Text')),
|
||||||
(TYPE_DATE, _("Date")),
|
(TYPE_DATE, _('Date')),
|
||||||
(TYPE_FLOAT, _("Float")),
|
(TYPE_FLOAT, _('Float')),
|
||||||
(TYPE_INT, _("Integer")),
|
(TYPE_INT, _('Integer')),
|
||||||
(TYPE_BOOLEAN, _("True / False")),
|
(TYPE_BOOLEAN, _('True / False')),
|
||||||
(TYPE_OBJECT, _("Django Object")),
|
(TYPE_OBJECT, _('Django Object')),
|
||||||
(TYPE_ENUM, _("Multiple Choice")),
|
(TYPE_ENUM, _('Multiple Choice')),
|
||||||
(TYPE_JSON, _("JSON Object")),
|
(TYPE_JSON, _('JSON Object')),
|
||||||
(TYPE_CSV, _("Comma-Separated-Value")),
|
(TYPE_CSV, _('Comma-Separated-Value')),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Core attributes
|
# Core attributes
|
||||||
|
|
@ -120,13 +121,13 @@ class Attribute(models.Model):
|
||||||
datatype = EavDatatypeField(
|
datatype = EavDatatypeField(
|
||||||
choices=DATATYPE_CHOICES,
|
choices=DATATYPE_CHOICES,
|
||||||
max_length=6,
|
max_length=6,
|
||||||
verbose_name=_("Data Type"),
|
verbose_name=_('Data Type'),
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=CHARFIELD_LENGTH,
|
max_length=CHARFIELD_LENGTH,
|
||||||
help_text=_("User-friendly attribute name"),
|
help_text=_('User-friendly attribute name'),
|
||||||
verbose_name=_("Name"),
|
verbose_name=_('Name'),
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -138,8 +139,8 @@ class Attribute(models.Model):
|
||||||
max_length=SLUGFIELD_MAX_LENGTH,
|
max_length=SLUGFIELD_MAX_LENGTH,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
help_text=_("Short unique attribute label"),
|
help_text=_('Short unique attribute label'),
|
||||||
verbose_name=_("Slug"),
|
verbose_name=_('Slug'),
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -150,13 +151,13 @@ class Attribute(models.Model):
|
||||||
"""
|
"""
|
||||||
required = models.BooleanField(
|
required = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Required"),
|
verbose_name=_('Required'),
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_ct = models.ManyToManyField(
|
entity_ct = models.ManyToManyField(
|
||||||
ContentType,
|
ContentType,
|
||||||
blank=True,
|
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.
|
This field allows you to specify a relationship with any number of content types.
|
||||||
|
|
@ -165,67 +166,49 @@ class Attribute(models.Model):
|
||||||
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
: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",
|
"eav.EnumGroup",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Choice Group"),
|
verbose_name=_('Choice Group'),
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
max_length=256,
|
max_length=256,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
null=True,
|
||||||
help_text=_("Short description"),
|
help_text=_('Short description'),
|
||||||
verbose_name=_("Description"),
|
verbose_name=_('Description'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Useful meta-information
|
# Useful meta-information
|
||||||
|
|
||||||
display_order = models.PositiveIntegerField(
|
display_order = models.PositiveIntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
verbose_name=_("Display order"),
|
verbose_name=_('Display order'),
|
||||||
)
|
)
|
||||||
|
|
||||||
modified = models.DateTimeField(
|
modified = models.DateTimeField(
|
||||||
auto_now=True,
|
auto_now=True,
|
||||||
verbose_name=_("Modified"),
|
verbose_name=_('Modified'),
|
||||||
)
|
)
|
||||||
|
|
||||||
created = models.DateTimeField(
|
created = models.DateTimeField(
|
||||||
default=timezone.now,
|
default=timezone.now,
|
||||||
editable=False,
|
editable=False,
|
||||||
verbose_name=_("Created"),
|
verbose_name=_('Created'),
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = AttributeManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ("name",)
|
|
||||||
verbose_name = _("Attribute")
|
|
||||||
verbose_name_plural = _("Attributes")
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} ({self.get_datatype_display()})"
|
return f'{self.name} ({self.get_datatype_display()})'
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def natural_key(self) -> Tuple[str, str]: # noqa: UP006
|
||||||
"""
|
|
||||||
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.
|
Retrieve the natural key for the Attribute instance.
|
||||||
|
|
||||||
The natural key for an Attribute is defined by its `name` and `slug`. This
|
The natural key for an Attribute is defined by its `name` and `slug`. This method
|
||||||
method returns a tuple containing these two attributes of the instance.
|
returns a tuple containing these two attributes of the instance.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -250,19 +233,19 @@ class Attribute(models.Model):
|
||||||
method to look elsewhere for additional attribute specific
|
method to look elsewhere for additional attribute specific
|
||||||
validators to return as well as the default, built-in one.
|
validators to return as well as the default, built-in one.
|
||||||
"""
|
"""
|
||||||
datatype_validators = {
|
DATATYPE_VALIDATORS = {
|
||||||
"text": validate_text,
|
'text': validate_text,
|
||||||
"float": validate_float,
|
'float': validate_float,
|
||||||
"int": validate_int,
|
'int': validate_int,
|
||||||
"date": validate_date,
|
'date': validate_date,
|
||||||
"bool": validate_bool,
|
'bool': validate_bool,
|
||||||
"object": validate_object,
|
'object': validate_object,
|
||||||
"enum": validate_enum,
|
'enum': validate_enum,
|
||||||
"json": validate_json,
|
'json': validate_json,
|
||||||
"csv": validate_csv,
|
'csv': validate_csv,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [datatype_validators[self.datatype]]
|
return [DATATYPE_VALIDATORS[self.datatype]]
|
||||||
|
|
||||||
def validate_value(self, value):
|
def validate_value(self, value):
|
||||||
"""
|
"""
|
||||||
|
|
@ -277,10 +260,21 @@ class Attribute(models.Model):
|
||||||
value = value.value
|
value = value.value
|
||||||
if not self.enum_group.values.filter(value=value).exists():
|
if not self.enum_group.values.filter(value=value).exists():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(val)s is not a valid choice for %(attr)s")
|
_('%(val)s is not a valid choice for %(attr)s')
|
||||||
% {"val": value, "attr": self},
|
% {'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):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Validates the attribute. Will raise ``ValidationError`` if the
|
Validates the attribute. Will raise ``ValidationError`` if the
|
||||||
|
|
@ -289,33 +283,12 @@ class Attribute(models.Model):
|
||||||
"""
|
"""
|
||||||
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
||||||
raise ValidationError(
|
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:
|
if self.datatype != self.TYPE_ENUM and self.enum_group:
|
||||||
raise ValidationError(
|
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):
|
|
||||||
"""Perform field-specific validation on the model's fields.
|
|
||||||
|
|
||||||
This method extends the default field cleaning process to include
|
|
||||||
custom validation for the slug field.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
exclude (list): Fields to exclude from cleaning.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: If the slug is not a valid Python identifier.
|
|
||||||
"""
|
|
||||||
super().clean_fields(exclude=exclude)
|
|
||||||
|
|
||||||
if not self.slug.isidentifier():
|
|
||||||
warnings.warn(
|
|
||||||
f"Slug '{self.slug}' is not a valid Python identifier. "
|
|
||||||
+ "Consider updating it.",
|
|
||||||
stacklevel=3,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_choices(self):
|
def get_choices(self):
|
||||||
|
|
@ -345,20 +318,20 @@ class Attribute(models.Model):
|
||||||
ct = ContentType.objects.get_for_model(entity)
|
ct = ContentType.objects.get_for_model(entity)
|
||||||
|
|
||||||
entity_filter = {
|
entity_filter = {
|
||||||
"entity_ct": ct,
|
'entity_ct': ct,
|
||||||
"attribute": self,
|
'attribute': self,
|
||||||
f"{get_entity_pk_type(entity)}": entity.pk,
|
f'{get_entity_pk_type(entity)}': entity.pk,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value_obj = self.value_set.get(**entity_filter)
|
value_obj = self.value_set.get(**entity_filter)
|
||||||
except Value.DoesNotExist:
|
except Value.DoesNotExist:
|
||||||
if value is None or value == "":
|
if value is None or value == '':
|
||||||
return
|
return
|
||||||
|
|
||||||
value_obj = Value.objects.create(**entity_filter)
|
value_obj = Value.objects.create(**entity_filter)
|
||||||
|
|
||||||
if value is None or value == "":
|
if value is None or value == '':
|
||||||
value_obj.delete()
|
value_obj.delete()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ class Entity:
|
||||||
model instance we are attached to is saved. This allows us to call
|
model instance we are attached to is saved. This allows us to call
|
||||||
:meth:`validate_attributes` before the entity is saved.
|
:meth:`validate_attributes` before the entity is saved.
|
||||||
"""
|
"""
|
||||||
instance = kwargs["instance"]
|
instance = kwargs['instance']
|
||||||
entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001
|
entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr)
|
||||||
entity.validate_attributes()
|
entity.validate_attributes()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -34,8 +34,8 @@ class Entity:
|
||||||
Post save handler attached to self.instance. Calls :meth:`save` when
|
Post save handler attached to self.instance. Calls :meth:`save` when
|
||||||
the model instance we are attached to is saved.
|
the model instance we are attached to is saved.
|
||||||
"""
|
"""
|
||||||
instance = kwargs["instance"]
|
instance = kwargs['instance']
|
||||||
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
entity = getattr(instance, instance._eav_config_cls.eav_attr)
|
||||||
entity.save()
|
entity.save()
|
||||||
|
|
||||||
def __init__(self, instance) -> None:
|
def __init__(self, instance) -> None:
|
||||||
|
|
@ -58,14 +58,14 @@ class Entity:
|
||||||
class:`Value` object, otherwise it hasn't been set, so it returns
|
class:`Value` object, otherwise it hasn't been set, so it returns
|
||||||
None.
|
None.
|
||||||
"""
|
"""
|
||||||
if not name.startswith("_"):
|
if not name.startswith('_'):
|
||||||
try:
|
try:
|
||||||
attribute = self.get_attribute_by_slug(name)
|
attribute = self.get_attribute_by_slug(name)
|
||||||
except Attribute.DoesNotExist as err:
|
except Attribute.DoesNotExist:
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
_("%(obj)s has no EAV attribute named %(attr)s")
|
_('%(obj)s has no EAV attribute named %(attr)s')
|
||||||
% {"obj": self.instance, "attr": name},
|
% {'obj': self.instance, 'attr': name},
|
||||||
) from err
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.get_value_by_attribute(attribute).value
|
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
|
Return a query set of all :class:`Attribute` objects that can be set
|
||||||
for this entity.
|
for this entity.
|
||||||
"""
|
"""
|
||||||
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
|
return self.instance._eav_config_cls.get_attributes(
|
||||||
instance=self.instance,
|
instance=self.instance,
|
||||||
).order_by("display_order")
|
).order_by('display_order')
|
||||||
|
|
||||||
def _hasattr(self, attribute_slug):
|
def _hasattr(self, attribute_slug):
|
||||||
"""
|
"""
|
||||||
|
|
@ -137,29 +137,28 @@ class Entity:
|
||||||
if value is None:
|
if value is None:
|
||||||
if attribute.required:
|
if attribute.required:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%s EAV field cannot be blank") % attribute.slug,
|
_(f'{attribute.slug} EAV field cannot be blank'),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
attribute.validate_value(value)
|
attribute.validate_value(value)
|
||||||
except ValidationError as err:
|
except ValidationError as e:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(attr)s EAV field %(err)s")
|
_('%(attr)s EAV field %(err)s')
|
||||||
% {"attr": attribute.slug, "err": err},
|
% {'attr': attribute.slug, 'err': e},
|
||||||
) from err
|
)
|
||||||
|
|
||||||
illegal = values_dict or (
|
illegal = values_dict or (
|
||||||
self.get_object_attributes() - self.get_all_attribute_slugs()
|
self.get_object_attributes() - self.get_all_attribute_slugs()
|
||||||
)
|
)
|
||||||
|
|
||||||
if illegal:
|
if illegal:
|
||||||
message = (
|
raise IllegalAssignmentException(
|
||||||
"Instance of the class {} cannot have values for attributes: {}."
|
'Instance of the class {} cannot have values for attributes: {}.'.format(
|
||||||
).format(
|
self.instance.__class__,
|
||||||
self.instance.__class__,
|
', '.join(illegal),
|
||||||
", ".join(illegal),
|
),
|
||||||
)
|
)
|
||||||
raise IllegalAssignmentException(message)
|
|
||||||
|
|
||||||
def get_values_dict(self):
|
def get_values_dict(self):
|
||||||
return {v.attribute.slug: v.value for v in self.get_values()}
|
return {v.attribute.slug: v.value for v in self.get_values()}
|
||||||
|
|
@ -167,15 +166,15 @@ class Entity:
|
||||||
def get_values(self):
|
def get_values(self):
|
||||||
"""Get all set :class:`Value` objects for self.instance."""
|
"""Get all set :class:`Value` objects for self.instance."""
|
||||||
entity_filter = {
|
entity_filter = {
|
||||||
"entity_ct": self.ct,
|
'entity_ct': self.ct,
|
||||||
f"{get_entity_pk_type(self.instance)}": self.instance.pk,
|
f'{get_entity_pk_type(self.instance)}': self.instance.pk,
|
||||||
}
|
}
|
||||||
|
|
||||||
return Value.objects.filter(**entity_filter).select_related()
|
return Value.objects.filter(**entity_filter).select_related()
|
||||||
|
|
||||||
def get_all_attribute_slugs(self):
|
def get_all_attribute_slugs(self):
|
||||||
"""Returns a list of slugs for all attributes available to this entity."""
|
"""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):
|
def get_attribute_by_slug(self, slug):
|
||||||
"""Returns a single :class:`Attribute` with *slug*."""
|
"""Returns a single :class:`Attribute` with *slug*."""
|
||||||
|
|
@ -190,7 +189,7 @@ class Entity:
|
||||||
Returns entity instance attributes, except for
|
Returns entity instance attributes, except for
|
||||||
``instance`` and ``ct`` which are used internally.
|
``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):
|
def __iter__(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
from __future__ import annotations
|
from typing import TYPE_CHECKING, Any, Tuple
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import ManyToManyField
|
from django.db.models import ManyToManyField
|
||||||
|
|
@ -23,33 +21,33 @@ class EnumGroup(models.Model):
|
||||||
See :class:`EnumValue` for an example.
|
See :class:`EnumValue` for an example.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = EnumGroupManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('EnumGroup')
|
||||||
|
verbose_name_plural = _('EnumGroups')
|
||||||
|
|
||||||
id = get_pk_format()
|
id = get_pk_format()
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
unique=True,
|
unique=True,
|
||||||
max_length=CHARFIELD_LENGTH,
|
max_length=CHARFIELD_LENGTH,
|
||||||
verbose_name=_("Name"),
|
verbose_name=_('Name'),
|
||||||
)
|
)
|
||||||
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
|
values: "ManyToManyField[EnumValue, Any]" = ManyToManyField(
|
||||||
"eav.EnumValue",
|
"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:
|
def __str__(self) -> str:
|
||||||
"""String representation of `EnumGroup` instance."""
|
"""String representation of `EnumGroup` instance."""
|
||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of `EnumGroup` object."""
|
"""String representation of `EnumGroup` object."""
|
||||||
return f"<EnumGroup {self.name}>"
|
return f'<EnumGroup {self.name}>'
|
||||||
|
|
||||||
def natural_key(self) -> tuple[str]:
|
def natural_key(self) -> Tuple[str]:
|
||||||
"""
|
"""
|
||||||
Retrieve the natural key for the EnumGroup instance.
|
Retrieve the natural key for the EnumGroup instance.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from __future__ import annotations
|
from typing import Tuple
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
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*.
|
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = EnumValueManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('EnumValue')
|
||||||
|
verbose_name_plural = _('EnumValues')
|
||||||
|
|
||||||
id = get_pk_format()
|
id = get_pk_format()
|
||||||
|
|
||||||
value = models.CharField(
|
value = models.CharField(
|
||||||
_("Value"),
|
_('Value'),
|
||||||
db_index=True,
|
db_index=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
max_length=SLUGFIELD_MAX_LENGTH,
|
max_length=SLUGFIELD_MAX_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = EnumValueManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("EnumValue")
|
|
||||||
verbose_name_plural = _("EnumValues")
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""String representation of `EnumValue` instance."""
|
"""String representation of `EnumValue` instance."""
|
||||||
return str(
|
return str(
|
||||||
|
|
@ -58,9 +58,9 @@ class EnumValue(models.Model):
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of `EnumValue` object."""
|
"""String representation of `EnumValue` object."""
|
||||||
return f"<EnumValue {self.value}>"
|
return f'<EnumValue {self.value}>'
|
||||||
|
|
||||||
def natural_key(self) -> tuple[str]:
|
def natural_key(self) -> Tuple[str]:
|
||||||
"""
|
"""
|
||||||
Retrieve the natural key for the EnumValue instance.
|
Retrieve the natural key for the EnumValue instance.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
# ruff: noqa: UP007
|
# ruff: noqa: UP007
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
from typing import TYPE_CHECKING, Optional, Tuple
|
||||||
|
|
||||||
from django.contrib.contenttypes import fields as generic
|
from django.contrib.contenttypes import fields as generic
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
@ -44,14 +43,20 @@ class Value(models.Model):
|
||||||
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = ValueManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Value')
|
||||||
|
verbose_name_plural = _('Values')
|
||||||
|
|
||||||
id = get_pk_format()
|
id = get_pk_format()
|
||||||
|
|
||||||
# Direct foreign keys
|
# Direct foreign keys
|
||||||
attribute: ForeignKey[Attribute] = ForeignKey(
|
attribute: "ForeignKey[Attribute]" = ForeignKey(
|
||||||
"eav.Attribute",
|
"eav.Attribute",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
verbose_name=_("Attribute"),
|
verbose_name=_('Attribute'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entity generic relationships. Rather than rely on database casting,
|
# Entity generic relationships. Rather than rely on database casting,
|
||||||
|
|
@ -60,73 +65,73 @@ class Value(models.Model):
|
||||||
entity_id = models.IntegerField(
|
entity_id = models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Entity id"),
|
verbose_name=_('Entity id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_uuid = models.UUIDField(
|
entity_uuid = models.UUIDField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Entity uuid"),
|
verbose_name=_('Entity uuid'),
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_ct = ForeignKey(
|
entity_ct = ForeignKey(
|
||||||
ContentType,
|
ContentType,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="value_entities",
|
related_name='value_entities',
|
||||||
verbose_name=_("Entity ct"),
|
verbose_name=_('Entity ct'),
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_pk_int = generic.GenericForeignKey(
|
entity_pk_int = generic.GenericForeignKey(
|
||||||
ct_field="entity_ct",
|
ct_field='entity_ct',
|
||||||
fk_field="entity_id",
|
fk_field='entity_id',
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_pk_uuid = generic.GenericForeignKey(
|
entity_pk_uuid = generic.GenericForeignKey(
|
||||||
ct_field="entity_ct",
|
ct_field='entity_ct',
|
||||||
fk_field="entity_uuid",
|
fk_field='entity_uuid',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Model attributes
|
# Model attributes
|
||||||
created = models.DateTimeField(
|
created = models.DateTimeField(
|
||||||
default=timezone.now,
|
default=timezone.now,
|
||||||
verbose_name=_("Created"),
|
verbose_name=_('Created'),
|
||||||
)
|
)
|
||||||
|
|
||||||
modified = models.DateTimeField(
|
modified = models.DateTimeField(
|
||||||
auto_now=True,
|
auto_now=True,
|
||||||
verbose_name=_("Modified"),
|
verbose_name=_('Modified'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Value attributes
|
# Value attributes
|
||||||
value_bool = models.BooleanField(
|
value_bool = models.BooleanField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Value bool"),
|
verbose_name=_('Value bool'),
|
||||||
)
|
)
|
||||||
value_csv = CSVField(
|
value_csv = CSVField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Value CSV"),
|
verbose_name=_('Value CSV'),
|
||||||
)
|
)
|
||||||
value_date = models.DateTimeField(
|
value_date = models.DateTimeField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Value date"),
|
verbose_name=_('Value date'),
|
||||||
)
|
)
|
||||||
value_float = models.FloatField(
|
value_float = models.FloatField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Value float"),
|
verbose_name=_('Value float'),
|
||||||
)
|
)
|
||||||
value_int = models.BigIntegerField(
|
value_int = models.BigIntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Value int"),
|
verbose_name=_('Value int'),
|
||||||
)
|
)
|
||||||
value_text = models.TextField(
|
value_text = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
null=True,
|
||||||
verbose_name=_("Value text"),
|
verbose_name=_('Value text'),
|
||||||
)
|
)
|
||||||
|
|
||||||
value_json = models.JSONField(
|
value_json = models.JSONField(
|
||||||
|
|
@ -134,23 +139,23 @@ class Value(models.Model):
|
||||||
encoder=DjangoJSONEncoder,
|
encoder=DjangoJSONEncoder,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=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",
|
"eav.EnumValue",
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="eav_values",
|
related_name='eav_values',
|
||||||
verbose_name=_("Value enum"),
|
verbose_name=_('Value enum'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Value object relationship
|
# Value object relationship
|
||||||
generic_value_id = models.IntegerField(
|
generic_value_id = models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("Generic value id"),
|
verbose_name=_('Generic value id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
generic_value_ct = ForeignKey(
|
generic_value_ct = ForeignKey(
|
||||||
|
|
@ -158,38 +163,29 @@ class Value(models.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="value_values",
|
related_name='value_values',
|
||||||
verbose_name=_("Generic value content type"),
|
verbose_name=_('Generic value content type'),
|
||||||
)
|
)
|
||||||
|
|
||||||
value_object = generic.GenericForeignKey(
|
value_object = generic.GenericForeignKey(
|
||||||
ct_field="generic_value_ct",
|
ct_field='generic_value_ct',
|
||||||
fk_field="generic_value_id",
|
fk_field='generic_value_id',
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ValueManager()
|
def natural_key(self) -> Tuple[Tuple[str, str], int, str]:
|
||||||
|
"""
|
||||||
|
Retrieve the natural key for the Value instance.
|
||||||
|
|
||||||
class Meta:
|
The natural key for a Value is a combination of its `attribute` natural key,
|
||||||
verbose_name = _("Value")
|
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
|
||||||
verbose_name_plural = _("Values")
|
three elements.
|
||||||
|
|
||||||
constraints: ClassVar[list[models.Constraint]] = [
|
Returns
|
||||||
models.UniqueConstraint(
|
-------
|
||||||
fields=["entity_ct", "attribute", "entity_uuid"],
|
tuple: A tuple containing the natural key of the attribute, entity ID,
|
||||||
name="unique_entity_uuid_per_attribute",
|
and entity UUID of the Value instance.
|
||||||
),
|
"""
|
||||||
models.UniqueConstraint(
|
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
|
||||||
fields=["entity_ct", "attribute", "entity_id"],
|
|
||||||
name="unique_entity_id_per_attribute",
|
|
||||||
),
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=(
|
|
||||||
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
|
|
||||||
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
|
|
||||||
),
|
|
||||||
name="ensure_entity_id_xor_entity_uuid",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""String representation of a Value."""
|
"""String representation of a Value."""
|
||||||
|
|
@ -206,27 +202,12 @@ class Value(models.Model):
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def natural_key(self) -> tuple[tuple[str, str], int, str]:
|
|
||||||
"""
|
|
||||||
Retrieve the natural key for the Value instance.
|
|
||||||
|
|
||||||
The natural key for a Value is a combination of its `attribute` natural key,
|
|
||||||
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
|
|
||||||
three elements.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
tuple: A tuple containing the natural key of the attribute, entity ID,
|
|
||||||
and entity UUID of the Value instance.
|
|
||||||
"""
|
|
||||||
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
|
|
||||||
|
|
||||||
def _get_value(self):
|
def _get_value(self):
|
||||||
"""Return the python object this value is holding."""
|
"""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):
|
def _set_value(self, new_value):
|
||||||
"""Set the object this value is holding."""
|
"""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)
|
value = property(_get_value, _set_value)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
This module contains custom :class:`EavQuerySet` class used for overriding
|
This module contains custom :class:`EavQuerySet` class used for overriding
|
||||||
relational operators and pure functions for rewriting Q-expressions.
|
relational operators and pure functions for rewriting Q-expressions.
|
||||||
|
|
@ -18,14 +19,14 @@ Q-expressions need to be rewritten for two reasons:
|
||||||
2. To ensure that Q-expression tree is compiled to valid SQL.
|
2. To ensure that Q-expression tree is compiled to valid SQL.
|
||||||
For details see: :func:`rewrite_q_expr`.
|
For details see: :func:`rewrite_q_expr`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from itertools import count
|
from itertools import count
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||||
from django.db.models import Case, IntegerField, Q, When
|
from django.db.models import Case, IntegerField, Q, When
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.utils import NotSupportedError
|
from django.db.utils import NotSupportedError
|
||||||
|
from django.db.models import Subquery
|
||||||
|
|
||||||
from eav.models import Attribute, EnumValue, Value
|
from eav.models import Attribute, EnumValue, Value
|
||||||
|
|
||||||
|
|
@ -42,9 +43,9 @@ def is_eav_and_leaf(expr, gr_name):
|
||||||
bool
|
bool
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
getattr(expr, "connector", None) == "AND"
|
getattr(expr, 'connector', None) == 'AND'
|
||||||
and len(expr.children) == 1
|
and len(expr.children) == 1
|
||||||
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
|
and expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
# We are only interested in Qs.
|
# We are only interested in Qs.
|
||||||
|
|
||||||
if isinstance(expr, Q):
|
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
|
gr_name = config_cls.generic_relation_attr
|
||||||
|
|
||||||
# Recursively check child nodes.
|
# Recursively check child nodes.
|
||||||
|
|
@ -111,18 +112,18 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
if len(rewritable) > 1:
|
if len(rewritable) > 1:
|
||||||
q = None
|
q = None
|
||||||
# Save nodes which shouldn't be merged (non-EAV).
|
# Save nodes which shouldn't be merged (non-EAV).
|
||||||
other = [c for c in expr.children if c not in rewritable]
|
other = [c for c in expr.children if not c in rewritable]
|
||||||
|
|
||||||
for child in rewritable:
|
for child in rewritable:
|
||||||
if not (child.children and len(child.children) == 1):
|
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,
|
# Child to be merged is always a terminal Q node,
|
||||||
# i.e. it's an AND expression with attribute-value tuple child.
|
# i.e. it's an AND expression with attribute-value tuple child.
|
||||||
attrval = child.children[0]
|
attrval = child.children[0]
|
||||||
if not isinstance(attrval, tuple):
|
if not isinstance(attrval, tuple):
|
||||||
raise TypeError("Attribute-value must be a tuple")
|
raise AssertionError('Attribute-value must be a tuple')
|
||||||
|
|
||||||
fname = f"{gr_name}__in"
|
fname = '{}__in'.format(gr_name)
|
||||||
|
|
||||||
# Child can be either a 'eav_values__in' or 'pk__in' query.
|
# Child can be either a 'eav_values__in' or 'pk__in' query.
|
||||||
# If it's the former then transform it into the latter.
|
# If it's the former then transform it into the latter.
|
||||||
|
|
@ -130,7 +131,7 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
# If so, reverse it back to QuerySet so that set operators
|
# If so, reverse it back to QuerySet so that set operators
|
||||||
# can be applied.
|
# can be applied.
|
||||||
|
|
||||||
if attrval[0] == fname or hasattr(attrval[1], "__contains__"):
|
if attrval[0] == fname or hasattr(attrval[1], '__contains__'):
|
||||||
# Create model queryset.
|
# Create model queryset.
|
||||||
_q = model_cls.objects.filter(**{fname: attrval[1]})
|
_q = model_cls.objects.filter(**{fname: attrval[1]})
|
||||||
else:
|
else:
|
||||||
|
|
@ -139,17 +140,17 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
|
|
||||||
# Explicitly check for None. 'or' doesn't work here
|
# Explicitly check for None. 'or' doesn't work here
|
||||||
# as empty QuerySet, which is valid, is falsy.
|
# as empty QuerySet, which is valid, is falsy.
|
||||||
q = q if q is not None else _q
|
q = q if q != None else _q
|
||||||
|
|
||||||
if expr.connector == "AND":
|
if expr.connector == 'AND':
|
||||||
q &= _q
|
q &= _q
|
||||||
else:
|
else:
|
||||||
q |= _q
|
q |= _q
|
||||||
|
|
||||||
# If any two children were merged,
|
# If any two children were merged,
|
||||||
# update parent expression.
|
# update parent expression.
|
||||||
if q is not None:
|
if q != None:
|
||||||
expr.children = [*other, ("pk__in", q)]
|
expr.children = other + [('pk__in', q)]
|
||||||
|
|
||||||
return expr
|
return expr
|
||||||
|
|
||||||
|
|
@ -169,9 +170,9 @@ def eav_filter(func):
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if isinstance(arg, Q):
|
if isinstance(arg, Q):
|
||||||
# Modify Q objects (warning: recursion ahead).
|
# Modify Q objects (warning: recursion ahead).
|
||||||
arg = expand_q_filters(arg, self.model) # noqa: PLW2901
|
arg = expand_q_filters(arg, self.model)
|
||||||
# Rewrite Q-expression to safeform.
|
# Rewrite Q-expression to safeform.
|
||||||
arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901
|
arg = rewrite_q_expr(self.model, arg)
|
||||||
nargs.append(arg)
|
nargs.append(arg)
|
||||||
|
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
|
|
@ -179,10 +180,9 @@ def eav_filter(func):
|
||||||
nkey, nval = expand_eav_filter(self.model, key, value)
|
nkey, nval = expand_eav_filter(self.model, key, value)
|
||||||
|
|
||||||
if nkey in nkwargs:
|
if nkey in nkwargs:
|
||||||
# Add filter to check if matching entity_id is
|
# Add filter to check if matching entity_id is in the previous queryset with same nkey
|
||||||
# in the previous queryset with same nkey
|
|
||||||
nkwargs[nkey] = nval.filter(
|
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()
|
).distinct()
|
||||||
else:
|
else:
|
||||||
nkwargs.update({nkey: nval})
|
nkwargs.update({nkey: nval})
|
||||||
|
|
@ -229,27 +229,27 @@ def expand_eav_filter(model_cls, key, value):
|
||||||
key = 'eav_values__in'
|
key = 'eav_values__in'
|
||||||
value = Values.objects.filter(value_int=5, attribute__slug='height')
|
value = Values.objects.filter(value_int=5, attribute__slug='height')
|
||||||
"""
|
"""
|
||||||
fields = key.split("__")
|
fields = key.split('__')
|
||||||
config_cls = getattr(model_cls, "_eav_config_cls", None)
|
config_cls = getattr(model_cls, '_eav_config_cls', None)
|
||||||
|
|
||||||
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
|
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
|
||||||
slug = fields[1]
|
slug = fields[1]
|
||||||
gr_name = config_cls.generic_relation_attr
|
gr_name = config_cls.generic_relation_attr
|
||||||
datatype = Attribute.objects.get(slug=slug).datatype
|
datatype = Attribute.objects.get(slug=slug).datatype
|
||||||
|
|
||||||
value_key = ""
|
value_key = ''
|
||||||
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
|
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
|
||||||
lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004
|
lookup = '__value__{}'.format(fields[2]) if len(fields) > 2 else '__value'
|
||||||
value_key = f"value_{datatype}{lookup}"
|
value_key = 'value_{}{}'.format(datatype, lookup)
|
||||||
elif datatype == Attribute.TYPE_OBJECT:
|
elif datatype == Attribute.TYPE_OBJECT:
|
||||||
value_key = "generic_value_id"
|
value_key = 'generic_value_id'
|
||||||
else:
|
else:
|
||||||
lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004
|
lookup = '__{}'.format(fields[2]) if len(fields) > 2 else ''
|
||||||
value_key = f"value_{datatype}{lookup}"
|
value_key = 'value_{}{}'.format(datatype, lookup)
|
||||||
kwargs = {value_key: value, "attribute__slug": slug}
|
kwargs = {value_key: value, 'attribute__slug': slug}
|
||||||
value = Value.objects.filter(**kwargs)
|
value = Value.objects.filter(**kwargs)
|
||||||
|
|
||||||
return f"{gr_name}__in", value
|
return '%s__in' % gr_name, value
|
||||||
|
|
||||||
# Not an eav field, so keep as is
|
# Not an eav field, so keep as is
|
||||||
return key, value
|
return key, value
|
||||||
|
|
@ -266,7 +266,7 @@ class EavQuerySet(QuerySet):
|
||||||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``Manager`` filter method.
|
the ``Manager`` filter method.
|
||||||
"""
|
"""
|
||||||
return super().filter(*args, **kwargs)
|
return super(EavQuerySet, self).filter(*args, **kwargs)
|
||||||
|
|
||||||
@eav_filter
|
@eav_filter
|
||||||
def exclude(self, *args, **kwargs):
|
def exclude(self, *args, **kwargs):
|
||||||
|
|
@ -274,7 +274,7 @@ class EavQuerySet(QuerySet):
|
||||||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``Manager`` exclude method.
|
the ``Manager`` exclude method.
|
||||||
"""
|
"""
|
||||||
return super().exclude(*args, **kwargs)
|
return super(EavQuerySet, self).exclude(*args, **kwargs)
|
||||||
|
|
||||||
@eav_filter
|
@eav_filter
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
|
|
@ -282,7 +282,7 @@ class EavQuerySet(QuerySet):
|
||||||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``Manager`` get method.
|
the ``Manager`` get method.
|
||||||
"""
|
"""
|
||||||
return super().get(*args, **kwargs)
|
return super(EavQuerySet, self).get(*args, **kwargs)
|
||||||
|
|
||||||
def order_by(self, *fields):
|
def order_by(self, *fields):
|
||||||
# Django only allows to order querysets by direct fields and
|
# Django only allows to order querysets by direct fields and
|
||||||
|
|
@ -292,20 +292,20 @@ class EavQuerySet(QuerySet):
|
||||||
# This will be slow, of course.
|
# This will be slow, of course.
|
||||||
order_clauses = []
|
order_clauses = []
|
||||||
query_clause = self
|
query_clause = self
|
||||||
config_cls = self.model._eav_config_cls # noqa: SLF001
|
config_cls = self.model._eav_config_cls
|
||||||
|
|
||||||
for term in [t.split("__") for t in fields]:
|
for term in [t.split('__') for t in fields]:
|
||||||
# Continue only for EAV attributes.
|
# Continue only for EAV attributes.
|
||||||
if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
if len(term) == 2 and term[0] == config_cls.eav_attr:
|
||||||
# Retrieve Attribute over which the ordering is performed.
|
# Retrieve Attribute over which the ordering is performed.
|
||||||
try:
|
try:
|
||||||
attr = Attribute.objects.get(slug=term[1])
|
attr = Attribute.objects.get(slug=term[1])
|
||||||
except ObjectDoesNotExist as err:
|
except ObjectDoesNotExist:
|
||||||
raise ObjectDoesNotExist(
|
raise ObjectDoesNotExist(
|
||||||
f'Cannot find EAV attribute "{term[1]}"',
|
'Cannot find EAV attribute "{}"'.format(term[1])
|
||||||
) from err
|
)
|
||||||
|
|
||||||
field_name = f"value_{attr.datatype}"
|
field_name = 'value_%s' % attr.datatype
|
||||||
|
|
||||||
pks_values = (
|
pks_values = (
|
||||||
Value.objects.filter(
|
Value.objects.filter(
|
||||||
|
|
@ -318,12 +318,12 @@ class EavQuerySet(QuerySet):
|
||||||
.order_by(
|
.order_by(
|
||||||
# Order values by their value-field of
|
# Order values by their value-field of
|
||||||
# appropriate attribute data-type.
|
# appropriate attribute data-type.
|
||||||
field_name,
|
field_name
|
||||||
)
|
)
|
||||||
.values_list(
|
.values_list(
|
||||||
# Retrieve only primary-keys of the entities
|
# Retrieve only primary-keys of the entities
|
||||||
# in the current queryset.
|
# in the current queryset.
|
||||||
"entity_id",
|
'entity_id',
|
||||||
field_name,
|
field_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -352,16 +352,16 @@ class EavQuerySet(QuerySet):
|
||||||
|
|
||||||
order_clause = Case(*when_clauses, output_field=IntegerField())
|
order_clause = Case(*when_clauses, output_field=IntegerField())
|
||||||
|
|
||||||
clause_name = "__".join(term)
|
clause_name = '__'.join(term)
|
||||||
# Use when-clause to construct
|
# Use when-clause to construct
|
||||||
# custom order-by clause.
|
# custom order-by clause.
|
||||||
query_clause = query_clause.annotate(**{clause_name: order_clause})
|
query_clause = query_clause.annotate(**{clause_name: order_clause})
|
||||||
|
|
||||||
order_clauses.append(clause_name)
|
order_clauses.append(clause_name)
|
||||||
|
|
||||||
elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
elif len(term) >= 2 and term[0] == config_cls.eav_attr:
|
||||||
raise NotSupportedError(
|
raise NotSupportedError(
|
||||||
"EAV does not support ordering through foreign-key chains",
|
'EAV does not support ordering through ' 'foreign-key chains'
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
from django.contrib.contenttypes import fields as generic
|
from django.contrib.contenttypes import fields as generic
|
||||||
from django.db.models.signals import post_init, post_save, pre_save
|
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.managers import EntityManager
|
||||||
from eav.models import Attribute, Entity, Value
|
from eav.models import Attribute, Entity, Value
|
||||||
|
|
||||||
|
from eav.logic.entity_pk import get_entity_pk_type
|
||||||
|
|
||||||
class EavConfig:
|
|
||||||
|
class EavConfig(object):
|
||||||
"""
|
"""
|
||||||
The default ``EavConfig`` class used if it is not overridden on registration.
|
The default ``EavConfig`` class used if it is not overridden on registration.
|
||||||
This is where all the default eav attribute names are defined.
|
This is where all the default eav attribute names are defined.
|
||||||
|
|
@ -28,10 +29,10 @@ class EavConfig:
|
||||||
if not overridden, it is not possible to query Values by Entities.
|
if not overridden, it is not possible to query Values by Entities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
manager_attr = "objects"
|
manager_attr = 'objects'
|
||||||
manager_only = False
|
manager_only = False
|
||||||
eav_attr = "eav"
|
eav_attr = 'eav'
|
||||||
generic_relation_attr = "eav_values"
|
generic_relation_attr = 'eav_values'
|
||||||
generic_relation_related_name = None
|
generic_relation_related_name = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -43,7 +44,7 @@ class EavConfig:
|
||||||
return Attribute.objects.all()
|
return Attribute.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class Registry:
|
class Registry(object):
|
||||||
"""
|
"""
|
||||||
Handles registration through the
|
Handles registration through the
|
||||||
:meth:`register` and :meth:`unregister` methods.
|
:meth:`register` and :meth:`unregister` methods.
|
||||||
|
|
@ -58,14 +59,14 @@ class Registry:
|
||||||
.. note::
|
.. note::
|
||||||
Multiple registrations for the same entity are harmlessly ignored.
|
Multiple registrations for the same entity are harmlessly ignored.
|
||||||
"""
|
"""
|
||||||
if hasattr(model_cls, "_eav_config_cls"):
|
if hasattr(model_cls, '_eav_config_cls'):
|
||||||
return
|
return
|
||||||
|
|
||||||
if config_cls is EavConfig or config_cls is None:
|
if config_cls is EavConfig or config_cls is None:
|
||||||
config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {})
|
config_cls = type("%sConfig" % model_cls.__name__, (EavConfig,), {})
|
||||||
|
|
||||||
# set _eav_config_cls on the model so we can access it there
|
# set _eav_config_cls on the model so we can access it there
|
||||||
model_cls._eav_config_cls = config_cls
|
setattr(model_cls, '_eav_config_cls', config_cls)
|
||||||
|
|
||||||
reg = Registry(model_cls)
|
reg = Registry(model_cls)
|
||||||
reg._register_self()
|
reg._register_self()
|
||||||
|
|
@ -78,19 +79,19 @@ class Registry:
|
||||||
.. note::
|
.. note::
|
||||||
Unregistering a class not already registered is harmlessly ignored.
|
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
|
return
|
||||||
reg = Registry(model_cls)
|
reg = Registry(model_cls)
|
||||||
reg._unregister_self()
|
reg._unregister_self()
|
||||||
|
|
||||||
delattr(model_cls, "_eav_config_cls")
|
delattr(model_cls, '_eav_config_cls')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def attach_eav_attr(sender, *args, **kwargs):
|
def attach_eav_attr(sender, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Attach EAV Entity toolkit to an instance after init.
|
Attach EAV Entity toolkit to an instance after init.
|
||||||
"""
|
"""
|
||||||
instance = kwargs["instance"]
|
instance = kwargs['instance']
|
||||||
config_cls = instance.__class__._eav_config_cls
|
config_cls = instance.__class__._eav_config_cls
|
||||||
setattr(instance, config_cls.eav_attr, Entity(instance))
|
setattr(instance, config_cls.eav_attr, Entity(instance))
|
||||||
|
|
||||||
|
|
@ -101,41 +102,25 @@ class Registry:
|
||||||
self.model_cls = model_cls
|
self.model_cls = model_cls
|
||||||
self.config_cls = model_cls._eav_config_cls
|
self.config_cls = model_cls._eav_config_cls
|
||||||
|
|
||||||
def _attach_manager(self) -> None:
|
def _attach_manager(self):
|
||||||
"""
|
"""
|
||||||
Attach the EntityManager to the model class.
|
Attach the manager to *manager_attr* specified in *config_cls*
|
||||||
|
|
||||||
This method replaces the existing manager specified in the `config_cls`
|
|
||||||
with a new instance of `EntityManager`. If the specified manager is the
|
|
||||||
default manager, the `EntityManager` is set as the new default manager.
|
|
||||||
Otherwise, it is appended to the list of managers.
|
|
||||||
|
|
||||||
If the model class already has a manager with the same name as the one
|
|
||||||
specified in `config_cls`, it is saved as `old_mgr` in the `config_cls`
|
|
||||||
for use during detachment.
|
|
||||||
"""
|
"""
|
||||||
manager_attr = self.config_cls.manager_attr
|
# Save the old manager if the attribute name conflicts with the new one.
|
||||||
model_meta = self.model_cls._meta
|
if hasattr(self.model_cls, self.config_cls.manager_attr):
|
||||||
current_manager = getattr(self.model_cls, manager_attr, None)
|
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
|
||||||
|
|
||||||
if isinstance(current_manager, EntityManager):
|
# For some models, `local_managers` may be empty, eg.
|
||||||
# EntityManager is already attached, no need to proceed
|
# django.contrib.auth.models.User and AbstractUser
|
||||||
return
|
if mgr in self.model_cls._meta.local_managers:
|
||||||
|
self.config_cls.old_mgr = mgr
|
||||||
|
self.model_cls._meta.local_managers.remove(mgr)
|
||||||
|
|
||||||
# Create a new EntityManager
|
self.model_cls._meta._expire_cache()
|
||||||
new_manager = EntityManager()
|
|
||||||
|
|
||||||
# Save and remove the old manager if it exists
|
# Attach the new manager to the model.
|
||||||
if current_manager and current_manager in model_meta.local_managers:
|
mgr = EntityManager()
|
||||||
self.config_cls.old_mgr = current_manager
|
mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr)
|
||||||
model_meta.local_managers.remove(current_manager)
|
|
||||||
|
|
||||||
# Set the creation_counter to maintain the order
|
|
||||||
# This ensures that the new manager has the same priority as the old one
|
|
||||||
new_manager.creation_counter = current_manager.creation_counter
|
|
||||||
|
|
||||||
# Attach the new EntityManager instance to the model.
|
|
||||||
new_manager.contribute_to_class(self.model_cls, manager_attr)
|
|
||||||
|
|
||||||
def _detach_manager(self):
|
def _detach_manager(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -146,10 +131,9 @@ class Registry:
|
||||||
self.model_cls._meta._expire_cache()
|
self.model_cls._meta._expire_cache()
|
||||||
delattr(self.model_cls, self.config_cls.manager_attr)
|
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.config_cls.old_mgr.contribute_to_class(
|
||||||
self.model_cls,
|
self.model_cls, self.config_cls.manager_attr
|
||||||
self.config_cls.manager_attr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _attach_signals(self):
|
def _attach_signals(self):
|
||||||
|
|
@ -181,7 +165,7 @@ class Registry:
|
||||||
generic_relation = generic.GenericRelation(
|
generic_relation = generic.GenericRelation(
|
||||||
Value,
|
Value,
|
||||||
object_id_field=get_entity_pk_type(self.model_cls),
|
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,
|
related_query_name=rel_name,
|
||||||
)
|
)
|
||||||
generic_relation.contribute_to_class(self.model_cls, gr_name)
|
generic_relation.contribute_to_class(self.model_cls, gr_name)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ def validate_text(value):
|
||||||
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
|
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise ValidationError(_("Must be str or unicode"))
|
raise ValidationError(_(u"Must be str or unicode"))
|
||||||
|
|
||||||
|
|
||||||
def validate_float(value):
|
def validate_float(value):
|
||||||
|
|
@ -32,8 +32,8 @@ def validate_float(value):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
float(value)
|
float(value)
|
||||||
except ValueError as err:
|
except ValueError:
|
||||||
raise ValidationError(_("Must be a float")) from err
|
raise ValidationError(_(u"Must be a float"))
|
||||||
|
|
||||||
|
|
||||||
def validate_int(value):
|
def validate_int(value):
|
||||||
|
|
@ -42,8 +42,8 @@ def validate_int(value):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
int(value)
|
int(value)
|
||||||
except ValueError as err:
|
except ValueError:
|
||||||
raise ValidationError(_("Must be an integer")) from err
|
raise ValidationError(_(u"Must be an integer"))
|
||||||
|
|
||||||
|
|
||||||
def validate_date(value):
|
def validate_date(value):
|
||||||
|
|
@ -52,10 +52,9 @@ def validate_date(value):
|
||||||
or ``date``
|
or ``date``
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, datetime.datetime) and not isinstance(
|
if not isinstance(value, datetime.datetime) and not isinstance(
|
||||||
value,
|
value, datetime.date
|
||||||
datetime.date,
|
|
||||||
):
|
):
|
||||||
raise ValidationError(_("Must be a date or datetime"))
|
raise ValidationError(_(u"Must be a date or datetime"))
|
||||||
|
|
||||||
|
|
||||||
def validate_bool(value):
|
def validate_bool(value):
|
||||||
|
|
@ -63,7 +62,7 @@ def validate_bool(value):
|
||||||
Raises ``ValidationError`` unless *value* type is ``bool``
|
Raises ``ValidationError`` unless *value* type is ``bool``
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, bool):
|
if not isinstance(value, bool):
|
||||||
raise ValidationError(_("Must be a boolean"))
|
raise ValidationError(_(u"Must be a boolean"))
|
||||||
|
|
||||||
|
|
||||||
def validate_object(value):
|
def validate_object(value):
|
||||||
|
|
@ -72,10 +71,10 @@ def validate_object(value):
|
||||||
django model instance.
|
django model instance.
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, models.Model):
|
if not isinstance(value, models.Model):
|
||||||
raise ValidationError(_("Must be a django model object instance"))
|
raise ValidationError(_(u"Must be a django model object instance"))
|
||||||
|
|
||||||
if not value.pk:
|
if not value.pk:
|
||||||
raise ValidationError(_("Model has not been saved yet"))
|
raise ValidationError(_(u"Model has not been saved yet"))
|
||||||
|
|
||||||
|
|
||||||
def validate_enum(value):
|
def validate_enum(value):
|
||||||
|
|
@ -86,7 +85,7 @@ def validate_enum(value):
|
||||||
from eav.models import EnumValue
|
from eav.models import EnumValue
|
||||||
|
|
||||||
if isinstance(value, EnumValue) and not value.pk:
|
if isinstance(value, EnumValue) and not value.pk:
|
||||||
raise ValidationError(_("EnumValue has not been saved yet"))
|
raise ValidationError(_(u"EnumValue has not been saved yet"))
|
||||||
|
|
||||||
|
|
||||||
def validate_json(value):
|
def validate_json(value):
|
||||||
|
|
@ -97,9 +96,9 @@ def validate_json(value):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = json.loads(value)
|
value = json.loads(value)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValidationError(_("Must be a JSON Serializable object"))
|
raise ValidationError(_(u"Must be a JSON Serializable object"))
|
||||||
except ValueError as err:
|
except ValueError:
|
||||||
raise ValidationError(_("Must be a JSON Serializable object")) from err
|
raise ValidationError(_(u"Must be a JSON Serializable object"))
|
||||||
|
|
||||||
|
|
||||||
def validate_csv(value):
|
def validate_csv(value):
|
||||||
|
|
@ -109,4 +108,4 @@ def validate_csv(value):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = value.split(";")
|
value = value.split(";")
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
raise ValidationError(_("Must be Comma-Separated-Value."))
|
raise ValidationError(_(u"Must be Comma-Separated-Value."))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from django.core import validators
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms.widgets import Textarea
|
from django.forms.widgets import Textarea
|
||||||
|
|
||||||
EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]")
|
EMPTY_VALUES = validators.EMPTY_VALUES + ('[]',)
|
||||||
|
|
||||||
|
|
||||||
class CSVWidget(Textarea):
|
class CSVWidget(Textarea):
|
||||||
|
|
@ -12,11 +12,11 @@ class CSVWidget(Textarea):
|
||||||
"""Prepare value before effectively render widget"""
|
"""Prepare value before effectively render widget"""
|
||||||
if value in EMPTY_VALUES:
|
if value in EMPTY_VALUES:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
return ";".join(value)
|
return ";".join(value)
|
||||||
raise ValidationError("Invalid format.")
|
raise ValidationError('Invalid format.')
|
||||||
|
|
||||||
def render(self, name, value, **kwargs):
|
def render(self, name, value, **kwargs):
|
||||||
value = self.prep_value(value)
|
value = self.prep_value(value)
|
||||||
|
|
@ -31,9 +31,11 @@ class CSVWidget(Textarea):
|
||||||
key, we need to loop through each field checking if the eav attribute
|
key, we need to loop through each field checking if the eav attribute
|
||||||
exists with the given 'name'.
|
exists with the given 'name'.
|
||||||
"""
|
"""
|
||||||
for data_value in data.values():
|
widget_value = None
|
||||||
widget_value = getattr(data_value, name, None)
|
for data_value in data:
|
||||||
if widget_value is not None:
|
try:
|
||||||
return widget_value
|
widget_value = getattr(data.get(data_value), name)
|
||||||
|
except AttributeError:
|
||||||
|
pass # noqa: WPS420
|
||||||
|
|
||||||
return None
|
return widget_value
|
||||||
|
|
|
||||||
14
manage.py
14
manage.py
|
|
@ -13,19 +13,19 @@ def main() -> None:
|
||||||
2. Warns if Django is not installed
|
2. Warns if Django is not installed
|
||||||
3. Executes any given command
|
3. Executes any given command
|
||||||
"""
|
"""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django.core import management
|
from django.core import management # noqa: WPS433
|
||||||
except ImportError as err:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
+ "available on your PYTHONPATH environment variable? Did you "
|
+ 'available on your PYTHONPATH environment variable? Did you '
|
||||||
+ "forget to activate a virtual environment?",
|
+ 'forget to activate a virtual environment?',
|
||||||
) from err
|
)
|
||||||
|
|
||||||
management.execute_from_command_line(sys.argv)
|
management.execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
3007
poetry.lock
generated
3007
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,22 @@
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.9"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.nitpick]
|
||||||
|
style = "https://raw.githubusercontent.com/wemake-services/wemake-python-styleguide/master/styles/nitpick-style-wemake.toml"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
target-version = ['py37', 'py38', 'py39', 'py310']
|
||||||
|
skip-string-normalization = true
|
||||||
|
include = '\.pyi?$'
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "django-eav2"
|
name = "django-eav2"
|
||||||
description = "Entity-Attribute-Value storage for Django"
|
description = "Entity-Attribute-Value storage for Django"
|
||||||
version = "1.8.1"
|
version = "1.6.0"
|
||||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||||
packages = [
|
packages = [
|
||||||
{ include = "eav" }
|
{ include = "eav" }
|
||||||
|
|
@ -37,17 +47,17 @@ classifiers = [
|
||||||
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Topic :: Database",
|
"Topic :: Database",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
|
"Framework :: Django :: 3.2",
|
||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
"Framework :: Django :: 5.1",
|
"Framework :: Django :: 5.0",
|
||||||
"Framework :: Django :: 5.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.semantic_release]
|
[tool.semantic_release]
|
||||||
|
|
@ -60,17 +70,22 @@ upload_to_release = false
|
||||||
build_command = "pip install poetry && poetry build"
|
build_command = "pip install poetry && poetry build"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.8"
|
||||||
django = ">=4.2,<5.3"
|
django = ">=3.2,<5.1"
|
||||||
|
pyyaml = { version = "^6.0.1", python = "^3.12" }
|
||||||
|
|
||||||
[tool.poetry.group.test.dependencies]
|
[tool.poetry.group.test.dependencies]
|
||||||
mypy = "^1.6"
|
mypy = "^1.6"
|
||||||
ruff = ">=0.6.3,<0.13.0"
|
|
||||||
|
wemake-python-styleguide = "^0.17"
|
||||||
|
flake8-pytest-style = "^1.7"
|
||||||
|
nitpick = ">=0.34,<0.36"
|
||||||
|
black = ">=22.12,<25.0"
|
||||||
|
|
||||||
safety = ">=2.3,<4.0"
|
safety = ">=2.3,<4.0"
|
||||||
|
|
||||||
pytest = ">=7.4.3,<9.0.0"
|
pytest = ">=7.4.3,<9.0.0"
|
||||||
pytest-cov = ">=4.1,<7.0"
|
pytest-cov = "^4.1"
|
||||||
pytest-randomly = "^3.15"
|
pytest-randomly = "^3.15"
|
||||||
pytest-django = "^4.5.2"
|
pytest-django = "^4.5.2"
|
||||||
hypothesis = "^6.87.1"
|
hypothesis = "^6.87.1"
|
||||||
|
|
@ -82,53 +97,7 @@ optional = true
|
||||||
|
|
||||||
[tool.poetry.group.docs.dependencies]
|
[tool.poetry.group.docs.dependencies]
|
||||||
sphinx = ">=5.0,<8.0"
|
sphinx = ">=5.0,<8.0"
|
||||||
sphinx-rtd-theme = ">=1.3,<4.0"
|
sphinx-rtd-theme = ">=1.3,<3.0"
|
||||||
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||||
m2r2 = "^0.3"
|
m2r2 = "^0.3"
|
||||||
tomlkit = ">=0.13.0,<0.14"
|
tomlkit = ">=0.11,<0.13"
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 88
|
|
||||||
target-version = "py38"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["ALL"]
|
|
||||||
ignore = [
|
|
||||||
"ANN", # Type hints related, let mypy handle these.
|
|
||||||
"ARG", # Unused arguments
|
|
||||||
"D", # Docstrings related
|
|
||||||
"EM101", # "Exception must not use a string literal, assign to variable first"
|
|
||||||
"EM102", # "Exception must not use an f-string literal, assign to variable first"
|
|
||||||
"PD", # Pandas related
|
|
||||||
"Q000", # For now
|
|
||||||
"SIM105", # "Use contextlib.suppress({exception}) instead of try-except-pass"
|
|
||||||
"TRY003", # "Avoid specifying long messages outside the exception class"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-implicit-str-concat]
|
|
||||||
allow-multiline = false
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
# Allow private member access for Registry
|
|
||||||
"eav/registry.py" = ["SLF001"]
|
|
||||||
|
|
||||||
# Migrations are special
|
|
||||||
"**/migrations/*" = ["RUF012"]
|
|
||||||
|
|
||||||
# Sphinx specific
|
|
||||||
"docs/source/conf.py" = ["INP001"]
|
|
||||||
|
|
||||||
# pytest is even more special
|
|
||||||
"tests/*" = [
|
|
||||||
"INP001", # "Add an __init__.py"
|
|
||||||
"PLR2004", # "Magic value used in comparison"
|
|
||||||
"PT009", # "Use a regular assert instead of unittest-style"
|
|
||||||
"PT027", # "Use pytest.raises instead of unittest-style"
|
|
||||||
"S101", # "Use of assert detected"
|
|
||||||
"SLF001" # "Private member accessed"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint.pydocstyle]
|
|
||||||
# Use Google-style docstrings.
|
|
||||||
convention = "google"
|
|
||||||
|
|
|
||||||
57
setup.cfg
57
setup.cfg
|
|
@ -3,6 +3,63 @@
|
||||||
# https://docs.python.org/3/distutils/configfile.html
|
# https://docs.python.org/3/distutils/configfile.html
|
||||||
|
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
format = wemake
|
||||||
|
show-source = True
|
||||||
|
doctests = False
|
||||||
|
statistics = False
|
||||||
|
|
||||||
|
# darglint configuration:
|
||||||
|
# https://github.com/terrencepreilly/darglint
|
||||||
|
strictness = long
|
||||||
|
docstring-style = numpy
|
||||||
|
|
||||||
|
# Plugins:
|
||||||
|
max-complexity = 6
|
||||||
|
max-line-length = 80
|
||||||
|
|
||||||
|
exclude =
|
||||||
|
# Trash and cache:
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
.eggs
|
||||||
|
*.egg
|
||||||
|
temp
|
||||||
|
|
||||||
|
ignore =
|
||||||
|
D100,
|
||||||
|
D104,
|
||||||
|
D401,
|
||||||
|
W504,
|
||||||
|
X100,
|
||||||
|
RST303,
|
||||||
|
RST304,
|
||||||
|
DAR103,
|
||||||
|
DAR203
|
||||||
|
|
||||||
|
per-file-ignores =
|
||||||
|
# Allow to have magic numbers inside migrations, wrong module names,
|
||||||
|
# and string over-use:
|
||||||
|
*/migrations/*.py: WPS102, WPS114, WPS226, WPS432
|
||||||
|
# Allow `__init__.py` with logic for configuration:
|
||||||
|
test_project/settings.py: S105, WPS226, WPS407
|
||||||
|
tests/test_*.py: N806, S101, S404, S603, S607, WPS118, WPS226, WPS432, WPS442
|
||||||
|
|
||||||
|
|
||||||
|
[isort]
|
||||||
|
# isort configuration:
|
||||||
|
# https://github.com/timothycrosley/isort/wiki/isort-Settings
|
||||||
|
include_trailing_comma = true
|
||||||
|
use_parentheses = true
|
||||||
|
# See https://github.com/timothycrosley/isort#multi-line-output-modes
|
||||||
|
multi_line_output = 3
|
||||||
|
line_length = 80
|
||||||
|
|
||||||
|
# Useful for our test app:
|
||||||
|
known_first_party = test_project
|
||||||
|
|
||||||
|
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
# Django options:
|
# Django options:
|
||||||
# https://pytest-django.readthedocs.io/en/latest/
|
# https://pytest-django.readthedocs.io/en/latest/
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class TestAppConfig(AppConfig):
|
class TestAppConfig(AppConfig):
|
||||||
name = "test_project"
|
name = 'test_project'
|
||||||
|
|
|
||||||
|
|
@ -14,136 +14,136 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="ExampleMetaclassModel",
|
name='ExampleMetaclassModel',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="ExampleModel",
|
name='ExampleModel',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="RegisterTestModel",
|
name='RegisterTestModel',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Patient",
|
name='Patient',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
("email", models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
|
('email', models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
|
||||||
(
|
(
|
||||||
"example",
|
'example',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.deletion.PROTECT,
|
on_delete=models.deletion.PROTECT,
|
||||||
to="test_project.examplemodel",
|
to='test_project.examplemodel',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="M2MModel",
|
name='M2MModel',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
("models", models.ManyToManyField(to="test_project.ExampleModel")),
|
('models', models.ManyToManyField(to='test_project.ExampleModel')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Encounter",
|
name='Encounter',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.AutoField(
|
models.AutoField(
|
||||||
auto_created=True,
|
auto_created=True,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
serialize=False,
|
serialize=False,
|
||||||
verbose_name="ID",
|
verbose_name='ID',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("num", models.PositiveSmallIntegerField()),
|
('num', models.PositiveSmallIntegerField()),
|
||||||
(
|
(
|
||||||
"patient",
|
'patient',
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=models.deletion.PROTECT,
|
on_delete=models.deletion.PROTECT,
|
||||||
to="test_project.patient",
|
to='test_project.patient',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Doctor",
|
name='Doctor',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
'id',
|
||||||
models.UUIDField(
|
models.UUIDField(
|
||||||
default=uuid.uuid4,
|
default=uuid.uuid4,
|
||||||
editable=False,
|
editable=False,
|
||||||
|
|
@ -151,10 +151,10 @@ class Migration(migrations.Migration):
|
||||||
serialize=False,
|
serialize=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Final, final
|
|
||||||
|
if sys.version_info >= (3, 8):
|
||||||
|
from typing import Final, final
|
||||||
|
else:
|
||||||
|
from typing_extensions import Final, final
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from eav.decorators import register_eav
|
from eav.decorators import register_eav
|
||||||
from eav.managers import EntityManager
|
|
||||||
from eav.models import EAVModelMeta
|
from eav.models import EAVModelMeta
|
||||||
|
|
||||||
#: Constants
|
#: Constants
|
||||||
|
|
@ -14,55 +18,13 @@ MAX_CHARFIELD_LEN: Final = 254
|
||||||
class TestBase(models.Model):
|
class TestBase(models.Model):
|
||||||
"""Base class for test models."""
|
"""Base class for test models."""
|
||||||
|
|
||||||
class Meta:
|
class Meta(object):
|
||||||
"""Define common options."""
|
"""Define common options."""
|
||||||
|
|
||||||
app_label = "test_project"
|
app_label = 'test_project'
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class DoctorManager(EntityManager):
|
|
||||||
"""
|
|
||||||
Custom manager for the Doctor model.
|
|
||||||
|
|
||||||
This manager extends the EntityManager and provides additional
|
|
||||||
methods specific to the Doctor model, and is expected to be the
|
|
||||||
default manager on the model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_by_name(self, name: str) -> models.QuerySet:
|
|
||||||
"""Returns a QuerySet of doctors with the given name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): The name of the doctor to search for.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
models.QuerySet: A QuerySet of doctors with the specified name.
|
|
||||||
"""
|
|
||||||
return self.filter(name=name)
|
|
||||||
|
|
||||||
|
|
||||||
class DoctorSubstringManager(models.Manager):
|
|
||||||
"""
|
|
||||||
Custom manager for the Doctor model.
|
|
||||||
|
|
||||||
This is a second manager used to ensure during testing that it's not replaced
|
|
||||||
as the default manager after eav.register().
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_by_name_contains(self, substring: str) -> models.QuerySet:
|
|
||||||
"""Returns a QuerySet of doctors whose names contain the given substring.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
return self.filter(name__icontains=substring)
|
|
||||||
|
|
||||||
|
|
||||||
@final
|
@final
|
||||||
@register_eav()
|
@register_eav()
|
||||||
class Doctor(TestBase):
|
class Doctor(TestBase):
|
||||||
|
|
@ -71,19 +33,13 @@ class Doctor(TestBase):
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
objects = DoctorManager()
|
|
||||||
substrings = DoctorSubstringManager()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class Patient(TestBase):
|
class Patient(TestBase):
|
||||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True)
|
email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True)
|
||||||
example = models.ForeignKey(
|
example = models.ForeignKey(
|
||||||
"ExampleModel",
|
'ExampleModel',
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
|
@ -101,7 +57,7 @@ class Encounter(TestBase):
|
||||||
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.patient}: encounter num {self.num}"
|
return '%s: encounter num %d' % (self.patient, self.num)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
@ -112,7 +68,7 @@ class Encounter(TestBase):
|
||||||
class ExampleModel(TestBase):
|
class ExampleModel(TestBase):
|
||||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
def __str__(self):
|
def __unicode__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,7 +78,7 @@ class M2MModel(TestBase):
|
||||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
models = models.ManyToManyField(ExampleModel)
|
models = models.ManyToManyField(ExampleModel)
|
||||||
|
|
||||||
def __str__(self):
|
def __unicode__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
|
@ -10,51 +9,51 @@ BASE_DIR = Path(__file__).parent.parent
|
||||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS: list[str] = []
|
ALLOWED_HOSTS: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
'django.contrib.admin',
|
||||||
"django.contrib.auth",
|
'django.contrib.auth',
|
||||||
"django.contrib.contenttypes",
|
'django.contrib.contenttypes',
|
||||||
"django.contrib.sessions",
|
'django.contrib.sessions',
|
||||||
"django.contrib.messages",
|
'django.contrib.messages',
|
||||||
"django.contrib.staticfiles",
|
'django.contrib.staticfiles',
|
||||||
# Test Project:
|
# Test Project:
|
||||||
"test_project.apps.TestAppConfig",
|
'test_project.apps.TestAppConfig',
|
||||||
# Our app:
|
# Our app:
|
||||||
"eav",
|
'eav',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
'django.middleware.security.SecurityMiddleware',
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
"django.middleware.common.CommonMiddleware",
|
'django.middleware.common.CommonMiddleware',
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
"DIRS": [],
|
'DIRS': [],
|
||||||
"APP_DIRS": True,
|
'APP_DIRS': True,
|
||||||
"OPTIONS": {
|
'OPTIONS': {
|
||||||
"context_processors": [
|
'context_processors': [
|
||||||
"django.template.context_processors.debug",
|
'django.template.context_processors.debug',
|
||||||
"django.template.context_processors.request",
|
'django.template.context_processors.request',
|
||||||
"django.contrib.auth.context_processors.auth",
|
'django.contrib.auth.context_processors.auth',
|
||||||
"django.contrib.messages.context_processors.messages",
|
'django.contrib.messages.context_processors.messages',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -65,15 +64,15 @@ TEMPLATES = [
|
||||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
'default': {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
"NAME": ":memory:",
|
'NAME': ':memory:',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
|
EAV2_PRIMARY_KEY_FIELD = 'django.db.models.AutoField'
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
|
|
@ -85,9 +84,9 @@ AUTH_PASSWORD_VALIDATORS = []
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
# 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
|
USE_I18N = True
|
||||||
|
|
||||||
|
|
@ -99,4 +98,4 @@ USE_TZ = False
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = '/static/'
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import string
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import string
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.conf import settings as django_settings
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from hypothesis import given, settings
|
from hypothesis import given, settings
|
||||||
from hypothesis import strategies as st
|
|
||||||
from hypothesis.extra import django
|
from hypothesis.extra import django
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
from hypothesis.strategies import just
|
from hypothesis.strategies import just
|
||||||
|
|
||||||
import eav
|
import eav
|
||||||
|
|
@ -16,6 +15,7 @@ from eav.models import Attribute, Value
|
||||||
from eav.registry import EavConfig
|
from eav.registry import EavConfig
|
||||||
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
|
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
|
||||||
|
|
||||||
|
|
||||||
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
|
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
|
||||||
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
|
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
|
||||||
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
|
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
|
||||||
|
|
@ -27,22 +27,22 @@ else:
|
||||||
class Attributes(TestCase):
|
class Attributes(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
class EncounterEavConfig(EavConfig):
|
class EncounterEavConfig(EavConfig):
|
||||||
manager_attr = "eav_objects"
|
manager_attr = 'eav_objects'
|
||||||
eav_attr = "eav_field"
|
eav_attr = 'eav_field'
|
||||||
generic_relation_attr = "encounter_eav_values"
|
generic_relation_attr = 'encounter_eav_values'
|
||||||
generic_relation_related_name = "encounters"
|
generic_relation_related_name = 'encounters'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_attributes(cls, instance=None):
|
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(Encounter, EncounterEavConfig)
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
|
|
||||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||||
Attribute.objects.create(name="weight", 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='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
eav.unregister(Encounter)
|
eav.unregister(Encounter)
|
||||||
|
|
@ -53,14 +53,14 @@ class Attributes(TestCase):
|
||||||
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
|
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
|
||||||
|
|
||||||
def test_duplicate_attributs(self):
|
def test_duplicate_attributs(self):
|
||||||
"""
|
'''
|
||||||
Ensure that no two Attributes with the same slug can exist.
|
Ensure that no two Attributes with the same slug can exist.
|
||||||
"""
|
'''
|
||||||
with self.assertRaises(ValidationError):
|
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):
|
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)
|
e = Encounter.objects.create(patient=p, num=1)
|
||||||
|
|
||||||
p.eav.age = 3
|
p.eav.age = 3
|
||||||
|
|
@ -73,7 +73,7 @@ class Attributes(TestCase):
|
||||||
t.eav.age = 6
|
t.eav.age = 6
|
||||||
t.eav.height = 10
|
t.eav.height = 10
|
||||||
t.save()
|
t.save()
|
||||||
p = Patient.objects.get(name="Jon")
|
p = Patient.objects.get(name='Jon')
|
||||||
self.assertEqual(p.eav.age, 3)
|
self.assertEqual(p.eav.age, 3)
|
||||||
self.assertEqual(p.eav.height, 2.3)
|
self.assertEqual(p.eav.height, 2.3)
|
||||||
e = Encounter.objects.get(num=1)
|
e = Encounter.objects.get(num=1)
|
||||||
|
|
@ -96,21 +96,20 @@ class Attributes(TestCase):
|
||||||
eav.unregister(Encounter)
|
eav.unregister(Encounter)
|
||||||
eav.register(Encounter, EncounterEavConfig)
|
eav.register(Encounter, EncounterEavConfig)
|
||||||
|
|
||||||
p = Patient.objects.create(name="Jon")
|
p = Patient.objects.create(name='Jon')
|
||||||
e = Encounter.objects.create(patient=p, num=1)
|
e = Encounter.objects.create(patient=p, num=1)
|
||||||
|
|
||||||
with self.assertRaises(IllegalAssignmentException):
|
with self.assertRaises(IllegalAssignmentException):
|
||||||
e.eav.color = "red"
|
e.eav.color = 'red'
|
||||||
e.save()
|
e.save()
|
||||||
|
|
||||||
def test_uuid_pk(self):
|
def test_uuid_pk(self):
|
||||||
"""Tests for when model pk is UUID."""
|
"""Tests for when model pk is UUID."""
|
||||||
expected_age = 10
|
d1 = Doctor.objects.create(name='Lu')
|
||||||
d1 = Doctor.objects.create(name="Lu")
|
d1.eav.age = 10
|
||||||
d1.eav.age = expected_age
|
|
||||||
d1.save()
|
d1.save()
|
||||||
|
|
||||||
assert d1.eav.age == expected_age
|
assert d1.eav.age == 10
|
||||||
|
|
||||||
# Validate repr of Value for an entity with a UUID PK
|
# Validate repr of Value for an entity with a UUID PK
|
||||||
v1 = Value.objects.filter(entity_uuid=d1.pk).first()
|
v1 = Value.objects.filter(entity_uuid=d1.pk).first()
|
||||||
|
|
@ -120,7 +119,7 @@ class Attributes(TestCase):
|
||||||
def test_big_integer(self):
|
def test_big_integer(self):
|
||||||
"""Tests an integer larger than 32-bit a value."""
|
"""Tests an integer larger than 32-bit a value."""
|
||||||
big_num = 3147483647
|
big_num = 3147483647
|
||||||
patient = Patient.objects.create(name="Jon")
|
patient = Patient.objects.create(name='Jon')
|
||||||
patient.eav.age = big_num
|
patient.eav.age = big_num
|
||||||
|
|
||||||
patient.save()
|
patient.save()
|
||||||
|
|
@ -137,7 +136,6 @@ class TestAttributeModel(django.TestCase):
|
||||||
id=auto_field_strategy,
|
id=auto_field_strategy,
|
||||||
datatype=just(Attribute.TYPE_TEXT),
|
datatype=just(Attribute.TYPE_TEXT),
|
||||||
enum_group=just(None),
|
enum_group=just(None),
|
||||||
slug=just(None), # Let Attribute.save() handle
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@settings(deadline=None)
|
@settings(deadline=None)
|
||||||
|
|
@ -164,20 +162,3 @@ class TestAttributeModel(django.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(instance, Attribute)
|
assert isinstance(instance, Attribute)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_attribute_create_with_invalid_slug() -> None:
|
|
||||||
"""
|
|
||||||
Test that creating an Attribute with an invalid slug raises a UserWarning.
|
|
||||||
|
|
||||||
This test ensures that when an Attribute is created with a slug that is not
|
|
||||||
a valid Python identifier, a UserWarning is raised. The warning should
|
|
||||||
indicate that the slug is invalid and suggest updating it.
|
|
||||||
"""
|
|
||||||
with pytest.warns(UserWarning):
|
|
||||||
Attribute.objects.create(
|
|
||||||
name="Test Attribute",
|
|
||||||
slug="123-invalid",
|
|
||||||
datatype=Attribute.TYPE_TEXT,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -12,85 +12,75 @@ class DataValidation(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
|
|
||||||
Attribute.objects.create(name="Age", datatype=Attribute.TYPE_INT)
|
Attribute.objects.create(name='Age', datatype=Attribute.TYPE_INT)
|
||||||
Attribute.objects.create(name="DoB", datatype=Attribute.TYPE_DATE)
|
Attribute.objects.create(name='DoB', datatype=Attribute.TYPE_DATE)
|
||||||
Attribute.objects.create(name="Height", datatype=Attribute.TYPE_FLOAT)
|
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_FLOAT)
|
||||||
Attribute.objects.create(name="City", datatype=Attribute.TYPE_TEXT)
|
Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT)
|
||||||
Attribute.objects.create(name="Pregnant", datatype=Attribute.TYPE_BOOLEAN)
|
Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN)
|
||||||
Attribute.objects.create(name="User", datatype=Attribute.TYPE_OBJECT)
|
Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT)
|
||||||
Attribute.objects.create(name="Extra", datatype=Attribute.TYPE_JSON)
|
Attribute.objects.create(name='Extra', datatype=Attribute.TYPE_JSON)
|
||||||
Attribute.objects.create(name="Multi", datatype=Attribute.TYPE_CSV)
|
Attribute.objects.create(name='Multi', datatype=Attribute.TYPE_CSV)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
|
|
||||||
def test_required_field(self):
|
def test_required_field(self):
|
||||||
p = Patient(name="Bob")
|
p = Patient(name='Bob')
|
||||||
p.eav.age = 5
|
p.eav.age = 5
|
||||||
p.save()
|
p.save()
|
||||||
|
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="Weight",
|
name='Weight', datatype=Attribute.TYPE_INT, required=True
|
||||||
datatype=Attribute.TYPE_INT,
|
|
||||||
required=True,
|
|
||||||
)
|
)
|
||||||
p.eav.age = 6
|
p.eav.age = 6
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p = Patient.objects.get(name="Bob")
|
p = Patient.objects.get(name='Bob')
|
||||||
self.assertEqual(p.eav.age, 5)
|
self.assertEqual(p.eav.age, 5)
|
||||||
p.eav.weight = 23
|
p.eav.weight = 23
|
||||||
p.save()
|
p.save()
|
||||||
p = Patient.objects.get(name="Bob")
|
p = Patient.objects.get(name='Bob')
|
||||||
self.assertEqual(p.eav.weight, 23)
|
self.assertEqual(p.eav.weight, 23)
|
||||||
|
|
||||||
def test_create_required_field(self):
|
def test_create_required_field(self):
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="Weight",
|
name='Weight', datatype=Attribute.TYPE_INT, required=True
|
||||||
datatype=Attribute.TYPE_INT,
|
|
||||||
required=True,
|
|
||||||
)
|
)
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
ValidationError,
|
ValidationError, Patient.objects.create, name='Joe', eav__age=5
|
||||||
Patient.objects.create,
|
|
||||||
name="Joe",
|
|
||||||
eav__age=5,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(Patient.objects.count(), 0)
|
self.assertEqual(Patient.objects.count(), 0)
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
Patient.objects.create(name="Joe", eav__weight=2, eav__age=5)
|
Patient.objects.create(name='Joe', eav__weight=2, eav__age=5)
|
||||||
self.assertEqual(Patient.objects.count(), 1)
|
self.assertEqual(Patient.objects.count(), 1)
|
||||||
self.assertEqual(Value.objects.count(), 2)
|
self.assertEqual(Value.objects.count(), 2)
|
||||||
|
|
||||||
def test_validation_error_create(self):
|
def test_validation_error_create(self):
|
||||||
self.assertRaises(
|
self.assertRaises(
|
||||||
ValidationError,
|
ValidationError, Patient.objects.create, name='Joe', eav__age='df'
|
||||||
Patient.objects.create,
|
|
||||||
name="Joe",
|
|
||||||
eav__age="df",
|
|
||||||
)
|
)
|
||||||
self.assertEqual(Patient.objects.count(), 0)
|
self.assertEqual(Patient.objects.count(), 0)
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
def test_changing_datatypes(self):
|
def test_changing_datatypes(self):
|
||||||
a = Attribute.objects.create(name="Color", datatype=Attribute.TYPE_INT)
|
a = Attribute.objects.create(name='Color', datatype=Attribute.TYPE_INT)
|
||||||
a.datatype = Attribute.TYPE_TEXT
|
a.datatype = Attribute.TYPE_TEXT
|
||||||
a.save()
|
a.save()
|
||||||
Patient.objects.create(name="Bob", eav__color="brown")
|
Patient.objects.create(name='Bob', eav__color='brown')
|
||||||
a.datatype = Attribute.TYPE_INT
|
a.datatype = Attribute.TYPE_INT
|
||||||
self.assertRaises(ValidationError, a.save)
|
self.assertRaises(ValidationError, a.save)
|
||||||
|
|
||||||
def test_int_validation(self):
|
def test_int_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.age = "bad"
|
p.eav.age = 'bad'
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.age = 15
|
p.eav.age = 15
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15)
|
||||||
|
|
||||||
def test_date_validation(self):
|
def test_date_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.dob = "12"
|
p.eav.dob = '12'
|
||||||
self.assertRaises(ValidationError, lambda: p.save())
|
self.assertRaises(ValidationError, lambda: p.save())
|
||||||
p.eav.dob = 15
|
p.eav.dob = 15
|
||||||
self.assertRaises(ValidationError, lambda: p.save())
|
self.assertRaises(ValidationError, lambda: p.save())
|
||||||
|
|
@ -104,26 +94,26 @@ class DataValidation(TestCase):
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today)
|
||||||
|
|
||||||
def test_float_validation(self):
|
def test_float_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.height = "bad"
|
p.eav.height = 'bad'
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.height = 15
|
p.eav.height = 15
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15)
|
||||||
p.eav.height = "2.3"
|
p.eav.height = '2.3'
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3)
|
||||||
|
|
||||||
def test_text_validation(self):
|
def test_text_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.city = 5
|
p.eav.city = 5
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.city = "El Dorado"
|
p.eav.city = 'El Dorado'
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, "El Dorado")
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, 'El Dorado')
|
||||||
|
|
||||||
def test_bool_validation(self):
|
def test_bool_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.pregnant = 5
|
p.eav.pregnant = 5
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.pregnant = True
|
p.eav.pregnant = True
|
||||||
|
|
@ -131,72 +121,70 @@ class DataValidation(TestCase):
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True)
|
||||||
|
|
||||||
def test_object_validation(self):
|
def test_object_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.user = 5
|
p.eav.user = 5
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.user = object
|
p.eav.user = object
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.user = User(username="joe")
|
p.eav.user = User(username='joe')
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
u = User.objects.create(username="joe")
|
u = User.objects.create(username='joe')
|
||||||
p.eav.user = u
|
p.eav.user = u
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u)
|
||||||
|
|
||||||
def test_enum_validation(self):
|
def test_enum_validation(self):
|
||||||
yes = EnumValue.objects.create(value="yes")
|
yes = EnumValue.objects.create(value='yes')
|
||||||
no = EnumValue.objects.create(value="no")
|
no = EnumValue.objects.create(value='no')
|
||||||
unkown = EnumValue.objects.create(value="unkown")
|
unkown = EnumValue.objects.create(value='unkown')
|
||||||
green = EnumValue.objects.create(value="green")
|
green = EnumValue.objects.create(value='green')
|
||||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
ynu.values.add(yes)
|
ynu.values.add(yes)
|
||||||
ynu.values.add(no)
|
ynu.values.add(no)
|
||||||
ynu.values.add(unkown)
|
ynu.values.add(unkown)
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="Fever",
|
name='Fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||||
datatype=Attribute.TYPE_ENUM,
|
|
||||||
enum_group=ynu,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.fever = 5
|
p.eav.fever = 5
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.fever = object
|
p.eav.fever = object
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.fever = green
|
p.eav.fever = green
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.fever = EnumValue(value="yes")
|
p.eav.fever = EnumValue(value='yes')
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.fever = no
|
p.eav.fever = no
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no)
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no)
|
||||||
|
|
||||||
def test_enum_datatype_without_enum_group(self):
|
def test_enum_datatype_without_enum_group(self):
|
||||||
a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM)
|
a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM)
|
||||||
self.assertRaises(ValidationError, a.save)
|
self.assertRaises(ValidationError, a.save)
|
||||||
yes = EnumValue.objects.create(value="yes")
|
yes = EnumValue.objects.create(value='yes')
|
||||||
no = EnumValue.objects.create(value="no")
|
no = EnumValue.objects.create(value='no')
|
||||||
unkown = EnumValue.objects.create(value="unkown")
|
unkown = EnumValue.objects.create(value='unkown')
|
||||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
ynu.values.add(yes)
|
ynu.values.add(yes)
|
||||||
ynu.values.add(no)
|
ynu.values.add(no)
|
||||||
ynu.values.add(unkown)
|
ynu.values.add(unkown)
|
||||||
a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
||||||
a.save()
|
a.save()
|
||||||
|
|
||||||
def test_enum_group_on_other_datatype(self):
|
def test_enum_group_on_other_datatype(self):
|
||||||
yes = EnumValue.objects.create(value="yes")
|
yes = EnumValue.objects.create(value='yes')
|
||||||
no = EnumValue.objects.create(value="no")
|
no = EnumValue.objects.create(value='no')
|
||||||
unkown = EnumValue.objects.create(value="unkown")
|
unkown = EnumValue.objects.create(value='unkown')
|
||||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
ynu.values.add(yes)
|
ynu.values.add(yes)
|
||||||
ynu.values.add(no)
|
ynu.values.add(no)
|
||||||
ynu.values.add(unkown)
|
ynu.values.add(unkown)
|
||||||
a = Attribute(name="color", datatype=Attribute.TYPE_TEXT, enum_group=ynu)
|
a = Attribute(name='color', datatype=Attribute.TYPE_TEXT, enum_group=ynu)
|
||||||
self.assertRaises(ValidationError, a.save)
|
self.assertRaises(ValidationError, a.save)
|
||||||
|
|
||||||
def test_json_validation(self):
|
def test_json_validation(self):
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.extra = 5
|
p.eav.extra = 5
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.extra = {"eyes": "blue", "hair": "brown"}
|
p.eav.extra = {"eyes": "blue", "hair": "brown"}
|
||||||
|
|
@ -204,13 +192,12 @@ class DataValidation(TestCase):
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue")
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue")
|
||||||
|
|
||||||
def test_csv_validation(self):
|
def test_csv_validation(self):
|
||||||
yes = EnumValue.objects.create(value="yes")
|
yes = EnumValue.objects.create(value='yes')
|
||||||
p = Patient.objects.create(name="Mike")
|
p = Patient.objects.create(name='Mike')
|
||||||
p.eav.multi = yes
|
p.eav.multi = yes
|
||||||
self.assertRaises(ValidationError, p.save)
|
self.assertRaises(ValidationError, p.save)
|
||||||
p.eav.multi = "one;two;three"
|
p.eav.multi = "one;two;three"
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
Patient.objects.get(pk=p.pk).eav.multi,
|
Patient.objects.get(pk=p.pk).eav.multi, ["one", "two", "three"]
|
||||||
["one", "two", "three"],
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.admin.sites import AdminSite
|
from django.contrib.admin.sites import AdminSite
|
||||||
from django.core.handlers.base import BaseHandler
|
from django.core.handlers.base import BaseHandler
|
||||||
|
|
@ -6,9 +8,9 @@ from django.test import TestCase
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
import eav
|
import eav
|
||||||
from eav.admin import BaseEntityAdmin
|
from eav.admin import *
|
||||||
from eav.forms import BaseDynamicEntityForm
|
from eav.forms import BaseDynamicEntityForm
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue
|
from eav.models import Attribute
|
||||||
from test_project.models import ExampleModel, M2MModel, Patient
|
from test_project.models import ExampleModel, M2MModel, Patient
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,7 +20,15 @@ class MockRequest(RequestFactory):
|
||||||
request = RequestFactory.request(self, **request)
|
request = RequestFactory.request(self, **request)
|
||||||
handler = BaseHandler()
|
handler = BaseHandler()
|
||||||
handler.load_middleware()
|
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
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,51 +48,49 @@ request.user = MockSuperUser()
|
||||||
class PatientForm(ModelForm):
|
class PatientForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
fields = ("name", "email", "example")
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class PatientDynamicForm(BaseDynamicEntityForm):
|
class PatientDynamicForm(BaseDynamicEntityForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Patient
|
model = Patient
|
||||||
fields = ("name", "email", "example")
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class M2MModelForm(ModelForm):
|
class M2MModelForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = M2MModel
|
model = M2MModel
|
||||||
fields = ("name", "models")
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class Forms(TestCase):
|
class Forms(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
Attribute.objects.create(name="weight", 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='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
self.female = EnumValue.objects.create(value="Female")
|
self.female = EnumValue.objects.create(value='Female')
|
||||||
self.male = EnumValue.objects.create(value="Male")
|
self.male = EnumValue.objects.create(value='Male')
|
||||||
gender_group = EnumGroup.objects.create(name="Gender")
|
gender_group = EnumGroup.objects.create(name='Gender')
|
||||||
gender_group.values.add(self.female, self.male)
|
gender_group.values.add(self.female, self.male)
|
||||||
|
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="gender",
|
name='gender', datatype=Attribute.TYPE_ENUM, enum_group=gender_group
|
||||||
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):
|
def test_valid_submit(self):
|
||||||
self.instance.eav.color = "Blue"
|
self.instance.eav.color = 'Blue'
|
||||||
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
||||||
jim = form.save()
|
jim = form.save()
|
||||||
|
|
||||||
self.assertEqual(jim.eav.color, "Blue")
|
self.assertEqual(jim.eav.color, 'Blue')
|
||||||
|
|
||||||
def test_invalid_submit(self):
|
def test_invalid_submit(self):
|
||||||
form = PatientForm({"color": "Blue"}, instance=self.instance)
|
form = PatientForm(dict(color='Blue'), instance=self.instance)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
form.save()
|
jim = form.save()
|
||||||
|
|
||||||
def test_valid_enums(self):
|
def test_valid_enums(self):
|
||||||
self.instance.eav.gender = self.female
|
self.instance.eav.gender = self.female
|
||||||
|
|
@ -92,41 +100,41 @@ class Forms(TestCase):
|
||||||
self.assertEqual(rose.eav.gender, self.female)
|
self.assertEqual(rose.eav.gender, self.female)
|
||||||
|
|
||||||
def test_m2m(self):
|
def test_m2m(self):
|
||||||
m2mmodel = M2MModel.objects.create(name="name")
|
m2mmodel = M2MModel.objects.create(name='name')
|
||||||
model = ExampleModel.objects.create(name="name")
|
model = ExampleModel.objects.create(name='name')
|
||||||
form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel)
|
form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel)
|
||||||
form.save()
|
form.save()
|
||||||
self.assertEqual(len(m2mmodel.models.all()), 1)
|
self.assertEqual(len(m2mmodel.models.all()), 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def patient() -> Patient:
|
def patient() -> Patient:
|
||||||
"""Return an eav enabled Patient instance."""
|
"""Return an eav enabled Patient instance."""
|
||||||
eav.register(Patient)
|
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:
|
def create_attributes() -> None:
|
||||||
"""Create some Attributes to use for testing."""
|
"""Create some Attributes to use for testing."""
|
||||||
Attribute.objects.create(name="weight", 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='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db()
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("csv_data", "separator"),
|
'csv_data, separator',
|
||||||
[
|
[
|
||||||
("", ";"),
|
('', ';'),
|
||||||
("justone", ","),
|
('justone', ','),
|
||||||
("one;two;three", ";"),
|
('one;two;three', ';'),
|
||||||
("alpha,beta,gamma", ","),
|
('alpha,beta,gamma', ','),
|
||||||
(None, ","),
|
(None, ','),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_csvdynamicform(patient, csv_data, separator) -> None:
|
def test_csvdynamicform(patient, csv_data, separator) -> None:
|
||||||
"""Ensure that a TYPE_CSV field works correctly with forms."""
|
"""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.eav.csv = csv_data
|
||||||
patient.save()
|
patient.save()
|
||||||
patient.refresh_from_db()
|
patient.refresh_from_db()
|
||||||
|
|
@ -135,7 +143,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
|
||||||
patient.__dict__,
|
patient.__dict__,
|
||||||
instance=patient,
|
instance=patient,
|
||||||
)
|
)
|
||||||
form.fields["csv"].separator = separator
|
form.fields['csv'].separator = separator
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
jim = form.save()
|
jim = form.save()
|
||||||
|
|
||||||
|
|
@ -143,7 +151,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
|
||||||
assert jim.eav.csv == expected_result
|
assert jim.eav.csv == expected_result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db()
|
||||||
def test_csvdynamicform_empty(patient) -> None:
|
def test_csvdynamicform_empty(patient) -> None:
|
||||||
"""Test to ensure an instance with no eav values is correct."""
|
"""Test to ensure an instance with no eav values is correct."""
|
||||||
form = PatientDynamicForm(
|
form = PatientDynamicForm(
|
||||||
|
|
@ -154,31 +162,29 @@ def test_csvdynamicform_empty(patient) -> None:
|
||||||
assert form.save()
|
assert form.save()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db()
|
||||||
@pytest.mark.usefixtures("create_attributes")
|
@pytest.mark.usefixtures('create_attributes')
|
||||||
@pytest.mark.parametrize("define_fieldsets", [True, False])
|
@pytest.mark.parametrize('define_fieldsets', (True, False))
|
||||||
def test_entity_admin_form(patient, define_fieldsets):
|
def test_entity_admin_form(patient, define_fieldsets):
|
||||||
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
|
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
|
||||||
admin = BaseEntityAdmin(Patient, AdminSite())
|
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||||
admin.readonly_fields = ("email",)
|
admin.readonly_fields = ('email',)
|
||||||
admin.form = BaseDynamicEntityForm
|
admin.form = BaseDynamicEntityForm
|
||||||
expected_fieldsets = 2
|
|
||||||
|
|
||||||
if define_fieldsets:
|
if define_fieldsets:
|
||||||
# Use all fields in Patient model
|
# Use all fields in Patient model
|
||||||
admin.fieldsets = (
|
admin.fieldsets = (
|
||||||
(None, {"fields": ["name", "example"]}),
|
(None, {'fields': ['name', 'example']}),
|
||||||
("Contact Info", {"fields": ["email"]}),
|
('Contact Info', {'fields': ['email']}),
|
||||||
)
|
)
|
||||||
expected_fieldsets = 3
|
|
||||||
|
|
||||||
view = admin.change_view(request, str(patient.pk))
|
view = admin.change_view(request, str(patient.pk))
|
||||||
|
|
||||||
adminform = view.context_data["adminform"]
|
adminform = view.context_data['adminform']
|
||||||
|
|
||||||
# Count the total fields in fieldsets
|
# Count the total fields in fieldsets
|
||||||
total_fields = sum(
|
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'
|
# 3 for 'name', 'email', 'example'
|
||||||
|
|
@ -187,65 +193,27 @@ def test_entity_admin_form(patient, define_fieldsets):
|
||||||
assert total_fields == expected_fields_count
|
assert total_fields == expected_fields_count
|
||||||
|
|
||||||
# Ensure our fieldset count is correct
|
# Ensure our fieldset count is correct
|
||||||
assert len(adminform.fieldsets) == expected_fieldsets
|
if define_fieldsets:
|
||||||
|
assert len(adminform.fieldsets) == 3
|
||||||
|
else:
|
||||||
|
assert len(adminform.fieldsets) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db()
|
||||||
def test_entity_admin_form_no_attributes(patient):
|
def test_entity_admin_form_no_attributes(patient):
|
||||||
"""Test the BaseEntityAdmin form with no Attributes created."""
|
"""Test the BaseEntityAdmin form with no Attributes created."""
|
||||||
admin = BaseEntityAdmin(Patient, AdminSite())
|
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||||
admin.readonly_fields = ("email",)
|
admin.readonly_fields = ('email',)
|
||||||
admin.form = BaseDynamicEntityForm
|
admin.form = BaseDynamicEntityForm
|
||||||
|
|
||||||
# Only fields defined in Patient model
|
|
||||||
expected_fields = 3
|
|
||||||
|
|
||||||
view = admin.change_view(request, str(patient.pk))
|
view = admin.change_view(request, str(patient.pk))
|
||||||
|
|
||||||
adminform = view.context_data["adminform"]
|
adminform = view.context_data['adminform']
|
||||||
|
|
||||||
# Count the total fields in fieldsets
|
# Count the total fields in fieldsets
|
||||||
total_fields = sum(
|
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'
|
# 3 for 'name', 'email', 'example'
|
||||||
assert total_fields == expected_fields
|
assert total_fields == 3
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_dynamic_form_renders_enum_choices():
|
|
||||||
"""
|
|
||||||
Test that enum choices render correctly in BaseDynamicEntityForm.
|
|
||||||
|
|
||||||
This test verifies the fix for issue #648 where enum choices weren't
|
|
||||||
rendering correctly in Django 4.2.17 due to QuerySet unpacking issues.
|
|
||||||
"""
|
|
||||||
# Setup
|
|
||||||
eav.register(Patient)
|
|
||||||
|
|
||||||
# Create enum values and group
|
|
||||||
female = EnumValue.objects.create(value="Female")
|
|
||||||
male = EnumValue.objects.create(value="Male")
|
|
||||||
gender_group = EnumGroup.objects.create(name="Gender")
|
|
||||||
gender_group.values.add(female, male)
|
|
||||||
|
|
||||||
Attribute.objects.create(
|
|
||||||
name="gender",
|
|
||||||
datatype=Attribute.TYPE_ENUM,
|
|
||||||
enum_group=gender_group,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a patient
|
|
||||||
patient = Patient.objects.create(name="Test Patient")
|
|
||||||
|
|
||||||
# Initialize the dynamic form
|
|
||||||
form = PatientDynamicForm(instance=patient)
|
|
||||||
|
|
||||||
# Test rendering - should not raise any exceptions
|
|
||||||
rendered_form = form.as_p()
|
|
||||||
|
|
||||||
# Verify the form rendered and contains the enum choices
|
|
||||||
assert 'name="gender"' in rendered_form
|
|
||||||
assert f'value="{female.pk}">{female.value}' in rendered_form
|
|
||||||
assert f'value="{male.pk}">{male.value}' in rendered_form
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import pytest
|
|
||||||
from hypothesis import given
|
from hypothesis import given
|
||||||
from hypothesis import strategies as st
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
|
@ -19,58 +18,3 @@ def test_generate_long_slug_text(name: str) -> None:
|
||||||
slug = generate_slug(name)
|
slug = generate_slug(name)
|
||||||
|
|
||||||
assert len(slug) <= SLUGFIELD_MAX_LENGTH
|
assert len(slug) <= SLUGFIELD_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
def test_generate_slug_uniqueness() -> None:
|
|
||||||
"""Test that generate_slug() produces unique slugs for different inputs.
|
|
||||||
|
|
||||||
This test ensures that even similar inputs result in unique slugs,
|
|
||||||
and that the number of unique slugs matches the number of inputs.
|
|
||||||
"""
|
|
||||||
inputs = ["age #", "age %", "age $", "age @", "age!", "age?", "age 😊"]
|
|
||||||
|
|
||||||
generated_slugs: dict[str, str] = {}
|
|
||||||
for input_str in inputs:
|
|
||||||
slug = generate_slug(input_str)
|
|
||||||
assert slug not in generated_slugs.values(), (
|
|
||||||
f"Duplicate slug '{slug}' generated for input '{input_str}'"
|
|
||||||
)
|
|
||||||
generated_slugs[input_str] = slug
|
|
||||||
|
|
||||||
assert len(generated_slugs) == len(
|
|
||||||
inputs,
|
|
||||||
), "Number of unique slugs doesn't match number of inputs"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"input_str",
|
|
||||||
[
|
|
||||||
"01 age",
|
|
||||||
"? age",
|
|
||||||
"age 😊",
|
|
||||||
"class",
|
|
||||||
"def function",
|
|
||||||
"2nd place",
|
|
||||||
"@username",
|
|
||||||
"user-name",
|
|
||||||
"first.last",
|
|
||||||
"snake_case",
|
|
||||||
"CamelCase",
|
|
||||||
" ", # Empty
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_generate_slug_valid_identifier(input_str: str) -> None:
|
|
||||||
"""Test that generate_slug() produces valid Python identifiers.
|
|
||||||
|
|
||||||
This test ensures that the generated slugs are valid Python identifiers
|
|
||||||
for a variety of input strings, including those with numbers, special
|
|
||||||
characters, emojis, and different naming conventions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_str (str): The input string to test.
|
|
||||||
"""
|
|
||||||
slug = generate_slug(input_str)
|
|
||||||
assert slug.isidentifier(), (
|
|
||||||
f"Generated slug '{slug}' for input '{input_str}' "
|
|
||||||
+ "is not a valid Python identifier"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
from test_project.models import Patient
|
from test_project.models import Patient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture()
|
||||||
def enumgroup(db):
|
def enumgroup(db):
|
||||||
"""Sample `EnumGroup` object for testing."""
|
"""Sample `EnumGroup` object for testing."""
|
||||||
test_group = EnumGroup.objects.create(name="Yes / No")
|
test_group = EnumGroup.objects.create(name='Yes / No')
|
||||||
value_yes = EnumValue.objects.create(value="Yes")
|
value_yes = EnumValue.objects.create(value='Yes')
|
||||||
value_no = EnumValue.objects.create(value="No")
|
value_no = EnumValue.objects.create(value='No')
|
||||||
test_group.values.add(value_yes)
|
test_group.values.add(value_yes)
|
||||||
test_group.values.add(value_no)
|
test_group.values.add(value_no)
|
||||||
return test_group
|
return test_group
|
||||||
|
|
@ -19,14 +19,14 @@ def enumgroup(db):
|
||||||
|
|
||||||
def test_enumgroup_display(enumgroup):
|
def test_enumgroup_display(enumgroup):
|
||||||
"""Test repr() and str() of EnumGroup."""
|
"""Test repr() and str() of EnumGroup."""
|
||||||
assert f"<EnumGroup {enumgroup.name}>" == repr(enumgroup)
|
assert '<EnumGroup {0}>'.format(enumgroup.name) == repr(enumgroup)
|
||||||
assert str(enumgroup) == str(enumgroup.name)
|
assert str(enumgroup) == str(enumgroup.name)
|
||||||
|
|
||||||
|
|
||||||
def test_enumvalue_display(enumgroup):
|
def test_enumvalue_display(enumgroup):
|
||||||
"""Test repr() and str() of EnumValue."""
|
"""Test repr() and str() of EnumValue."""
|
||||||
test_value = enumgroup.values.first()
|
test_value = enumgroup.values.first()
|
||||||
assert f"<EnumValue {test_value.value}>" == repr(test_value)
|
assert '<EnumValue {0}>'.format(test_value.value) == repr(test_value)
|
||||||
assert str(test_value) == test_value.value
|
assert str(test_value) == test_value.value
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,37 +34,33 @@ class MiscModels(TestCase):
|
||||||
"""Miscellaneous tests on models."""
|
"""Miscellaneous tests on models."""
|
||||||
|
|
||||||
def test_attribute_help_text(self):
|
def test_attribute_help_text(self):
|
||||||
desc = "Patient Age"
|
desc = 'Patient Age'
|
||||||
a = Attribute.objects.create(
|
a = Attribute.objects.create(
|
||||||
name="age",
|
name='age', description=desc, datatype=Attribute.TYPE_INT
|
||||||
description=desc,
|
|
||||||
datatype=Attribute.TYPE_INT,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(a.help_text, desc)
|
self.assertEqual(a.help_text, desc)
|
||||||
|
|
||||||
def test_setting_to_none_deletes_value(self):
|
def test_setting_to_none_deletes_value(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||||
p = Patient.objects.create(name="Bob", eav__age=5)
|
p = Patient.objects.create(name='Bob', eav__age=5)
|
||||||
self.assertEqual(Value.objects.count(), 1)
|
self.assertEqual(Value.objects.count(), 1)
|
||||||
p.eav.age = None
|
p.eav.age = None
|
||||||
p.save()
|
p.save()
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
def test_string_enum_value_assignment(self):
|
def test_string_enum_value_assignment(self):
|
||||||
yes = EnumValue.objects.create(value="yes")
|
yes = EnumValue.objects.create(value='yes')
|
||||||
no = EnumValue.objects.create(value="no")
|
no = EnumValue.objects.create(value='no')
|
||||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
ynu.values.add(yes)
|
ynu.values.add(yes)
|
||||||
ynu.values.add(no)
|
ynu.values.add(no)
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="is_patient",
|
name='is_patient', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||||
datatype=Attribute.TYPE_ENUM,
|
|
||||||
enum_group=ynu,
|
|
||||||
)
|
)
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
p = Patient.objects.create(name="Joe")
|
p = Patient.objects.create(name='Joe')
|
||||||
p.eav.is_patient = "yes"
|
p.eav.is_patient = 'yes'
|
||||||
p.save()
|
p.save()
|
||||||
p = Patient.objects.get(name="Joe") # get from DB again
|
p = Patient.objects.get(name='Joe') # get from DB again
|
||||||
self.assertEqual(p.eav.is_patient, yes)
|
self.assertEqual(p.eav.is_patient, yes)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,30 @@
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
from test_project.models import Patient
|
from test_project.models import Patient
|
||||||
|
import eav
|
||||||
|
|
||||||
|
|
||||||
class ModelTest(TestCase):
|
class ModelTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||||
Attribute.objects.create(name="weight", 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='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
EnumGroup.objects.create(name="Yes / No")
|
EnumGroup.objects.create(name='Yes / No')
|
||||||
EnumValue.objects.create(value="yes")
|
EnumValue.objects.create(value='yes')
|
||||||
EnumValue.objects.create(value="no")
|
EnumValue.objects.create(value='no')
|
||||||
EnumValue.objects.create(value="unknown")
|
EnumValue.objects.create(value='unknown')
|
||||||
|
|
||||||
def test_attr_natural_keys(self):
|
def test_attr_natural_keys(self):
|
||||||
attr = Attribute.objects.get(name="age")
|
attr = Attribute.objects.get(name='age')
|
||||||
attr_natural_key = attr.natural_key()
|
attr_natural_key = attr.natural_key()
|
||||||
attr_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key)
|
attr_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key)
|
||||||
self.assertEqual(attr_retrieved_model, attr)
|
self.assertEqual(attr_retrieved_model, attr)
|
||||||
|
|
||||||
def test_value_natural_keys(self):
|
def test_value_natural_keys(self):
|
||||||
p = Patient.objects.create(name="Jon")
|
p = Patient.objects.create(name='Jon')
|
||||||
p.eav.age = 5
|
p.eav.age = 5
|
||||||
p.save()
|
p.save()
|
||||||
|
|
||||||
|
|
@ -39,7 +38,7 @@ class ModelTest(TestCase):
|
||||||
enum_group = EnumGroup.objects.first()
|
enum_group = EnumGroup.objects.first()
|
||||||
enum_group_natural_key = enum_group.natural_key()
|
enum_group_natural_key = enum_group.natural_key()
|
||||||
enum_group_retrieved_model = EnumGroup.objects.get_by_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)
|
self.assertEqual(enum_group_retrieved_model, enum_group)
|
||||||
|
|
||||||
|
|
@ -47,6 +46,6 @@ class ModelTest(TestCase):
|
||||||
enum_value = EnumValue.objects.first()
|
enum_value = EnumValue.objects.first()
|
||||||
enum_value_natural_key = enum_value.natural_key()
|
enum_value_natural_key = enum_value.natural_key()
|
||||||
enum_value_retrieved_model = EnumValue.objects.get_by_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)
|
self.assertEqual(enum_value_retrieved_model, enum_value)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format
|
from eav.logic.object_pk import get_pk_format
|
||||||
|
|
||||||
|
|
||||||
def test_get_uuid_primary_key(settings) -> None:
|
def test_get_uuid_primary_key(settings) -> None:
|
||||||
|
|
@ -20,7 +21,7 @@ def test_get_char_primary_key(settings) -> None:
|
||||||
assert isinstance(primary_field, models.CharField)
|
assert isinstance(primary_field, models.CharField)
|
||||||
assert primary_field.primary_key
|
assert primary_field.primary_key
|
||||||
assert not primary_field.editable
|
assert not primary_field.editable
|
||||||
assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN
|
assert primary_field.max_length == 40
|
||||||
|
|
||||||
|
|
||||||
def test_get_default_primary_key(settings) -> None:
|
def test_get_default_primary_key(settings) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.utils import NotSupportedError
|
from django.db.utils import NotSupportedError
|
||||||
|
|
@ -9,7 +6,7 @@ from django.test import TestCase
|
||||||
import eav
|
import eav
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
from eav.registry import EavConfig
|
from eav.registry import EavConfig
|
||||||
from test_project.models import Encounter, ExampleModel, Patient
|
from test_project.models import Encounter, Patient, ExampleModel
|
||||||
|
|
||||||
|
|
||||||
class Queries(TestCase):
|
class Queries(TestCase):
|
||||||
|
|
@ -17,34 +14,32 @@ class Queries(TestCase):
|
||||||
eav.register(Encounter)
|
eav.register(Encounter)
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
|
|
||||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||||
Attribute.objects.create(name="weight", 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='city', datatype=Attribute.TYPE_TEXT)
|
||||||
Attribute.objects.create(name="country", 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='extras', datatype=Attribute.TYPE_JSON)
|
||||||
Attribute.objects.create(name="illness", datatype=Attribute.TYPE_CSV)
|
Attribute.objects.create(name='illness', datatype=Attribute.TYPE_CSV)
|
||||||
|
|
||||||
self.yes = EnumValue.objects.create(value="yes")
|
self.yes = EnumValue.objects.create(value='yes')
|
||||||
self.no = EnumValue.objects.create(value="no")
|
self.no = EnumValue.objects.create(value='no')
|
||||||
self.unknown = EnumValue.objects.create(value="unknown")
|
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.yes)
|
||||||
ynu.values.add(self.no)
|
ynu.values.add(self.no)
|
||||||
ynu.values.add(self.unknown)
|
ynu.values.add(self.unknown)
|
||||||
|
|
||||||
Attribute.objects.create(
|
Attribute.objects.create(
|
||||||
name="fever",
|
name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||||
datatype=Attribute.TYPE_ENUM,
|
|
||||||
enum_group=ynu,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
eav.unregister(Encounter)
|
eav.unregister(Encounter)
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
|
|
||||||
def init_data(self) -> None:
|
def init_data(self):
|
||||||
yes = self.yes
|
yes = self.yes
|
||||||
no = self.no
|
no = self.no
|
||||||
|
|
||||||
|
|
@ -52,24 +47,24 @@ class Queries(TestCase):
|
||||||
# Name, age, fever,
|
# Name, age, fever,
|
||||||
# city, country, extras
|
# city, country, extras
|
||||||
# possible illness
|
# possible illness
|
||||||
["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"],
|
['Anne', 3, no, 'New York', 'USA', {"chills": "yes"}, "cold"],
|
||||||
["Bob", 15, no, "Bamako", "Mali", {}, ""],
|
['Bob', 15, no, 'Bamako', 'Mali', {}, ""],
|
||||||
[
|
[
|
||||||
"Cyrill",
|
'Cyrill',
|
||||||
15,
|
15,
|
||||||
yes,
|
yes,
|
||||||
"Kisumu",
|
'Kisumu',
|
||||||
"Kenya",
|
'Kenya',
|
||||||
{"chills": "yes", "headache": "no"},
|
{"chills": "yes", "headache": "no"},
|
||||||
"flu",
|
"flu",
|
||||||
],
|
],
|
||||||
["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"],
|
['Daniel', 3, no, 'Nice', 'France', {"headache": "yes"}, "cold"],
|
||||||
[
|
[
|
||||||
"Eugene",
|
'Eugene',
|
||||||
2,
|
2,
|
||||||
yes,
|
yes,
|
||||||
"France",
|
'France',
|
||||||
"Nice",
|
'Nice',
|
||||||
{"chills": "no", "headache": "yes"},
|
{"chills": "no", "headache": "yes"},
|
||||||
"flu;cold",
|
"flu;cold",
|
||||||
],
|
],
|
||||||
|
|
@ -87,26 +82,26 @@ class Queries(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_or_create_with_eav(self):
|
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(Patient.objects.count(), 1)
|
||||||
self.assertEqual(Value.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(Patient.objects.count(), 1)
|
||||||
self.assertEqual(Value.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(Patient.objects.count(), 2)
|
||||||
self.assertEqual(Value.objects.count(), 2)
|
self.assertEqual(Value.objects.count(), 2)
|
||||||
|
|
||||||
def test_get_or_create_with_defaults(self):
|
def test_get_or_create_with_defaults(self):
|
||||||
"""Tests EntityManager.get_or_create() with defaults keyword."""
|
"""Tests EntityManager.get_or_create() with defaults keyword."""
|
||||||
city_name = "Tokyo"
|
city_name = 'Tokyo'
|
||||||
email = "mari@test.com"
|
email = 'mari@test.com'
|
||||||
p1, _ = Patient.objects.get_or_create(
|
p1, _ = Patient.objects.get_or_create(
|
||||||
name="Mari",
|
name='Mari',
|
||||||
eav__age=27,
|
eav__age=27,
|
||||||
defaults={
|
defaults={
|
||||||
"email": email,
|
'email': email,
|
||||||
"eav__city": city_name,
|
'eav__city': city_name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert Patient.objects.count() == 1
|
assert Patient.objects.count() == 1
|
||||||
|
|
@ -114,258 +109,175 @@ class Queries(TestCase):
|
||||||
assert p1.eav.city == city_name
|
assert p1.eav.city == city_name
|
||||||
|
|
||||||
def test_get_with_eav(self):
|
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)
|
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(
|
self.assertRaises(
|
||||||
MultipleObjectsReturned,
|
MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)
|
||||||
lambda: Patient.objects.get(eav__age=6),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_no_results_for_contradictory_conditions(self) -> None:
|
def test_filtering_on_normal_and_eav_fields(self):
|
||||||
"""Test that contradictory conditions return no results."""
|
|
||||||
self.init_data()
|
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)
|
q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no)
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 0)
|
||||||
|
|
||||||
# Should return no patients due to contradictory conditions
|
# Anne, Daniel
|
||||||
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
|
q1 = Q(eav__age__gte=3) # Everyone except Eugene
|
||||||
q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene
|
q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene
|
||||||
p = Patient.objects.filter(q2 & q1)
|
p = Patient.objects.filter(q2 & q1)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return Anne and Daniel
|
# Anne
|
||||||
assert p.count() == 2
|
q1 = Q(eav__city__contains='Y') & Q(eav__fever='no')
|
||||||
|
|
||||||
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)
|
q2 = Q(eav__age=3)
|
||||||
p = Patient.objects.filter(q1 & q2)
|
p = Patient.objects.filter(q1 & q2)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return only Anne
|
# Anne
|
||||||
assert p.count() == 1
|
q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no)
|
||||||
|
|
||||||
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)
|
q2 = Q(eav__age=3)
|
||||||
p = Patient.objects.filter(q1 & q2)
|
p = Patient.objects.filter(q1 & q2)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return only Anne
|
# Anne, Daniel
|
||||||
assert p.count() == 1
|
q1 = Q(eav__city__contains='Y', eav__fever=self.no)
|
||||||
|
q2 = Q(eav__city='Nice')
|
||||||
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)
|
q3 = Q(eav__age=3)
|
||||||
p = Patient.objects.filter((q1 | q2) & q3)
|
p = Patient.objects.filter((q1 | q2) & q3)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return Anne and Daniel
|
# Everyone
|
||||||
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)
|
q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes)
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 5)
|
||||||
|
|
||||||
# Should return all patients
|
# Anne, Bob, Daniel
|
||||||
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
|
q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel
|
||||||
q2 = Q(eav__fever=self.yes) # Cyrill, Eugene
|
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
|
q4 = q2 & q3 # Cyrill, Daniel, Eugene
|
||||||
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
|
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
|
||||||
p = Patient.objects.filter(q5)
|
p = Patient.objects.filter(q5)
|
||||||
|
self.assertEqual(p.count(), 3)
|
||||||
|
|
||||||
# Should return Anne, Bob, and Daniel
|
# Everyone except Anne
|
||||||
assert p.count() == 3
|
q1 = Q(eav__city__contains='Y')
|
||||||
|
|
||||||
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)
|
p = Patient.objects.exclude(q1)
|
||||||
|
self.assertEqual(p.count(), 4)
|
||||||
|
|
||||||
# Should return all patients except Anne
|
# Anne, Bob, Daniel
|
||||||
assert p.count() == 4
|
q1 = Q(eav__city__contains='Y')
|
||||||
|
|
||||||
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)
|
q2 = Q(eav__fever=self.no)
|
||||||
q3 = q1 | q2
|
q3 = q1 | q2
|
||||||
p = Patient.objects.filter(q3)
|
p = Patient.objects.filter(q3)
|
||||||
|
self.assertEqual(p.count(), 3)
|
||||||
|
|
||||||
# Should return Anne, Bob, and Daniel
|
# Anne, 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)
|
q1 = Q(eav__age=3)
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return Anne and Daniel
|
# Eugene
|
||||||
assert p.count() == 2
|
q1 = Q(name__contains='E', eav__fever=self.yes)
|
||||||
|
|
||||||
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)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return only Eugene
|
# Extras: Chills
|
||||||
assert p.count() == 1
|
# Without
|
||||||
|
|
||||||
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")
|
q1 = Q(eav__extras__has_key="chills")
|
||||||
p = Patient.objects.exclude(q1)
|
p = Patient.objects.exclude(q1)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return patients without 'chills' in extras
|
# With
|
||||||
assert p.count() == 2
|
|
||||||
|
|
||||||
q1 = Q(eav__extras__has_key="chills")
|
q1 = Q(eav__extras__has_key="chills")
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 3)
|
||||||
|
|
||||||
# Should return patients with 'chills' in extras
|
# No chills
|
||||||
assert p.count() == 3
|
|
||||||
|
|
||||||
q1 = Q(eav__extras__chills="no")
|
q1 = Q(eav__extras__chills="no")
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return patients with 'chills' set to 'no'
|
# Has chills
|
||||||
assert p.count() == 1
|
|
||||||
|
|
||||||
q1 = Q(eav__extras__chills="yes")
|
q1 = Q(eav__extras__chills="yes")
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return patients with 'chills' set to 'yes'
|
# Extras: Empty
|
||||||
assert p.count() == 2
|
# Yes
|
||||||
|
|
||||||
def test_filtering_on_empty_json_eav_field(self) -> None:
|
|
||||||
"""Test filtering on empty JSON EAV field."""
|
|
||||||
self.init_data()
|
|
||||||
q1 = Q(eav__extras={})
|
q1 = Q(eav__extras={})
|
||||||
p = Patient.objects.filter(q1)
|
p = Patient.objects.filter(q1)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return patients with empty extras
|
# No
|
||||||
assert p.count() == 1
|
|
||||||
|
|
||||||
q1 = Q(eav__extras={})
|
q1 = Q(eav__extras={})
|
||||||
p = Patient.objects.exclude(q1)
|
p = Patient.objects.exclude(q1)
|
||||||
|
self.assertEqual(p.count(), 4)
|
||||||
|
|
||||||
# Should return patients with non-empty extras
|
# Illness:
|
||||||
assert p.count() == 4
|
# Cold
|
||||||
|
|
||||||
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")
|
q1 = Q(eav__illness__icontains="cold")
|
||||||
p = Patient.objects.exclude(q1)
|
p = Patient.objects.exclude(q1)
|
||||||
|
self.assertEqual(p.count(), 2)
|
||||||
|
|
||||||
# Should return patients without 'cold' in illness
|
# Flu
|
||||||
assert p.count() == 2
|
|
||||||
|
|
||||||
q1 = Q(eav__illness__icontains="flu")
|
q1 = Q(eav__illness__icontains="flu")
|
||||||
p = Patient.objects.exclude(q1)
|
p = Patient.objects.exclude(q1)
|
||||||
|
self.assertEqual(p.count(), 3)
|
||||||
|
|
||||||
# Should return patients without 'flu' in illness
|
# Empty
|
||||||
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)
|
q1 = Q(eav__illness__isnull=False)
|
||||||
p = Patient.objects.filter(~q1)
|
p = Patient.objects.filter(~q1)
|
||||||
|
self.assertEqual(p.count(), 1)
|
||||||
|
|
||||||
# Should return patients with null illness
|
def _order(self, ordering):
|
||||||
assert p.count() == 1
|
|
||||||
|
|
||||||
def _order(self, ordering) -> list[str]:
|
|
||||||
query = Patient.objects.all().order_by(*ordering)
|
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") -> None:
|
def assert_order_by_results(self, eav_attr='eav'):
|
||||||
"""Test the ordering functionality of EAV attributes."""
|
self.assertEqual(
|
||||||
# Ordering by a single EAV attribute
|
['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'],
|
||||||
assert self._order([f"{eav_attr}__city"]) == [
|
self._order(['%s__city' % eav_attr]),
|
||||||
"Bob",
|
)
|
||||||
"Eugene",
|
|
||||||
"Cyrill",
|
|
||||||
"Anne",
|
|
||||||
"Daniel",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ordering by multiple EAV attributes
|
self.assertEqual(
|
||||||
assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [
|
['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'],
|
||||||
"Eugene",
|
self._order(['%s__age' % eav_attr, '%s__city' % eav_attr]),
|
||||||
"Anne",
|
)
|
||||||
"Daniel",
|
|
||||||
"Bob",
|
|
||||||
"Cyrill",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ordering by EAV attributes with different data types
|
self.assertEqual(
|
||||||
assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [
|
['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'],
|
||||||
"Eugene",
|
self._order(['%s__fever' % eav_attr, '%s__age' % eav_attr]),
|
||||||
"Cyrill",
|
)
|
||||||
"Anne",
|
|
||||||
"Daniel",
|
|
||||||
"Bob",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Combining EAV and regular model field ordering
|
self.assertEqual(
|
||||||
assert self._order([f"{eav_attr}__fever", "-name"]) == [
|
['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'],
|
||||||
"Eugene",
|
self._order(['%s__fever' % eav_attr, '-name']),
|
||||||
"Cyrill",
|
)
|
||||||
"Daniel",
|
|
||||||
"Bob",
|
|
||||||
"Anne",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mixing regular and EAV field ordering
|
self.assertEqual(
|
||||||
assert self._order(["-name", f"{eav_attr}__age"]) == [
|
['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'],
|
||||||
"Eugene",
|
self._order(['-name', '%s__age' % eav_attr]),
|
||||||
"Daniel",
|
)
|
||||||
"Cyrill",
|
|
||||||
"Bob",
|
|
||||||
"Anne",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ordering by a related model field
|
self.assertEqual(
|
||||||
assert self._order(["example__name"]) == [
|
['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'],
|
||||||
"Anne",
|
self._order(['example__name']),
|
||||||
"Bob",
|
)
|
||||||
"Cyrill",
|
|
||||||
"Daniel",
|
|
||||||
"Eugene",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Error handling for unsupported nested EAV attributes
|
with self.assertRaises(NotSupportedError):
|
||||||
with pytest.raises(NotSupportedError):
|
Patient.objects.all().order_by('%s__first__second' % eav_attr)
|
||||||
Patient.objects.all().order_by(f"{eav_attr}__first__second")
|
|
||||||
|
|
||||||
# Error handling for non-existent EAV attributes
|
with self.assertRaises(ObjectDoesNotExist):
|
||||||
with pytest.raises(ObjectDoesNotExist):
|
Patient.objects.all().order_by('%s__nonsense' % eav_attr)
|
||||||
Patient.objects.all().order_by(f"{eav_attr}__nonsense")
|
|
||||||
|
|
||||||
def test_order_by(self):
|
def test_order_by(self):
|
||||||
self.init_data()
|
self.init_data()
|
||||||
|
|
@ -379,11 +291,11 @@ class Queries(TestCase):
|
||||||
self.init_data()
|
self.init_data()
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
eav.register(Patient, config_cls=CustomConfig)
|
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):
|
def test_fk_filter(self):
|
||||||
e = ExampleModel.objects.create(name="test1")
|
e = ExampleModel.objects.create(name='test1')
|
||||||
p = Patient.objects.get_or_create(name="Beth", example=e)[0]
|
p = Patient.objects.get_or_create(name='Beth', example=e)[0]
|
||||||
c = ExampleModel.objects.filter(patient=p)
|
c = ExampleModel.objects.filter(patient=p)
|
||||||
self.assertEqual(c.count(), 1)
|
self.assertEqual(c.count(), 1)
|
||||||
|
|
||||||
|
|
@ -398,12 +310,12 @@ class Queries(TestCase):
|
||||||
|
|
||||||
# Use the filter method with 3 EAV attribute conditions
|
# Use the filter method with 3 EAV attribute conditions
|
||||||
patients = Patient.objects.filter(
|
patients = Patient.objects.filter(
|
||||||
name="Anne",
|
name='Anne',
|
||||||
eav__age=3,
|
eav__age=3,
|
||||||
eav__illness="cold",
|
eav__illness='cold',
|
||||||
eav__fever="no",
|
eav__fever='no',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert that the expected patient is returned
|
# Assert that the expected patient is returned
|
||||||
self.assertEqual(len(patients), 1)
|
self.assertEqual(len(patients), 1)
|
||||||
self.assertEqual(patients[0].name, "Anne")
|
self.assertEqual(patients[0].name, 'Anne')
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
import eav
|
import eav
|
||||||
from eav.managers import EntityManager
|
|
||||||
from eav.registry import EavConfig
|
from eav.registry import EavConfig
|
||||||
from test_project.models import (
|
from test_project.models import (
|
||||||
Doctor,
|
|
||||||
Encounter,
|
Encounter,
|
||||||
ExampleMetaclassModel,
|
ExampleMetaclassModel,
|
||||||
ExampleModel,
|
ExampleModel,
|
||||||
|
|
@ -22,72 +20,72 @@ class RegistryTests(TestCase):
|
||||||
|
|
||||||
def register_encounter(self):
|
def register_encounter(self):
|
||||||
class EncounterEav(EavConfig):
|
class EncounterEav(EavConfig):
|
||||||
manager_attr = "eav_objects"
|
manager_attr = 'eav_objects'
|
||||||
eav_attr = "eav_field"
|
eav_attr = 'eav_field'
|
||||||
generic_relation_attr = "encounter_eav_values"
|
generic_relation_attr = 'encounter_eav_values'
|
||||||
generic_relation_related_name = "encounters"
|
generic_relation_related_name = 'encounters'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_attributes(cls):
|
def get_attributes(cls):
|
||||||
return "testing"
|
return 'testing'
|
||||||
|
|
||||||
eav.register(Encounter, EncounterEav)
|
eav.register(Encounter, EncounterEav)
|
||||||
|
|
||||||
def test_registering_with_defaults(self):
|
def test_registering_with_defaults(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertFalse(Patient._eav_config_cls.manager_only)
|
self.assertFalse(Patient._eav_config_cls.manager_only)
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
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_attr, 'eav_values')
|
||||||
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
|
|
||||||
def test_registering_overriding_defaults(self):
|
def test_registering_overriding_defaults(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.register_encounter()
|
self.register_encounter()
|
||||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||||
|
|
||||||
self.assertTrue(hasattr(Encounter, "_eav_config_cls"))
|
self.assertTrue(hasattr(Encounter, '_eav_config_cls'))
|
||||||
self.assertEqual(Encounter._eav_config_cls.get_attributes(), "testing")
|
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.manager_attr, 'eav_objects')
|
||||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field")
|
self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field')
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
eav.unregister(Encounter)
|
eav.unregister(Encounter)
|
||||||
|
|
||||||
def test_registering_via_decorator_with_defaults(self):
|
def test_registering_via_decorator_with_defaults(self):
|
||||||
self.assertTrue(hasattr(ExampleModel, "_eav_config_cls"))
|
self.assertTrue(hasattr(ExampleModel, '_eav_config_cls'))
|
||||||
self.assertEqual(ExampleModel._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertEqual(ExampleModel._eav_config_cls.eav_attr, "eav")
|
self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav')
|
||||||
|
|
||||||
def test_register_via_metaclass_with_defaults(self):
|
def test_register_via_metaclass_with_defaults(self):
|
||||||
self.assertTrue(hasattr(ExampleMetaclassModel, "_eav_config_cls"))
|
self.assertTrue(hasattr(ExampleMetaclassModel, '_eav_config_cls'))
|
||||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, "eav")
|
self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, 'eav')
|
||||||
|
|
||||||
def test_unregistering(self):
|
def test_unregistering(self):
|
||||||
old_mgr = Patient.objects
|
old_mgr = Patient.objects
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager")
|
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager")
|
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||||
self.assertEqual(Patient.objects, old_mgr)
|
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):
|
def test_unregistering_via_decorator(self):
|
||||||
self.assertTrue(ExampleModel.objects.__class__.__name__ == "EntityManager")
|
self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
||||||
eav.unregister(ExampleModel)
|
eav.unregister(ExampleModel)
|
||||||
self.assertFalse(ExampleModel.objects.__class__.__name__ == "EntityManager")
|
self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
||||||
|
|
||||||
def test_unregistering_via_metaclass(self):
|
def test_unregistering_via_metaclass(self):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
|
ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager'
|
||||||
)
|
)
|
||||||
eav.unregister(ExampleMetaclassModel)
|
eav.unregister(ExampleMetaclassModel)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
|
ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager'
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unregistering_unregistered_model_proceeds_silently(self):
|
def test_unregistering_unregistered_model_proceeds_silently(self):
|
||||||
|
|
@ -98,10 +96,10 @@ class RegistryTests(TestCase):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
|
|
||||||
def test_doesnt_register_nonmodel(self):
|
def test_doesnt_register_nonmodel(self):
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(ValueError):
|
||||||
|
|
||||||
@eav.decorators.register_eav()
|
@eav.decorators.register_eav()
|
||||||
class Foo:
|
class Foo(object):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def test_model_without_local_managers(self):
|
def test_model_without_local_managers(self):
|
||||||
|
|
@ -114,23 +112,3 @@ class RegistryTests(TestCase):
|
||||||
# Reverse check: managers should be empty again
|
# Reverse check: managers should be empty again
|
||||||
eav.unregister(User)
|
eav.unregister(User)
|
||||||
assert bool(User._meta.local_managers) is False
|
assert bool(User._meta.local_managers) is False
|
||||||
|
|
||||||
|
|
||||||
def test_default_manager_stays() -> None:
|
|
||||||
"""
|
|
||||||
Test to ensure default manager remains after registration.
|
|
||||||
|
|
||||||
This test verifies that the default manager of the Doctor model is correctly
|
|
||||||
replaced or maintained after registering a new EntityManager. Specifically,
|
|
||||||
if the model's Meta default_manager_name isn't set, the test ensures that
|
|
||||||
the default manager remains as 'objects' or the first manager declared in
|
|
||||||
the class.
|
|
||||||
"""
|
|
||||||
instance_meta = Doctor._meta
|
|
||||||
assert instance_meta.default_manager_name is None
|
|
||||||
assert isinstance(instance_meta.default_manager, EntityManager)
|
|
||||||
|
|
||||||
# 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 len(instance_meta.managers) == 2
|
|
||||||
|
|
|
||||||
|
|
@ -14,44 +14,44 @@ class RegistryTests(TestCase):
|
||||||
|
|
||||||
def register_encounter(self):
|
def register_encounter(self):
|
||||||
class EncounterEav(EavConfig):
|
class EncounterEav(EavConfig):
|
||||||
manager_attr = "eav_objects"
|
manager_attr = 'eav_objects'
|
||||||
eav_attr = "eav_field"
|
eav_attr = 'eav_field'
|
||||||
generic_relation_attr = "encounter_eav_values"
|
generic_relation_attr = 'encounter_eav_values'
|
||||||
generic_relation_related_name = "encounters"
|
generic_relation_related_name = 'encounters'
|
||||||
|
|
||||||
eav.register(Encounter, EncounterEav)
|
eav.register(Encounter, EncounterEav)
|
||||||
|
|
||||||
def test_registering_with_defaults(self):
|
def test_registering_with_defaults(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertFalse(Patient._eav_config_cls.manager_only)
|
self.assertFalse(Patient._eav_config_cls.manager_only)
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
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_attr, 'eav_values')
|
||||||
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
|
|
||||||
def test_registering_overriding_defaults(self):
|
def test_registering_overriding_defaults(self):
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.register_encounter()
|
self.register_encounter()
|
||||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||||
|
|
||||||
self.assertTrue(hasattr(Encounter, "_eav_config_cls"))
|
self.assertTrue(hasattr(Encounter, '_eav_config_cls'))
|
||||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects")
|
self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects')
|
||||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field")
|
self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field')
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
eav.unregister(Encounter)
|
eav.unregister(Encounter)
|
||||||
|
|
||||||
def test_unregistering(self):
|
def test_unregistering(self):
|
||||||
old_mgr = Patient.objects
|
old_mgr = Patient.objects
|
||||||
eav.register(Patient)
|
eav.register(Patient)
|
||||||
self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager")
|
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager")
|
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||||
self.assertEqual(Patient.objects, old_mgr)
|
self.assertEqual(Patient.objects, old_mgr)
|
||||||
self.assertFalse(hasattr(Patient, "_eav_config_cls"))
|
self.assertFalse(hasattr(Patient, '_eav_config_cls'))
|
||||||
|
|
||||||
def test_unregistering_unregistered_model_proceeds_silently(self):
|
def test_unregistering_unregistered_model_proceeds_silently(self):
|
||||||
eav.unregister(Patient)
|
eav.unregister(Patient)
|
||||||
|
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
import pytest
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import IntegrityError
|
|
||||||
|
|
||||||
from eav.models import Attribute, Value
|
|
||||||
from test_project.models import Doctor, Patient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patient_ct() -> ContentType:
|
|
||||||
"""Return the content type for the Patient model."""
|
|
||||||
return ContentType.objects.get_for_model(Patient)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def doctor_ct() -> ContentType:
|
|
||||||
"""Return the content type for the Doctor model."""
|
|
||||||
# We use Doctor model for UUID tests since it already uses UUID as primary key
|
|
||||||
return ContentType.objects.get_for_model(Doctor)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def attribute() -> Attribute:
|
|
||||||
"""Create and return a test attribute."""
|
|
||||||
return Attribute.objects.create(
|
|
||||||
name="test_attribute",
|
|
||||||
datatype="text",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patient() -> Patient:
|
|
||||||
"""Create and return a patient with integer PK."""
|
|
||||||
# Patient model uses auto-incrementing integer primary keys
|
|
||||||
return Patient.objects.create(name="Patient with Int PK")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def doctor() -> Doctor:
|
|
||||||
"""Create and return a doctor with UUID PK."""
|
|
||||||
# Doctor model uses UUID primary keys, ideal for testing entity_uuid constraints
|
|
||||||
return Doctor.objects.create(name="Doctor with UUID PK")
|
|
||||||
|
|
||||||
|
|
||||||
class TestValueModelValidation:
|
|
||||||
"""Test Value model Python-level validation (via full_clean in save)."""
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_id_validation(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation prevents duplicate entity_id values.
|
|
||||||
|
|
||||||
The model's save() method calls full_clean() which should detect the
|
|
||||||
duplicate before it hits the database constraint.
|
|
||||||
"""
|
|
||||||
# Create first value - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create a second value with the same entity_ct, attribute, and entity_id
|
|
||||||
# This should fail with ValidationError from full_clean()
|
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the error message indicates uniqueness violation
|
|
||||||
assert "already exists" in str(excinfo.value)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_uuid_validation(
|
|
||||||
self,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation prevents duplicate entity_uuid values.
|
|
||||||
|
|
||||||
The model's full_clean() should detect the duplicate before it hits
|
|
||||||
the database constraint.
|
|
||||||
"""
|
|
||||||
# Create first value with UUID - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create a second value with the same entity_ct,
|
|
||||||
# attribute, and entity_uuid
|
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the error message indicates uniqueness violation
|
|
||||||
assert "already exists" in str(excinfo.value)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_entity_id_xor_entity_uuid_validation(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation enforces XOR between entity_id and entity_uuid.
|
|
||||||
|
|
||||||
The model's full_clean() should detect if both or neither field is provided.
|
|
||||||
"""
|
|
||||||
# Try to create with both ID types
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Both IDs provided",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create with neither ID type
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=None,
|
|
||||||
entity_uuid=None,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="No IDs provided",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValueDatabaseConstraints:
|
|
||||||
"""
|
|
||||||
Test Value model database constraints when bypassing model validation.
|
|
||||||
|
|
||||||
These tests use bulk_create() which bypasses the save() method and its
|
|
||||||
full_clean() validation, allowing us to test the database constraints directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_id_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent duplicate entity_id values.
|
|
||||||
|
|
||||||
Even when bypassing model validation with bulk_create, the database
|
|
||||||
constraint should still prevent duplicates.
|
|
||||||
"""
|
|
||||||
# Create first value - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to bulk create a duplicate value, bypassing model validation
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second value",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent duplicate entity_uuid values.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
still prevent duplicates.
|
|
||||||
"""
|
|
||||||
# Create first value with UUID - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to bulk create a duplicate value, bypassing model validation
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second UUID value",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_entity_id_and_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent having both entity_id and entity_uuid.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
prevent having both fields set.
|
|
||||||
"""
|
|
||||||
# Try to bulk create with both ID types
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Both IDs provided",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_neither_entity_id_nor_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent having neither entity_id nor entity_uuid.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
prevent having neither field set.
|
|
||||||
"""
|
|
||||||
# Try to bulk create with neither ID type
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=None,
|
|
||||||
entity_uuid=None,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="No IDs provided",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_happy_path_constraints(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that valid values pass both database constraints.
|
|
||||||
|
|
||||||
Values with either entity_id or entity_uuid (but not both) should be accepted.
|
|
||||||
"""
|
|
||||||
# Test with entity_id using bulk_create
|
|
||||||
values = Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Integer ID bulk created",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert len(values) == 1
|
|
||||||
|
|
||||||
# Test with entity_uuid using bulk_create
|
|
||||||
values = Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="UUID bulk created",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert len(values) == 1
|
|
||||||
Loading…
Reference in a new issue