mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-03-17 06:50:24 +00:00
Compare commits
136 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d941373d34 | ||
|
|
8b70e6afec | ||
|
|
5248498008 | ||
|
|
ce5f58dc50 | ||
|
|
f42a53bcaf | ||
|
|
32e20994f1 | ||
|
|
ec9a5b413d | ||
|
|
dcc643ff81 | ||
|
|
88d367f924 | ||
|
|
f7aeed0b14 | ||
|
|
a32e94adb6 | ||
|
|
794c71c7a6 | ||
|
|
8dd92753d6 | ||
|
|
539f0003a1 | ||
|
|
dc371db44f | ||
|
|
24c1de89fa | ||
|
|
3e5841af10 | ||
|
|
dc49b53c82 | ||
|
|
f193bd41cd | ||
|
|
f7f3d59b30 | ||
|
|
439fa5046f | ||
|
|
fe6db896bd | ||
|
|
682cf61840 | ||
|
|
7e572801b0 | ||
|
|
a38a6b9f5c | ||
|
|
2b9b9d7aa7 | ||
|
|
f6b3cf0865 | ||
|
|
041b19a1d2 | ||
|
|
e789a0dcd3 | ||
|
|
fafe528ea5 | ||
|
|
4deda2abc5 | ||
|
|
28c67b3d04 | ||
|
|
449ddc9248 | ||
|
|
a95f2a1c33 | ||
|
|
996512b04c | ||
|
|
73755c4fdf | ||
|
|
579a1e0fc7 | ||
|
|
b160b38309 | ||
|
|
436edd5492 | ||
|
|
9261d518da | ||
|
|
18148c2b97 | ||
|
|
eca5995616 | ||
|
|
1125887ba9 | ||
|
|
9c68743af8 | ||
|
|
6c44ba988a | ||
|
|
75708e3fbb | ||
|
|
34862ed30a | ||
|
|
abd93a44a1 | ||
|
|
835717bd27 | ||
|
|
3a7d8eec63 | ||
|
|
fef15f0ba6 | ||
|
|
ae73962bb2 | ||
|
|
70fceedda0 | ||
|
|
39c3540592 | ||
|
|
6c3c7f39e8 | ||
|
|
a47b1b05e0 | ||
|
|
b276cb3e35 | ||
|
|
5a1d7546f4 | ||
|
|
dc2cd2dff5 | ||
|
|
0e27224106 | ||
|
|
305740e2e6 | ||
|
|
d281ff97c2 | ||
|
|
e17778b522 | ||
|
|
0f218add0b | ||
|
|
f07e2d0506 | ||
|
|
0d82d5ab5a | ||
|
|
d0b531f7be | ||
|
|
8f18d5e7e2 | ||
|
|
3e7563338f | ||
|
|
1c4355e948 | ||
|
|
353bc5f094 | ||
|
|
17c94198d0 | ||
|
|
625b8a5315 | ||
|
|
6b7a04f8a7 | ||
|
|
9f4bddb94d | ||
|
|
393e3e352a | ||
|
|
56939d9c5e | ||
|
|
3ccf3146eb | ||
|
|
50db5bead4 | ||
|
|
b5b576aca5 | ||
|
|
5e1a7d2803 | ||
|
|
b8bcc383a4 | ||
|
|
41fa7ddc5c | ||
|
|
1262a52282 | ||
|
|
27d3887604 | ||
|
|
3990d7d6cb | ||
|
|
6f141ff4f2 | ||
|
|
ab23ba118d | ||
|
|
03cb115531 | ||
|
|
a56559ebb7 | ||
|
|
3884d2c2aa | ||
|
|
b0a73e2b9c | ||
|
|
cad0846d2c | ||
|
|
c86b909970 | ||
|
|
6e63441ca0 | ||
|
|
d2c34da383 | ||
|
|
c8bc9310d9 | ||
|
|
4940d7fa0b | ||
|
|
8ab1d09627 | ||
|
|
3ea0257a21 | ||
|
|
5dba618e63 | ||
|
|
17e018a0a0 | ||
|
|
c82273e62e | ||
|
|
ceaf3abf40 | ||
|
|
38ddd519cf | ||
|
|
c35d1355b0 | ||
|
|
97eb3f7fc2 | ||
|
|
a4f5a8ae3d | ||
|
|
322753b821 | ||
|
|
c62c927548 | ||
|
|
c0f485e766 | ||
|
|
462e949a08 | ||
|
|
6585010038 | ||
|
|
8c87f2f53b | ||
|
|
1b0ea0b9c4 | ||
|
|
1b73435ff3 | ||
|
|
3c74c2c008 | ||
|
|
04b1926d3a | ||
|
|
6d3892459f | ||
|
|
671fd814b6 | ||
|
|
27dfd357de | ||
|
|
9746ef8218 | ||
|
|
5c804658b9 | ||
|
|
18b821e4a5 | ||
|
|
8529a40d9d | ||
|
|
827c54895f | ||
|
|
26dfc17c1b | ||
|
|
b48882ee9d | ||
|
|
b94cc44db5 | ||
|
|
4a01f13109 | ||
|
|
20c9194372 | ||
|
|
e6cc7bc64e | ||
|
|
6cc8e1206c | ||
|
|
9ee8af9a8a | ||
|
|
19fb9f2017 | ||
|
|
bc64acf39c |
56 changed files with 3370 additions and 2758 deletions
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# 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
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
|
|
|
|||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
|
|
@ -1,24 +1,30 @@
|
|||
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
||||
name: test
|
||||
|
||||
"on": [push, pull_request, workflow_dispatch]
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
django-version: ["3.2", "4.2", "5.0"]
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
django-version: ['4.2', '5.1', '5.2']
|
||||
exclude:
|
||||
- django-version: "3.2"
|
||||
python-version: "3.11"
|
||||
- django-version: "3.2"
|
||||
python-version: "3.12"
|
||||
- django-version: "5.0"
|
||||
python-version: "3.8"
|
||||
- django-version: "5.0"
|
||||
python-version: "3.9"
|
||||
# Exclude Python 3.9 with Django 5.1 and 5.2
|
||||
- python-version: '3.9'
|
||||
django-version: '5.1'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.2'
|
||||
# Exclude Python 3.13 with Django 4.2
|
||||
- python-version: '3.13'
|
||||
django-version: '4.2'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -30,6 +36,7 @@ jobs:
|
|||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: 1.8.4
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
installer-parallel: true
|
||||
|
|
@ -53,6 +60,6 @@ jobs:
|
|||
poetry run pip check
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
|
@ -9,12 +9,15 @@ repos:
|
|||
- id: check-added-large-files
|
||||
- id: mixed-line-ending
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.2.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
entry: black --target-version=py36
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/remastr/pre-commit-django-check-migrations
|
||||
rev: v0.1.0
|
||||
|
|
|
|||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -2,6 +2,41 @@
|
|||
|
||||
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)
|
||||
|
||||
### What's Changed
|
||||
|
|
|
|||
|
|
@ -2,30 +2,35 @@
|
|||
#
|
||||
# More information on the configuration options is available at:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict
|
||||
from pathlib import Path
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from sphinx.ext.autodoc import between
|
||||
|
||||
sys.path.insert(0, os.path.abspath('.'))
|
||||
sys.path.insert(0, os.path.abspath('../../'))
|
||||
# For discovery of Python modules
|
||||
sys.path.insert(0, str(Path().cwd()))
|
||||
|
||||
# For finding the django_settings.py file
|
||||
sys.path.insert(0, str(Path("../../").resolve()))
|
||||
|
||||
|
||||
# Pass settings into configure.
|
||||
settings.configure(
|
||||
INSTALLED_APPS=[
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'eav',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"eav",
|
||||
],
|
||||
SECRET_KEY=os.environ.get('DJANGO_SECRET_KEY', 'this-is-not-s3cur3'),
|
||||
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"),
|
||||
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
|
||||
)
|
||||
|
||||
|
|
@ -34,22 +39,22 @@ django.setup()
|
|||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Django EAV 2'
|
||||
copyright = '2018, Iwo Herka and team at MAKIMO'
|
||||
author = '-'
|
||||
project = "Django EAV 2"
|
||||
copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001
|
||||
author = "-"
|
||||
|
||||
# The short X.Y version
|
||||
version = ''
|
||||
version = ""
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '0.10.0'
|
||||
release = "0.10.0"
|
||||
|
||||
|
||||
def setup(app):
|
||||
"""Use the configuration file itself as an extension."""
|
||||
app.connect(
|
||||
'autodoc-process-docstring',
|
||||
"autodoc-process-docstring",
|
||||
between(
|
||||
'^.*IGNORE.*$',
|
||||
"^.*IGNORE.*$",
|
||||
exclude=True,
|
||||
),
|
||||
)
|
||||
|
|
@ -59,57 +64,57 @@ def setup(app):
|
|||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
extensions = [
|
||||
'sphinx.ext.napoleon',
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.coverage',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx_rtd_theme',
|
||||
"sphinx.ext.napoleon",
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.coverage",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx_rtd_theme",
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
source_suffix = '.rst'
|
||||
source_suffix = ".rst"
|
||||
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
language = 'en'
|
||||
language = "en"
|
||||
|
||||
exclude_patterns = ['build']
|
||||
exclude_patterns = ["build"]
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
pygments_style = "sphinx"
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
html_static_path = ['_static']
|
||||
html_static_path = ["_static"]
|
||||
|
||||
html_sidebars = {
|
||||
'index': ['sidebarintro.html', 'localtoc.html'],
|
||||
'**': [
|
||||
'sidebarintro.html',
|
||||
'localtoc.html',
|
||||
'relations.html',
|
||||
'searchbox.html',
|
||||
"index": ["sidebarintro.html", "localtoc.html"],
|
||||
"**": [
|
||||
"sidebarintro.html",
|
||||
"localtoc.html",
|
||||
"relations.html",
|
||||
"searchbox.html",
|
||||
],
|
||||
}
|
||||
|
||||
htmlhelp_basename = 'DjangoEAV2doc'
|
||||
htmlhelp_basename = "DjangoEAV2doc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ------------------------------------------------
|
||||
|
||||
latex_elements: Dict[str, str] = {}
|
||||
latex_elements: dict[str, str] = {}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', '-', 'manual'),
|
||||
(master_doc, "DjangoEAV2.tex", "Django EAV 2 Documentation", "-", "manual"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -120,8 +125,8 @@ latex_documents = [
|
|||
man_pages = [
|
||||
(
|
||||
master_doc,
|
||||
'djangoeav2',
|
||||
'Django EAV 2 Documentation',
|
||||
"djangoeav2",
|
||||
"Django EAV 2 Documentation",
|
||||
[author],
|
||||
1,
|
||||
),
|
||||
|
|
@ -136,12 +141,12 @@ man_pages = [
|
|||
texinfo_documents = [
|
||||
(
|
||||
master_doc,
|
||||
'DjangoEAV2',
|
||||
'Django EAV 2 Documentation',
|
||||
"DjangoEAV2",
|
||||
"Django EAV 2 Documentation",
|
||||
author,
|
||||
'DjangoEAV2',
|
||||
'One line description of project.',
|
||||
'Miscellaneous',
|
||||
"DjangoEAV2",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -150,7 +155,7 @@ texinfo_documents = [
|
|||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
||||
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||
|
||||
# -- Autodoc configuration ---------------------------------------------------
|
||||
|
||||
|
|
|
|||
49
eav/admin.py
49
eav/admin.py
|
|
@ -1,6 +1,8 @@
|
|||
"""This module contains classes used for admin integration."""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
||||
|
|
@ -9,8 +11,13 @@ from django.utils.safestring import mark_safe
|
|||
|
||||
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]
|
||||
|
||||
some_attribute = ClassVar[Dict[str, str]]
|
||||
|
||||
|
||||
class BaseEntityAdmin(ModelAdmin):
|
||||
"""Custom admin model to support dynamic EAV fieldsets.
|
||||
|
|
@ -26,7 +33,7 @@ class BaseEntityAdmin(ModelAdmin):
|
|||
"""
|
||||
|
||||
eav_fieldset_title: str = "EAV Attributes"
|
||||
eav_fieldset_description: Optional[str] = None
|
||||
eav_fieldset_description: str | None = None
|
||||
|
||||
def render_change_form(self, request, context, *args, **kwargs):
|
||||
"""Dynamically modifies the admin form to include EAV fields.
|
||||
|
|
@ -45,7 +52,7 @@ class BaseEntityAdmin(ModelAdmin):
|
|||
Returns:
|
||||
HttpResponse object representing the rendered change form.
|
||||
"""
|
||||
form = context['adminform'].form
|
||||
form = context["adminform"].form
|
||||
|
||||
# Identify EAV fields based on the form instance's configuration.
|
||||
eav_fields = self._get_eav_fields(form.instance)
|
||||
|
|
@ -55,7 +62,7 @@ class BaseEntityAdmin(ModelAdmin):
|
|||
return super().render_change_form(request, context, *args, **kwargs)
|
||||
|
||||
# Get the non-EAV fieldsets and then append our own
|
||||
fieldsets = list(self.get_fieldsets(request, kwargs['obj']))
|
||||
fieldsets = list(self.get_fieldsets(request, kwargs["obj"]))
|
||||
fieldsets.append(self._get_eav_fieldset(eav_fields))
|
||||
|
||||
# Reconstruct the admin form with updated fieldsets.
|
||||
|
|
@ -65,18 +72,18 @@ class BaseEntityAdmin(ModelAdmin):
|
|||
# Clear prepopulated fields on a view-only form to avoid a crash.
|
||||
(
|
||||
self.prepopulated_fields
|
||||
if self.has_change_permission(request, kwargs['obj'])
|
||||
if self.has_change_permission(request, kwargs["obj"])
|
||||
else {}
|
||||
),
|
||||
readonly_fields=self.readonly_fields,
|
||||
model_admin=self,
|
||||
)
|
||||
media = mark_safe(context['media'] + adminform.media)
|
||||
media = mark_safe(context["media"] + adminform.media) # noqa: S308
|
||||
context.update(adminform=adminform, media=media)
|
||||
|
||||
return super().render_change_form(request, context, *args, **kwargs)
|
||||
|
||||
def _get_eav_fields(self, instance) -> List[str]:
|
||||
def _get_eav_fields(self, instance) -> list[str]:
|
||||
"""Retrieves a list of EAV field slugs for the given instance.
|
||||
|
||||
Args:
|
||||
|
|
@ -85,24 +92,24 @@ class BaseEntityAdmin(ModelAdmin):
|
|||
Returns:
|
||||
A list of strings representing the slugs of EAV fields.
|
||||
"""
|
||||
entity = getattr(instance, instance._eav_config_cls.eav_attr)
|
||||
return list(entity.get_all_attributes().values_list('slug', flat=True))
|
||||
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||
return list(entity.get_all_attributes().values_list("slug", flat=True))
|
||||
|
||||
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
|
||||
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
|
||||
|
||||
Generates a list representing a fieldset specifically for Entity-Attribute-Value (EAV) fields,
|
||||
intended to be appended to the admin form's fieldsets configuration. This facilitates the
|
||||
dynamic inclusion of EAV fields within the Django admin interface by creating a designated
|
||||
section for these attributes.
|
||||
Generates a list representing a fieldset specifically for Entity-Attribute-Value
|
||||
(EAV) fields, intended to be appended to the admin form's fieldsets
|
||||
configuration. This facilitates the dynamic inclusion of EAV fields within the
|
||||
Django admin interface by creating a designated section for these attributes.
|
||||
|
||||
Args:
|
||||
eav_fields (List[str]): A list of slugs representing the EAV fields to be included
|
||||
in the EAV Attributes fieldset.
|
||||
eav_fields (List[str]): A list of slugs representing the EAV fields to be
|
||||
included in the EAV Attributes fieldset.
|
||||
"""
|
||||
return [
|
||||
self.eav_fieldset_title,
|
||||
{'fields': eav_fields, 'description': self.eav_fieldset_description},
|
||||
{"fields": eav_fields, "description": self.eav_fieldset_description},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -114,9 +121,9 @@ class BaseEntityInlineFormSet(BaseInlineFormSet):
|
|||
def add_fields(self, form, index):
|
||||
if self.instance:
|
||||
setattr(form.instance, self.fk.name, self.instance)
|
||||
form._build_dynamic_fields()
|
||||
form._build_dynamic_fields() # noqa: SLF001
|
||||
|
||||
super(BaseEntityInlineFormSet, self).add_fields(form, index)
|
||||
super().add_fields(form, index)
|
||||
|
||||
|
||||
class BaseEntityInline(InlineModelAdmin):
|
||||
|
|
@ -147,12 +154,12 @@ class BaseEntityInline(InlineModelAdmin):
|
|||
instance = self.model(**kw)
|
||||
form = formset.form(request.POST, instance=instance)
|
||||
|
||||
return [(None, {'fields': form.fields.keys()})]
|
||||
return [(None, {"fields": form.fields.keys()})]
|
||||
|
||||
|
||||
class AttributeAdmin(ModelAdmin):
|
||||
list_display = ('name', 'slug', 'datatype', 'description')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
list_display = ("name", "slug", "datatype", "description")
|
||||
prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)}
|
||||
|
||||
|
||||
admin.site.register(Attribute, AttributeAdmin)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ def register_eav(**kwargs):
|
|||
|
||||
def _model_eav_wrapper(model_class):
|
||||
if not issubclass(model_class, Model):
|
||||
raise ValueError('Wrapped class must subclass Model.')
|
||||
raise TypeError("Wrapped class must subclass Model.")
|
||||
register(model_class, **kwargs)
|
||||
return model_class
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
class IllegalAssignmentException(Exception):
|
||||
class IllegalAssignmentException(Exception): # noqa: N818
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class EavDatatypeField(models.CharField):
|
|||
:class:`~eav.models.Attribute` that is already used by
|
||||
:class:`~eav.models.Value` objects.
|
||||
"""
|
||||
super(EavDatatypeField, self).validate(value, instance)
|
||||
super().validate(value, instance)
|
||||
|
||||
if not instance.pk:
|
||||
return
|
||||
|
|
@ -31,8 +31,9 @@ class EavDatatypeField(models.CharField):
|
|||
if instance.value_set.count():
|
||||
raise ValidationError(
|
||||
_(
|
||||
'You cannot change the datatype of an attribute that is already in use.'
|
||||
)
|
||||
"You cannot change the datatype of an "
|
||||
+ "attribute that is already in use.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -42,21 +43,21 @@ class CSVField(models.TextField): # (models.Field):
|
|||
|
||||
def __init__(self, separator=";", *args, **kwargs):
|
||||
self.separator = separator
|
||||
kwargs.setdefault('default', "")
|
||||
kwargs.setdefault("default", "")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
if self.separator != self.default_separator:
|
||||
kwargs['separator'] = self.separator
|
||||
kwargs["separator"] = self.separator
|
||||
return name, path, args, kwargs
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': CSVFormField}
|
||||
defaults = {"form_class": CSVFormField}
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
def from_db_value(self, value, expression, connection, context=None):
|
||||
def from_db_value(self, value, expression, connection):
|
||||
if value is None:
|
||||
return []
|
||||
return value.split(self.separator)
|
||||
|
|
@ -73,8 +74,9 @@ class CSVField(models.TextField): # (models.Field):
|
|||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, list):
|
||||
if isinstance(value, list):
|
||||
return self.separator.join(value)
|
||||
return value
|
||||
|
||||
def value_to_string(self, obj):
|
||||
value = self.value_from_object(obj)
|
||||
|
|
|
|||
73
eav/forms.py
73
eav/forms.py
|
|
@ -1,6 +1,9 @@
|
|||
"""This module contains forms used for admin integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import ClassVar
|
||||
|
||||
from django.contrib.admin.widgets import AdminSplitDateTime
|
||||
from django.core.exceptions import ValidationError
|
||||
|
|
@ -21,14 +24,14 @@ from eav.widgets import CSVWidget
|
|||
|
||||
|
||||
class CSVFormField(Field):
|
||||
message = _('Enter comma-separated-values. eg: one;two;three.')
|
||||
code = 'invalid'
|
||||
message = _("Enter comma-separated-values. eg: one;two;three.")
|
||||
code = "invalid"
|
||||
widget = CSVWidget
|
||||
default_separator = ";"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('max_length', None)
|
||||
self.separator = kwargs.pop('separator', self.default_separator)
|
||||
kwargs.pop("max_length", None)
|
||||
self.separator = kwargs.pop("separator", self.default_separator)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
|
|
@ -38,9 +41,8 @@ class CSVFormField(Field):
|
|||
|
||||
def validate(self, field_value):
|
||||
super().validate(field_value)
|
||||
try:
|
||||
isinstance(field_value, list)
|
||||
except ValidationError:
|
||||
|
||||
if not isinstance(field_value, list):
|
||||
raise ValidationError(self.message, code=self.code)
|
||||
|
||||
|
||||
|
|
@ -70,20 +72,20 @@ class BaseDynamicEntityForm(ModelForm):
|
|||
===== =============
|
||||
"""
|
||||
|
||||
FIELD_CLASSES = {
|
||||
'text': CharField,
|
||||
'float': FloatField,
|
||||
'int': IntegerField,
|
||||
'date': SplitDateTimeField,
|
||||
'bool': BooleanField,
|
||||
'enum': ChoiceField,
|
||||
'json': JSONField,
|
||||
'csv': CSVFormField,
|
||||
FIELD_CLASSES: ClassVar[dict[str, Field]] = {
|
||||
"text": CharField,
|
||||
"float": FloatField,
|
||||
"int": IntegerField,
|
||||
"date": SplitDateTimeField,
|
||||
"bool": BooleanField,
|
||||
"enum": ChoiceField,
|
||||
"json": JSONField,
|
||||
"csv": CSVFormField,
|
||||
}
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs)
|
||||
config_cls = self.instance._eav_config_cls
|
||||
super().__init__(data, *args, **kwargs)
|
||||
config_cls = self.instance._eav_config_cls # noqa: SLF001
|
||||
self.entity = getattr(self.instance, config_cls.eav_attr)
|
||||
self._build_dynamic_fields()
|
||||
|
||||
|
|
@ -95,35 +97,35 @@ class BaseDynamicEntityForm(ModelForm):
|
|||
value = getattr(self.entity, attribute.slug)
|
||||
|
||||
defaults = {
|
||||
'label': attribute.name.capitalize(),
|
||||
'required': attribute.required,
|
||||
'help_text': attribute.help_text,
|
||||
'validators': attribute.get_validators(),
|
||||
"label": attribute.name.capitalize(),
|
||||
"required": attribute.required,
|
||||
"help_text": attribute.help_text,
|
||||
"validators": attribute.get_validators(),
|
||||
}
|
||||
|
||||
datatype = attribute.datatype
|
||||
|
||||
if datatype == attribute.TYPE_ENUM:
|
||||
values = attribute.get_choices().values_list('id', 'value')
|
||||
choices = [('', '-----')] + list(values)
|
||||
defaults.update({'choices': choices})
|
||||
values = attribute.get_choices().values_list("id", "value")
|
||||
choices = [("", ""), ("-----", "-----"), *list(values)]
|
||||
defaults.update({"choices": choices})
|
||||
|
||||
if value:
|
||||
defaults.update({'initial': value.pk})
|
||||
defaults.update({"initial": value.pk})
|
||||
|
||||
elif datatype == attribute.TYPE_DATE:
|
||||
defaults.update({'widget': AdminSplitDateTime})
|
||||
defaults.update({"widget": AdminSplitDateTime})
|
||||
elif datatype == attribute.TYPE_OBJECT:
|
||||
continue
|
||||
|
||||
MappedField = self.FIELD_CLASSES[datatype]
|
||||
MappedField = self.FIELD_CLASSES[datatype] # noqa: N806
|
||||
self.fields[attribute.slug] = MappedField(**defaults)
|
||||
|
||||
# Fill initial data (if attribute was already defined).
|
||||
if value and not datatype == attribute.TYPE_ENUM:
|
||||
if value and datatype != attribute.TYPE_ENUM:
|
||||
self.initial[attribute.slug] = value
|
||||
|
||||
def save(self, commit=True):
|
||||
def save(self, *, commit=True):
|
||||
"""
|
||||
Saves this ``form``'s cleaned_data into model instance
|
||||
``self.instance`` and related EAV attributes. Returns ``instance``.
|
||||
|
|
@ -131,23 +133,20 @@ class BaseDynamicEntityForm(ModelForm):
|
|||
if self.errors:
|
||||
raise ValueError(
|
||||
_(
|
||||
'The %s could not be saved because the data'
|
||||
'didn\'t validate.' % self.instance._meta.object_name
|
||||
"The %s could not be saved because the data didn't validate.",
|
||||
)
|
||||
% self.instance._meta.object_name, # noqa: SLF001
|
||||
)
|
||||
|
||||
# Create entity instance, don't save yet.
|
||||
instance = super(BaseDynamicEntityForm, self).save(commit=False)
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Assign attributes.
|
||||
for attribute in self.entity.get_all_attributes():
|
||||
value = self.cleaned_data.get(attribute.slug)
|
||||
|
||||
if attribute.datatype == attribute.TYPE_ENUM:
|
||||
if value:
|
||||
value = attribute.enum_group.values.get(pk=value)
|
||||
else:
|
||||
value = None
|
||||
value = attribute.enum_group.values.get(pk=value) if value else None
|
||||
|
||||
setattr(self.entity, attribute.slug, value)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ def get_entity_pk_type(entity_cls) -> str:
|
|||
These values map to `models.Value` as potential fields to use to relate
|
||||
to the proper entity via the correct PK type.
|
||||
"""
|
||||
if isinstance(entity_cls._meta.pk, UUIDField):
|
||||
return 'entity_uuid'
|
||||
return 'entity_id'
|
||||
if isinstance(entity_cls._meta.pk, UUIDField): # noqa: SLF001
|
||||
return "entity_uuid"
|
||||
return "entity_id"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import uuid
|
||||
from functools import partial
|
||||
from typing import Type
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
|
@ -24,7 +23,7 @@ _FIELD_MAPPING = {
|
|||
}
|
||||
|
||||
|
||||
def get_pk_format() -> Type[models.Field]:
|
||||
def get_pk_format() -> models.Field:
|
||||
"""
|
||||
Get the primary key field format based on the Django settings.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from typing import Final
|
||||
|
|
@ -7,16 +9,53 @@ from django.utils.text import slugify
|
|||
SLUGFIELD_MAX_LENGTH: Final = 50
|
||||
|
||||
|
||||
def generate_slug(name: str) -> str:
|
||||
"""Generates a valid slug based on ``name``."""
|
||||
slug = slugify(name, allow_unicode=False)
|
||||
def non_identifier_chars() -> dict[str, str]:
|
||||
"""Generate a mapping of non-identifier characters to their Unicode representations.
|
||||
|
||||
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:
|
||||
# Fallback to ensure a slug is always generated by using a random one
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
randstr = ''.join(secrets.choice(chars) for _ in range(8))
|
||||
slug = 'rand-{0}'.format(randstr)
|
||||
randstr = "".join(secrets.choice(chars) for _ in range(8))
|
||||
slug = f"rand_{randstr}"
|
||||
|
||||
slug = slug.encode('utf-8', 'surrogateescape').decode()
|
||||
# Ensure the slug doesn't start with a digit to make it a valid Python identifier.
|
||||
if slug[0].isdigit():
|
||||
slug = "_" + slug
|
||||
|
||||
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
|
||||
the object, then assign and save it's eav attributes.
|
||||
"""
|
||||
config_cls = getattr(self.model, '_eav_config_cls', None)
|
||||
config_cls = getattr(self.model, "_eav_config_cls", None)
|
||||
|
||||
if not config_cls or config_cls.manager_only:
|
||||
return super(EntityManager, self).create(**kwargs)
|
||||
return super().create(**kwargs)
|
||||
|
||||
prefix = '%s__' % config_cls.eav_attr
|
||||
prefix = f"{config_cls.eav_attr}__"
|
||||
new_kwargs = {}
|
||||
eav_kwargs = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,215 +8,220 @@ import eav.fields
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Initial migration that creates the Attribute, EnumGroup, EnumValue, and Value models."""
|
||||
"""Initial migration for the Attribute, EnumGroup, EnumValue, and Value models."""
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Attribute',
|
||||
name="Attribute",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
'name',
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text='User-friendly attribute name',
|
||||
help_text="User-friendly attribute name",
|
||||
max_length=100,
|
||||
verbose_name='Name',
|
||||
verbose_name="Name",
|
||||
),
|
||||
),
|
||||
(
|
||||
'slug',
|
||||
"slug",
|
||||
models.SlugField(
|
||||
help_text='Short unique attribute label',
|
||||
help_text="Short unique attribute label",
|
||||
unique=True,
|
||||
verbose_name='Slug',
|
||||
verbose_name="Slug",
|
||||
),
|
||||
),
|
||||
(
|
||||
'description',
|
||||
"description",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text='Short description',
|
||||
help_text="Short description",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name='Description',
|
||||
verbose_name="Description",
|
||||
),
|
||||
),
|
||||
(
|
||||
'datatype',
|
||||
"datatype",
|
||||
eav.fields.EavDatatypeField(
|
||||
choices=[
|
||||
('text', 'Text'),
|
||||
('date', 'Date'),
|
||||
('float', 'Float'),
|
||||
('int', 'Integer'),
|
||||
('bool', 'True / False'),
|
||||
('object', 'Django Object'),
|
||||
('enum', 'Multiple Choice'),
|
||||
("text", "Text"),
|
||||
("date", "Date"),
|
||||
("float", "Float"),
|
||||
("int", "Integer"),
|
||||
("bool", "True / False"),
|
||||
("object", "Django Object"),
|
||||
("enum", "Multiple Choice"),
|
||||
],
|
||||
max_length=6,
|
||||
verbose_name='Data Type',
|
||||
verbose_name="Data Type",
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name='Created',
|
||||
verbose_name="Created",
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
models.DateTimeField(auto_now=True, verbose_name='Modified'),
|
||||
"modified",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
||||
),
|
||||
(
|
||||
'required',
|
||||
models.BooleanField(default=False, verbose_name='Required'),
|
||||
"required",
|
||||
models.BooleanField(default=False, verbose_name="Required"),
|
||||
),
|
||||
(
|
||||
'display_order',
|
||||
"display_order",
|
||||
models.PositiveIntegerField(
|
||||
default=1, verbose_name='Display order'
|
||||
default=1,
|
||||
verbose_name="Display order",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnumGroup',
|
||||
name="EnumGroup",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
'name',
|
||||
models.CharField(max_length=100, unique=True, verbose_name='Name'),
|
||||
"name",
|
||||
models.CharField(max_length=100, unique=True, verbose_name="Name"),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnumValue',
|
||||
name="EnumValue",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
'value',
|
||||
"value",
|
||||
models.CharField(
|
||||
db_index=True, max_length=50, unique=True, verbose_name='Value'
|
||||
db_index=True,
|
||||
max_length=50,
|
||||
unique=True,
|
||||
verbose_name="Value",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Value',
|
||||
name="Value",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
('entity_id', models.IntegerField()),
|
||||
('value_text', models.TextField(blank=True, null=True)),
|
||||
('value_float', models.FloatField(blank=True, null=True)),
|
||||
('value_int', models.IntegerField(blank=True, null=True)),
|
||||
('value_date', models.DateTimeField(blank=True, null=True)),
|
||||
('value_bool', models.NullBooleanField()),
|
||||
('generic_value_id', models.IntegerField(blank=True, null=True)),
|
||||
("entity_id", models.IntegerField()),
|
||||
("value_text", models.TextField(blank=True, null=True)),
|
||||
("value_float", models.FloatField(blank=True, null=True)),
|
||||
("value_int", models.IntegerField(blank=True, null=True)),
|
||||
("value_date", models.DateTimeField(blank=True, null=True)),
|
||||
("value_bool", models.NullBooleanField()),
|
||||
("generic_value_id", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
'created',
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name='Created'
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
models.DateTimeField(auto_now=True, verbose_name='Modified'),
|
||||
"modified",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
||||
),
|
||||
(
|
||||
'attribute',
|
||||
"attribute",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to='eav.Attribute',
|
||||
verbose_name='Attribute',
|
||||
to="eav.Attribute",
|
||||
verbose_name="Attribute",
|
||||
),
|
||||
),
|
||||
(
|
||||
'entity_ct',
|
||||
"entity_ct",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='value_entities',
|
||||
to='contenttypes.ContentType',
|
||||
related_name="value_entities",
|
||||
to="contenttypes.ContentType",
|
||||
),
|
||||
),
|
||||
(
|
||||
'generic_value_ct',
|
||||
"generic_value_ct",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='value_values',
|
||||
to='contenttypes.ContentType',
|
||||
related_name="value_values",
|
||||
to="contenttypes.ContentType",
|
||||
),
|
||||
),
|
||||
(
|
||||
'value_enum',
|
||||
"value_enum",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='eav_values',
|
||||
to='eav.EnumValue',
|
||||
related_name="eav_values",
|
||||
to="eav.EnumValue",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enumgroup',
|
||||
name='values',
|
||||
field=models.ManyToManyField(to='eav.EnumValue', verbose_name='Enum group'),
|
||||
model_name="enumgroup",
|
||||
name="values",
|
||||
field=models.ManyToManyField(to="eav.EnumValue", verbose_name="Enum group"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='attribute',
|
||||
name='enum_group',
|
||||
model_name="attribute",
|
||||
name="enum_group",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to='eav.EnumGroup',
|
||||
verbose_name='Choice Group',
|
||||
to="eav.EnumGroup",
|
||||
verbose_name="Choice Group",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ class Migration(migrations.Migration):
|
|||
"""Add entity_ct field to Attribute model."""
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('eav', '0001_initial'),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("eav", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='attribute',
|
||||
name='entity_ct',
|
||||
field=models.ManyToManyField(blank=True, to='contenttypes.ContentType'),
|
||||
model_name="attribute",
|
||||
name="entity_ct",
|
||||
field=models.ManyToManyField(blank=True, to="contenttypes.ContentType"),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import eav.fields
|
|||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('eav', '0002_add_entity_ct_field'),
|
||||
("eav", "0002_add_entity_ct_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='value',
|
||||
name='value_json',
|
||||
model_name="value",
|
||||
name="value_json",
|
||||
field=JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
|
|
@ -24,21 +24,21 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='attribute',
|
||||
name='datatype',
|
||||
model_name="attribute",
|
||||
name="datatype",
|
||||
field=eav.fields.EavDatatypeField(
|
||||
choices=[
|
||||
('text', 'Text'),
|
||||
('date', 'Date'),
|
||||
('float', 'Float'),
|
||||
('int', 'Integer'),
|
||||
('bool', 'True / False'),
|
||||
('object', 'Django Object'),
|
||||
('enum', 'Multiple Choice'),
|
||||
('json', 'JSON Object'),
|
||||
("text", "Text"),
|
||||
("date", "Date"),
|
||||
("float", "Float"),
|
||||
("int", "Integer"),
|
||||
("bool", "True / False"),
|
||||
("object", "Django Object"),
|
||||
("enum", "Multiple Choice"),
|
||||
("json", "JSON Object"),
|
||||
],
|
||||
max_length=6,
|
||||
verbose_name='Data Type',
|
||||
verbose_name="Data Type",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ from django.db import migrations, models
|
|||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('eav', '0003_auto_20210404_2209'),
|
||||
("eav", "0003_auto_20210404_2209"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_bool',
|
||||
model_name="value",
|
||||
name="value_bool",
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,32 +7,32 @@ import eav.fields
|
|||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('eav', '0004_alter_value_value_bool'),
|
||||
("eav", "0004_alter_value_value_bool"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='value',
|
||||
name='value_csv',
|
||||
model_name="value",
|
||||
name="value_csv",
|
||||
field=eav.fields.CSVField(blank=True, default="", null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='attribute',
|
||||
name='datatype',
|
||||
model_name="attribute",
|
||||
name="datatype",
|
||||
field=eav.fields.EavDatatypeField(
|
||||
choices=[
|
||||
('text', 'Text'),
|
||||
('date', 'Date'),
|
||||
('float', 'Float'),
|
||||
('int', 'Integer'),
|
||||
('bool', 'True / False'),
|
||||
('object', 'Django Object'),
|
||||
('enum', 'Multiple Choice'),
|
||||
('json', 'JSON Object'),
|
||||
('csv', 'Comma-Separated-Value'),
|
||||
("text", "Text"),
|
||||
("date", "Date"),
|
||||
("float", "Float"),
|
||||
("int", "Integer"),
|
||||
("bool", "True / False"),
|
||||
("object", "Django Object"),
|
||||
("enum", "Multiple Choice"),
|
||||
("json", "JSON Object"),
|
||||
("csv", "Comma-Separated-Value"),
|
||||
],
|
||||
max_length=6,
|
||||
verbose_name='Data Type',
|
||||
verbose_name="Data Type",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ class Migration(migrations.Migration):
|
|||
"""Creates UUID field to map to Entity FK."""
|
||||
|
||||
dependencies = [
|
||||
('eav', '0005_auto_20210510_1305'),
|
||||
("eav", "0005_auto_20210510_1305"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='value',
|
||||
name='entity_uuid',
|
||||
model_name="value",
|
||||
name="entity_uuid",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='entity_id',
|
||||
model_name="value",
|
||||
name="entity_id",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ class Migration(migrations.Migration):
|
|||
"""Convert Value.value_int to BigInteger."""
|
||||
|
||||
dependencies = [
|
||||
('eav', '0006_add_entity_uuid'),
|
||||
("eav", "0006_add_entity_uuid"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_int',
|
||||
model_name="value",
|
||||
name="value_int",
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ class Migration(migrations.Migration):
|
|||
"""Use Django SlugField() for Attribute.slug."""
|
||||
|
||||
dependencies = [
|
||||
('eav', '0007_alter_value_value_int'),
|
||||
("eav", "0007_alter_value_value_int"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attribute',
|
||||
name='slug',
|
||||
model_name="attribute",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
help_text='Short unique attribute label',
|
||||
help_text="Short unique attribute label",
|
||||
unique=True,
|
||||
verbose_name='Slug',
|
||||
verbose_name="Slug",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,171 +8,171 @@ class Migration(migrations.Migration):
|
|||
"""Define verbose naming for models and fields."""
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('eav', '0008_use_native_slugfield'),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("eav", "0008_use_native_slugfield"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='attribute',
|
||||
name="attribute",
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'verbose_name': 'Attribute',
|
||||
'verbose_name_plural': 'Attributes',
|
||||
"ordering": ["name"],
|
||||
"verbose_name": "Attribute",
|
||||
"verbose_name_plural": "Attributes",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='enumgroup',
|
||||
name="enumgroup",
|
||||
options={
|
||||
'verbose_name': 'EnumGroup',
|
||||
'verbose_name_plural': 'EnumGroups',
|
||||
"verbose_name": "EnumGroup",
|
||||
"verbose_name_plural": "EnumGroups",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='enumvalue',
|
||||
name="enumvalue",
|
||||
options={
|
||||
'verbose_name': 'EnumValue',
|
||||
'verbose_name_plural': 'EnumValues',
|
||||
"verbose_name": "EnumValue",
|
||||
"verbose_name_plural": "EnumValues",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='value',
|
||||
options={'verbose_name': 'Value', 'verbose_name_plural': 'Values'},
|
||||
name="value",
|
||||
options={"verbose_name": "Value", "verbose_name_plural": "Values"},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='attribute',
|
||||
name='entity_ct',
|
||||
model_name="attribute",
|
||||
name="entity_ct",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
to='contenttypes.contenttype',
|
||||
verbose_name='Entity content type',
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Entity content type",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='entity_ct',
|
||||
model_name="value",
|
||||
name="entity_ct",
|
||||
field=models.ForeignKey(
|
||||
on_delete=models.deletion.PROTECT,
|
||||
related_name='value_entities',
|
||||
to='contenttypes.contenttype',
|
||||
verbose_name='Entity ct',
|
||||
related_name="value_entities",
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Entity ct",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='entity_id',
|
||||
model_name="value",
|
||||
name="entity_id",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Entity id',
|
||||
verbose_name="Entity id",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='entity_uuid',
|
||||
model_name="value",
|
||||
name="entity_uuid",
|
||||
field=models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Entity uuid',
|
||||
verbose_name="Entity uuid",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='generic_value_ct',
|
||||
model_name="value",
|
||||
name="generic_value_ct",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.PROTECT,
|
||||
related_name='value_values',
|
||||
to='contenttypes.contenttype',
|
||||
verbose_name='Generic value content type',
|
||||
related_name="value_values",
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Generic value content type",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='generic_value_id',
|
||||
model_name="value",
|
||||
name="generic_value_id",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Generic value id',
|
||||
verbose_name="Generic value id",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_bool',
|
||||
model_name="value",
|
||||
name="value_bool",
|
||||
field=models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Value bool',
|
||||
verbose_name="Value bool",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_csv',
|
||||
model_name="value",
|
||||
name="value_csv",
|
||||
field=CSVField(
|
||||
blank=True,
|
||||
default='',
|
||||
default="",
|
||||
null=True,
|
||||
verbose_name='Value CSV',
|
||||
verbose_name="Value CSV",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_date',
|
||||
model_name="value",
|
||||
name="value_date",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Value date',
|
||||
verbose_name="Value date",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_enum',
|
||||
model_name="value",
|
||||
name="value_enum",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.PROTECT,
|
||||
related_name='eav_values',
|
||||
to='eav.enumvalue',
|
||||
verbose_name='Value enum',
|
||||
related_name="eav_values",
|
||||
to="eav.enumvalue",
|
||||
verbose_name="Value enum",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_float',
|
||||
model_name="value",
|
||||
name="value_float",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Value float',
|
||||
verbose_name="Value float",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_int',
|
||||
model_name="value",
|
||||
name="value_int",
|
||||
field=models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Value int',
|
||||
verbose_name="Value int",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_json',
|
||||
model_name="value",
|
||||
name="value_json",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
null=True,
|
||||
verbose_name='Value JSON',
|
||||
verbose_name="Value JSON",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='value_text',
|
||||
model_name="value",
|
||||
name="value_text",
|
||||
field=models.TextField(
|
||||
blank=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."""
|
||||
|
||||
dependencies = [
|
||||
('eav', '0009_enchance_naming'),
|
||||
("eav", "0009_enchance_naming"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='attribute',
|
||||
name='id',
|
||||
model_name="attribute",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
|
|
@ -19,8 +19,8 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='enumgroup',
|
||||
name='id',
|
||||
model_name="enumgroup",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
|
|
@ -28,8 +28,8 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='enumvalue',
|
||||
name='id',
|
||||
model_name="enumvalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
|
|
@ -37,8 +37,8 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='value',
|
||||
name='id',
|
||||
model_name="value",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
|
|
|
|||
36
eav/migrations/0011_update_defaults_and_meta.py
Normal file
36
eav/migrations/0011_update_defaults_and_meta.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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"),
|
||||
),
|
||||
]
|
||||
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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__ = [
|
||||
"Attribute",
|
||||
"EAVModelMeta",
|
||||
"Entity",
|
||||
"EnumGroup",
|
||||
"EnumValue",
|
||||
"Value",
|
||||
"Entity",
|
||||
"EAVModelMeta",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# ruff: noqa: UP007
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Tuple # noqa: UP035
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
|
@ -79,40 +82,36 @@ class Attribute(models.Model):
|
|||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
ynu.values.add(yes, no, unknown)
|
||||
|
||||
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
||||
Attribute.objects.create(name='has fever?',
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=ynu
|
||||
)
|
||||
# = <Attribute: has fever? (Multiple Choice)>
|
||||
|
||||
.. warning:: Once an Attribute has been used by an entity, you can not
|
||||
change it's datatype.
|
||||
"""
|
||||
|
||||
objects = AttributeManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('Attribute')
|
||||
verbose_name_plural = _('Attributes')
|
||||
|
||||
TYPE_TEXT = 'text'
|
||||
TYPE_FLOAT = 'float'
|
||||
TYPE_INT = 'int'
|
||||
TYPE_DATE = 'date'
|
||||
TYPE_BOOLEAN = 'bool'
|
||||
TYPE_OBJECT = 'object'
|
||||
TYPE_ENUM = 'enum'
|
||||
TYPE_JSON = 'json'
|
||||
TYPE_CSV = 'csv'
|
||||
TYPE_TEXT = "text"
|
||||
TYPE_FLOAT = "float"
|
||||
TYPE_INT = "int"
|
||||
TYPE_DATE = "date"
|
||||
TYPE_BOOLEAN = "bool"
|
||||
TYPE_OBJECT = "object"
|
||||
TYPE_ENUM = "enum"
|
||||
TYPE_JSON = "json"
|
||||
TYPE_CSV = "csv"
|
||||
|
||||
DATATYPE_CHOICES = (
|
||||
(TYPE_TEXT, _('Text')),
|
||||
(TYPE_DATE, _('Date')),
|
||||
(TYPE_FLOAT, _('Float')),
|
||||
(TYPE_INT, _('Integer')),
|
||||
(TYPE_BOOLEAN, _('True / False')),
|
||||
(TYPE_OBJECT, _('Django Object')),
|
||||
(TYPE_ENUM, _('Multiple Choice')),
|
||||
(TYPE_JSON, _('JSON Object')),
|
||||
(TYPE_CSV, _('Comma-Separated-Value')),
|
||||
(TYPE_TEXT, _("Text")),
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_FLOAT, _("Float")),
|
||||
(TYPE_INT, _("Integer")),
|
||||
(TYPE_BOOLEAN, _("True / False")),
|
||||
(TYPE_OBJECT, _("Django Object")),
|
||||
(TYPE_ENUM, _("Multiple Choice")),
|
||||
(TYPE_JSON, _("JSON Object")),
|
||||
(TYPE_CSV, _("Comma-Separated-Value")),
|
||||
)
|
||||
|
||||
# Core attributes
|
||||
|
|
@ -121,13 +120,13 @@ class Attribute(models.Model):
|
|||
datatype = EavDatatypeField(
|
||||
choices=DATATYPE_CHOICES,
|
||||
max_length=6,
|
||||
verbose_name=_('Data Type'),
|
||||
verbose_name=_("Data Type"),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
help_text=_('User-friendly attribute name'),
|
||||
verbose_name=_('Name'),
|
||||
help_text=_("User-friendly attribute name"),
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
|
||||
"""
|
||||
|
|
@ -139,8 +138,8 @@ class Attribute(models.Model):
|
|||
max_length=SLUGFIELD_MAX_LENGTH,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
help_text=_('Short unique attribute label'),
|
||||
verbose_name=_('Slug'),
|
||||
help_text=_("Short unique attribute label"),
|
||||
verbose_name=_("Slug"),
|
||||
)
|
||||
|
||||
"""
|
||||
|
|
@ -151,13 +150,13 @@ class Attribute(models.Model):
|
|||
"""
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Required'),
|
||||
verbose_name=_("Required"),
|
||||
)
|
||||
|
||||
entity_ct = models.ManyToManyField(
|
||||
ContentType,
|
||||
blank=True,
|
||||
verbose_name=_('Entity content type'),
|
||||
verbose_name=_("Entity content type"),
|
||||
)
|
||||
"""
|
||||
This field allows you to specify a relationship with any number of content types.
|
||||
|
|
@ -166,49 +165,67 @@ class Attribute(models.Model):
|
|||
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
||||
"""
|
||||
|
||||
enum_group: "ForeignKey[Optional[EnumGroup]]" = ForeignKey(
|
||||
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
|
||||
"eav.EnumGroup",
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Choice Group'),
|
||||
verbose_name=_("Choice Group"),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=256,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Short description'),
|
||||
verbose_name=_('Description'),
|
||||
default="",
|
||||
help_text=_("Short description"),
|
||||
verbose_name=_("Description"),
|
||||
)
|
||||
|
||||
# Useful meta-information
|
||||
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Display order'),
|
||||
verbose_name=_("Display order"),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Modified'),
|
||||
verbose_name=_("Modified"),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
editable=False,
|
||||
verbose_name=_('Created'),
|
||||
verbose_name=_("Created"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.name} ({self.get_datatype_display()})'
|
||||
objects = AttributeManager()
|
||||
|
||||
def natural_key(self) -> Tuple[str, str]: # noqa: UP006
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
verbose_name = _("Attribute")
|
||||
verbose_name_plural = _("Attributes")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.get_datatype_display()})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Saves the Attribute and auto-generates a slug field
|
||||
if one wasn't provided.
|
||||
"""
|
||||
if not self.slug:
|
||||
self.slug = generate_slug(self.name)
|
||||
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def natural_key(self) -> tuple[str, str]:
|
||||
"""
|
||||
Retrieve the natural key for the Attribute instance.
|
||||
|
||||
The natural key for an Attribute is defined by its `name` and `slug`. This method
|
||||
returns a tuple containing these two attributes of the instance.
|
||||
The natural key for an Attribute is defined by its `name` and `slug`. This
|
||||
method returns a tuple containing these two attributes of the instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -233,19 +250,19 @@ class Attribute(models.Model):
|
|||
method to look elsewhere for additional attribute specific
|
||||
validators to return as well as the default, built-in one.
|
||||
"""
|
||||
DATATYPE_VALIDATORS = {
|
||||
'text': validate_text,
|
||||
'float': validate_float,
|
||||
'int': validate_int,
|
||||
'date': validate_date,
|
||||
'bool': validate_bool,
|
||||
'object': validate_object,
|
||||
'enum': validate_enum,
|
||||
'json': validate_json,
|
||||
'csv': validate_csv,
|
||||
datatype_validators = {
|
||||
"text": validate_text,
|
||||
"float": validate_float,
|
||||
"int": validate_int,
|
||||
"date": validate_date,
|
||||
"bool": validate_bool,
|
||||
"object": validate_object,
|
||||
"enum": validate_enum,
|
||||
"json": validate_json,
|
||||
"csv": validate_csv,
|
||||
}
|
||||
|
||||
return [DATATYPE_VALIDATORS[self.datatype]]
|
||||
return [datatype_validators[self.datatype]]
|
||||
|
||||
def validate_value(self, value):
|
||||
"""
|
||||
|
|
@ -260,21 +277,10 @@ class Attribute(models.Model):
|
|||
value = value.value
|
||||
if not self.enum_group.values.filter(value=value).exists():
|
||||
raise ValidationError(
|
||||
_('%(val)s is not a valid choice for %(attr)s')
|
||||
% {'val': value, 'attr': self},
|
||||
_("%(val)s is not a valid choice for %(attr)s")
|
||||
% {"val": value, "attr": self},
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Saves the Attribute and auto-generates a slug field
|
||||
if one wasn't provided.
|
||||
"""
|
||||
if not self.slug:
|
||||
self.slug = generate_slug(self.name)
|
||||
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validates the attribute. Will raise ``ValidationError`` if the
|
||||
|
|
@ -283,12 +289,33 @@ class Attribute(models.Model):
|
|||
"""
|
||||
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
||||
raise ValidationError(
|
||||
_('You must set the choice group for multiple choice attributes'),
|
||||
_("You must set the choice group for multiple choice attributes"),
|
||||
)
|
||||
|
||||
if self.datatype != self.TYPE_ENUM and self.enum_group:
|
||||
raise ValidationError(
|
||||
_('You can only assign a choice group to multiple choice attributes'),
|
||||
_("You can only assign a choice group to multiple choice attributes"),
|
||||
)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""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):
|
||||
|
|
@ -318,20 +345,20 @@ class Attribute(models.Model):
|
|||
ct = ContentType.objects.get_for_model(entity)
|
||||
|
||||
entity_filter = {
|
||||
'entity_ct': ct,
|
||||
'attribute': self,
|
||||
f'{get_entity_pk_type(entity)}': entity.pk,
|
||||
"entity_ct": ct,
|
||||
"attribute": self,
|
||||
f"{get_entity_pk_type(entity)}": entity.pk,
|
||||
}
|
||||
|
||||
try:
|
||||
value_obj = self.value_set.get(**entity_filter)
|
||||
except Value.DoesNotExist:
|
||||
if value is None or value == '':
|
||||
if value is None or value == "":
|
||||
return
|
||||
|
||||
value_obj = Value.objects.create(**entity_filter)
|
||||
|
||||
if value is None or value == '':
|
||||
if value is None or value == "":
|
||||
value_obj.delete()
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ class Entity:
|
|||
model instance we are attached to is saved. This allows us to call
|
||||
:meth:`validate_attributes` before the entity is saved.
|
||||
"""
|
||||
instance = kwargs['instance']
|
||||
entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr)
|
||||
instance = kwargs["instance"]
|
||||
entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||
entity.validate_attributes()
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -34,8 +34,8 @@ class Entity:
|
|||
Post save handler attached to self.instance. Calls :meth:`save` when
|
||||
the model instance we are attached to is saved.
|
||||
"""
|
||||
instance = kwargs['instance']
|
||||
entity = getattr(instance, instance._eav_config_cls.eav_attr)
|
||||
instance = kwargs["instance"]
|
||||
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||
entity.save()
|
||||
|
||||
def __init__(self, instance) -> None:
|
||||
|
|
@ -58,14 +58,14 @@ class Entity:
|
|||
class:`Value` object, otherwise it hasn't been set, so it returns
|
||||
None.
|
||||
"""
|
||||
if not name.startswith('_'):
|
||||
if not name.startswith("_"):
|
||||
try:
|
||||
attribute = self.get_attribute_by_slug(name)
|
||||
except Attribute.DoesNotExist:
|
||||
except Attribute.DoesNotExist as err:
|
||||
raise AttributeError(
|
||||
_('%(obj)s has no EAV attribute named %(attr)s')
|
||||
% {'obj': self.instance, 'attr': name},
|
||||
)
|
||||
_("%(obj)s has no EAV attribute named %(attr)s")
|
||||
% {"obj": self.instance, "attr": name},
|
||||
) from err
|
||||
|
||||
try:
|
||||
return self.get_value_by_attribute(attribute).value
|
||||
|
|
@ -79,9 +79,9 @@ class Entity:
|
|||
Return a query set of all :class:`Attribute` objects that can be set
|
||||
for this entity.
|
||||
"""
|
||||
return self.instance._eav_config_cls.get_attributes(
|
||||
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
|
||||
instance=self.instance,
|
||||
).order_by('display_order')
|
||||
).order_by("display_order")
|
||||
|
||||
def _hasattr(self, attribute_slug):
|
||||
"""
|
||||
|
|
@ -137,28 +137,29 @@ class Entity:
|
|||
if value is None:
|
||||
if attribute.required:
|
||||
raise ValidationError(
|
||||
_(f'{attribute.slug} EAV field cannot be blank'),
|
||||
_("%s EAV field cannot be blank") % attribute.slug,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
attribute.validate_value(value)
|
||||
except ValidationError as e:
|
||||
except ValidationError as err:
|
||||
raise ValidationError(
|
||||
_('%(attr)s EAV field %(err)s')
|
||||
% {'attr': attribute.slug, 'err': e},
|
||||
)
|
||||
_("%(attr)s EAV field %(err)s")
|
||||
% {"attr": attribute.slug, "err": err},
|
||||
) from err
|
||||
|
||||
illegal = values_dict or (
|
||||
self.get_object_attributes() - self.get_all_attribute_slugs()
|
||||
)
|
||||
|
||||
if illegal:
|
||||
raise IllegalAssignmentException(
|
||||
'Instance of the class {} cannot have values for attributes: {}.'.format(
|
||||
self.instance.__class__,
|
||||
', '.join(illegal),
|
||||
),
|
||||
message = (
|
||||
"Instance of the class {} cannot have values for attributes: {}."
|
||||
).format(
|
||||
self.instance.__class__,
|
||||
", ".join(illegal),
|
||||
)
|
||||
raise IllegalAssignmentException(message)
|
||||
|
||||
def get_values_dict(self):
|
||||
return {v.attribute.slug: v.value for v in self.get_values()}
|
||||
|
|
@ -166,15 +167,15 @@ class Entity:
|
|||
def get_values(self):
|
||||
"""Get all set :class:`Value` objects for self.instance."""
|
||||
entity_filter = {
|
||||
'entity_ct': self.ct,
|
||||
f'{get_entity_pk_type(self.instance)}': self.instance.pk,
|
||||
"entity_ct": self.ct,
|
||||
f"{get_entity_pk_type(self.instance)}": self.instance.pk,
|
||||
}
|
||||
|
||||
return Value.objects.filter(**entity_filter).select_related()
|
||||
|
||||
def get_all_attribute_slugs(self):
|
||||
"""Returns a list of slugs for all attributes available to this entity."""
|
||||
return set(self.get_all_attributes().values_list('slug', flat=True))
|
||||
return set(self.get_all_attributes().values_list("slug", flat=True))
|
||||
|
||||
def get_attribute_by_slug(self, slug):
|
||||
"""Returns a single :class:`Attribute` with *slug*."""
|
||||
|
|
@ -189,7 +190,7 @@ class Entity:
|
|||
Returns entity instance attributes, except for
|
||||
``instance`` and ``ct`` which are used internally.
|
||||
"""
|
||||
return set(copy(self.__dict__).keys()) - {'instance', 'ct'}
|
||||
return set(copy(self.__dict__).keys()) - {"instance", "ct"}
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from typing import TYPE_CHECKING, Any, Tuple
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import ManyToManyField
|
||||
|
|
@ -21,33 +23,33 @@ class EnumGroup(models.Model):
|
|||
See :class:`EnumValue` for an example.
|
||||
"""
|
||||
|
||||
objects = EnumGroupManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('EnumGroup')
|
||||
verbose_name_plural = _('EnumGroups')
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
verbose_name=_('Name'),
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
values: "ManyToManyField[EnumValue, Any]" = ManyToManyField(
|
||||
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
|
||||
"eav.EnumValue",
|
||||
verbose_name=_('Enum group'),
|
||||
verbose_name=_("Enum group"),
|
||||
)
|
||||
|
||||
objects = EnumGroupManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("EnumGroup")
|
||||
verbose_name_plural = _("EnumGroups")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of `EnumGroup` instance."""
|
||||
return str(self.name)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of `EnumGroup` object."""
|
||||
return f'<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Tuple
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
|
@ -35,21 +35,21 @@ class EnumValue(models.Model):
|
|||
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
|
||||
"""
|
||||
|
||||
objects = EnumValueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('EnumValue')
|
||||
verbose_name_plural = _('EnumValues')
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
value = models.CharField(
|
||||
_('Value'),
|
||||
_("Value"),
|
||||
db_index=True,
|
||||
unique=True,
|
||||
max_length=SLUGFIELD_MAX_LENGTH,
|
||||
)
|
||||
|
||||
objects = EnumValueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("EnumValue")
|
||||
verbose_name_plural = _("EnumValues")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of `EnumValue` instance."""
|
||||
return str(
|
||||
|
|
@ -58,9 +58,9 @@ class EnumValue(models.Model):
|
|||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of `EnumValue` object."""
|
||||
return f'<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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# ruff: noqa: UP007
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||
|
||||
from django.contrib.contenttypes import fields as generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
|
@ -43,20 +44,14 @@ class Value(models.Model):
|
|||
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
||||
"""
|
||||
|
||||
objects = ValueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Value')
|
||||
verbose_name_plural = _('Values')
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
# Direct foreign keys
|
||||
attribute: "ForeignKey[Attribute]" = ForeignKey(
|
||||
attribute: ForeignKey[Attribute] = ForeignKey(
|
||||
"eav.Attribute",
|
||||
db_index=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_('Attribute'),
|
||||
verbose_name=_("Attribute"),
|
||||
)
|
||||
|
||||
# Entity generic relationships. Rather than rely on database casting,
|
||||
|
|
@ -65,73 +60,73 @@ class Value(models.Model):
|
|||
entity_id = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Entity id'),
|
||||
verbose_name=_("Entity id"),
|
||||
)
|
||||
|
||||
entity_uuid = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Entity uuid'),
|
||||
verbose_name=_("Entity uuid"),
|
||||
)
|
||||
|
||||
entity_ct = ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='value_entities',
|
||||
verbose_name=_('Entity ct'),
|
||||
related_name="value_entities",
|
||||
verbose_name=_("Entity ct"),
|
||||
)
|
||||
|
||||
entity_pk_int = generic.GenericForeignKey(
|
||||
ct_field='entity_ct',
|
||||
fk_field='entity_id',
|
||||
ct_field="entity_ct",
|
||||
fk_field="entity_id",
|
||||
)
|
||||
|
||||
entity_pk_uuid = generic.GenericForeignKey(
|
||||
ct_field='entity_ct',
|
||||
fk_field='entity_uuid',
|
||||
ct_field="entity_ct",
|
||||
fk_field="entity_uuid",
|
||||
)
|
||||
|
||||
# Model attributes
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('Created'),
|
||||
verbose_name=_("Created"),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Modified'),
|
||||
verbose_name=_("Modified"),
|
||||
)
|
||||
|
||||
# Value attributes
|
||||
value_bool = models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value bool'),
|
||||
verbose_name=_("Value bool"),
|
||||
)
|
||||
value_csv = CSVField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value CSV'),
|
||||
verbose_name=_("Value CSV"),
|
||||
)
|
||||
value_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value date'),
|
||||
verbose_name=_("Value date"),
|
||||
)
|
||||
value_float = models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value float'),
|
||||
verbose_name=_("Value float"),
|
||||
)
|
||||
value_int = models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value int'),
|
||||
verbose_name=_("Value int"),
|
||||
)
|
||||
value_text = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value text'),
|
||||
default="",
|
||||
verbose_name=_("Value text"),
|
||||
)
|
||||
|
||||
value_json = models.JSONField(
|
||||
|
|
@ -139,23 +134,23 @@ class Value(models.Model):
|
|||
encoder=DjangoJSONEncoder,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value JSON'),
|
||||
verbose_name=_("Value JSON"),
|
||||
)
|
||||
|
||||
value_enum: "ForeignKey[Optional[EnumValue]]" = ForeignKey(
|
||||
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
|
||||
"eav.EnumValue",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='eav_values',
|
||||
verbose_name=_('Value enum'),
|
||||
related_name="eav_values",
|
||||
verbose_name=_("Value enum"),
|
||||
)
|
||||
|
||||
# Value object relationship
|
||||
generic_value_id = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Generic value id'),
|
||||
verbose_name=_("Generic value id"),
|
||||
)
|
||||
|
||||
generic_value_ct = ForeignKey(
|
||||
|
|
@ -163,29 +158,38 @@ class Value(models.Model):
|
|||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='value_values',
|
||||
verbose_name=_('Generic value content type'),
|
||||
related_name="value_values",
|
||||
verbose_name=_("Generic value content type"),
|
||||
)
|
||||
|
||||
value_object = generic.GenericForeignKey(
|
||||
ct_field='generic_value_ct',
|
||||
fk_field='generic_value_id',
|
||||
ct_field="generic_value_ct",
|
||||
fk_field="generic_value_id",
|
||||
)
|
||||
|
||||
def natural_key(self) -> Tuple[Tuple[str, str], int, str]:
|
||||
"""
|
||||
Retrieve the natural key for the Value instance.
|
||||
objects = ValueManager()
|
||||
|
||||
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.
|
||||
class Meta:
|
||||
verbose_name = _("Value")
|
||||
verbose_name_plural = _("Values")
|
||||
|
||||
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)
|
||||
constraints: ClassVar[list[models.Constraint]] = [
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_ct", "attribute", "entity_uuid"],
|
||||
name="unique_entity_uuid_per_attribute",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
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:
|
||||
"""String representation of a Value."""
|
||||
|
|
@ -202,12 +206,27 @@ class Value(models.Model):
|
|||
self.full_clean()
|
||||
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):
|
||||
"""Return the python object this value is holding."""
|
||||
return getattr(self, f'value_{self.attribute.datatype}')
|
||||
return getattr(self, f"value_{self.attribute.datatype}")
|
||||
|
||||
def _set_value(self, new_value):
|
||||
"""Set the object this value is holding."""
|
||||
setattr(self, f'value_{self.attribute.datatype}', new_value)
|
||||
setattr(self, f"value_{self.attribute.datatype}", new_value)
|
||||
|
||||
value = property(_get_value, _set_value)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
This module contains custom :class:`EavQuerySet` class used for overriding
|
||||
relational operators and pure functions for rewriting Q-expressions.
|
||||
|
|
@ -19,14 +18,14 @@ Q-expressions need to be rewritten for two reasons:
|
|||
2. To ensure that Q-expression tree is compiled to valid SQL.
|
||||
For details see: :func:`rewrite_q_expr`.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from itertools import count
|
||||
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Case, IntegerField, Q, When
|
||||
from django.db.models.query import QuerySet
|
||||
from django.db.utils import NotSupportedError
|
||||
from django.db.models import Subquery
|
||||
|
||||
from eav.models import Attribute, EnumValue, Value
|
||||
|
||||
|
|
@ -43,9 +42,9 @@ def is_eav_and_leaf(expr, gr_name):
|
|||
bool
|
||||
"""
|
||||
return (
|
||||
getattr(expr, 'connector', None) == 'AND'
|
||||
getattr(expr, "connector", None) == "AND"
|
||||
and len(expr.children) == 1
|
||||
and expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)]
|
||||
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -98,7 +97,7 @@ def rewrite_q_expr(model_cls, expr):
|
|||
# We are only interested in Qs.
|
||||
|
||||
if isinstance(expr, Q):
|
||||
config_cls = getattr(model_cls, '_eav_config_cls', None)
|
||||
config_cls = getattr(model_cls, "_eav_config_cls", None)
|
||||
gr_name = config_cls.generic_relation_attr
|
||||
|
||||
# Recursively check child nodes.
|
||||
|
|
@ -112,18 +111,18 @@ def rewrite_q_expr(model_cls, expr):
|
|||
if len(rewritable) > 1:
|
||||
q = None
|
||||
# Save nodes which shouldn't be merged (non-EAV).
|
||||
other = [c for c in expr.children if not c in rewritable]
|
||||
other = [c for c in expr.children if c not in rewritable]
|
||||
|
||||
for child in rewritable:
|
||||
if not (child.children and len(child.children) == 1):
|
||||
raise AssertionError('Child must have exactly one descendant')
|
||||
raise AssertionError("Child must have exactly one descendant")
|
||||
# Child to be merged is always a terminal Q node,
|
||||
# i.e. it's an AND expression with attribute-value tuple child.
|
||||
attrval = child.children[0]
|
||||
if not isinstance(attrval, tuple):
|
||||
raise AssertionError('Attribute-value must be a tuple')
|
||||
raise TypeError("Attribute-value must be a tuple")
|
||||
|
||||
fname = '{}__in'.format(gr_name)
|
||||
fname = f"{gr_name}__in"
|
||||
|
||||
# Child can be either a 'eav_values__in' or 'pk__in' query.
|
||||
# If it's the former then transform it into the latter.
|
||||
|
|
@ -131,7 +130,7 @@ def rewrite_q_expr(model_cls, expr):
|
|||
# If so, reverse it back to QuerySet so that set operators
|
||||
# can be applied.
|
||||
|
||||
if attrval[0] == fname or hasattr(attrval[1], '__contains__'):
|
||||
if attrval[0] == fname or hasattr(attrval[1], "__contains__"):
|
||||
# Create model queryset.
|
||||
_q = model_cls.objects.filter(**{fname: attrval[1]})
|
||||
else:
|
||||
|
|
@ -140,17 +139,17 @@ def rewrite_q_expr(model_cls, expr):
|
|||
|
||||
# Explicitly check for None. 'or' doesn't work here
|
||||
# as empty QuerySet, which is valid, is falsy.
|
||||
q = q if q != None else _q
|
||||
q = q if q is not None else _q
|
||||
|
||||
if expr.connector == 'AND':
|
||||
if expr.connector == "AND":
|
||||
q &= _q
|
||||
else:
|
||||
q |= _q
|
||||
|
||||
# If any two children were merged,
|
||||
# update parent expression.
|
||||
if q != None:
|
||||
expr.children = other + [('pk__in', q)]
|
||||
if q is not None:
|
||||
expr.children = [*other, ("pk__in", q)]
|
||||
|
||||
return expr
|
||||
|
||||
|
|
@ -170,9 +169,9 @@ def eav_filter(func):
|
|||
for arg in args:
|
||||
if isinstance(arg, Q):
|
||||
# Modify Q objects (warning: recursion ahead).
|
||||
arg = expand_q_filters(arg, self.model)
|
||||
arg = expand_q_filters(arg, self.model) # noqa: PLW2901
|
||||
# Rewrite Q-expression to safeform.
|
||||
arg = rewrite_q_expr(self.model, arg)
|
||||
arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901
|
||||
nargs.append(arg)
|
||||
|
||||
for key, value in kwargs.items():
|
||||
|
|
@ -180,9 +179,10 @@ def eav_filter(func):
|
|||
nkey, nval = expand_eav_filter(self.model, key, value)
|
||||
|
||||
if nkey in nkwargs:
|
||||
# Add filter to check if matching entity_id is in the previous queryset with same nkey
|
||||
# Add filter to check if matching entity_id is
|
||||
# in the previous queryset with same nkey
|
||||
nkwargs[nkey] = nval.filter(
|
||||
entity_id__in=nkwargs[nkey].values_list('entity_id', flat=True)
|
||||
entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True),
|
||||
).distinct()
|
||||
else:
|
||||
nkwargs.update({nkey: nval})
|
||||
|
|
@ -229,27 +229,27 @@ def expand_eav_filter(model_cls, key, value):
|
|||
key = 'eav_values__in'
|
||||
value = Values.objects.filter(value_int=5, attribute__slug='height')
|
||||
"""
|
||||
fields = key.split('__')
|
||||
config_cls = getattr(model_cls, '_eav_config_cls', None)
|
||||
fields = key.split("__")
|
||||
config_cls = getattr(model_cls, "_eav_config_cls", None)
|
||||
|
||||
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
|
||||
slug = fields[1]
|
||||
gr_name = config_cls.generic_relation_attr
|
||||
datatype = Attribute.objects.get(slug=slug).datatype
|
||||
|
||||
value_key = ''
|
||||
value_key = ""
|
||||
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
|
||||
lookup = '__value__{}'.format(fields[2]) if len(fields) > 2 else '__value'
|
||||
value_key = 'value_{}{}'.format(datatype, lookup)
|
||||
lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004
|
||||
value_key = f"value_{datatype}{lookup}"
|
||||
elif datatype == Attribute.TYPE_OBJECT:
|
||||
value_key = 'generic_value_id'
|
||||
value_key = "generic_value_id"
|
||||
else:
|
||||
lookup = '__{}'.format(fields[2]) if len(fields) > 2 else ''
|
||||
value_key = 'value_{}{}'.format(datatype, lookup)
|
||||
kwargs = {value_key: value, 'attribute__slug': slug}
|
||||
lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004
|
||||
value_key = f"value_{datatype}{lookup}"
|
||||
kwargs = {value_key: value, "attribute__slug": slug}
|
||||
value = Value.objects.filter(**kwargs)
|
||||
|
||||
return '%s__in' % gr_name, value
|
||||
return f"{gr_name}__in", value
|
||||
|
||||
# Not an eav field, so keep as is
|
||||
return key, value
|
||||
|
|
@ -266,7 +266,7 @@ class EavQuerySet(QuerySet):
|
|||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||
the ``Manager`` filter method.
|
||||
"""
|
||||
return super(EavQuerySet, self).filter(*args, **kwargs)
|
||||
return super().filter(*args, **kwargs)
|
||||
|
||||
@eav_filter
|
||||
def exclude(self, *args, **kwargs):
|
||||
|
|
@ -274,7 +274,7 @@ class EavQuerySet(QuerySet):
|
|||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||
the ``Manager`` exclude method.
|
||||
"""
|
||||
return super(EavQuerySet, self).exclude(*args, **kwargs)
|
||||
return super().exclude(*args, **kwargs)
|
||||
|
||||
@eav_filter
|
||||
def get(self, *args, **kwargs):
|
||||
|
|
@ -282,7 +282,7 @@ class EavQuerySet(QuerySet):
|
|||
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||
the ``Manager`` get method.
|
||||
"""
|
||||
return super(EavQuerySet, self).get(*args, **kwargs)
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def order_by(self, *fields):
|
||||
# Django only allows to order querysets by direct fields and
|
||||
|
|
@ -292,20 +292,20 @@ class EavQuerySet(QuerySet):
|
|||
# This will be slow, of course.
|
||||
order_clauses = []
|
||||
query_clause = self
|
||||
config_cls = self.model._eav_config_cls
|
||||
config_cls = self.model._eav_config_cls # noqa: SLF001
|
||||
|
||||
for term in [t.split('__') for t in fields]:
|
||||
for term in [t.split("__") for t in fields]:
|
||||
# Continue only for EAV attributes.
|
||||
if len(term) == 2 and term[0] == config_cls.eav_attr:
|
||||
if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
||||
# Retrieve Attribute over which the ordering is performed.
|
||||
try:
|
||||
attr = Attribute.objects.get(slug=term[1])
|
||||
except ObjectDoesNotExist:
|
||||
except ObjectDoesNotExist as err:
|
||||
raise ObjectDoesNotExist(
|
||||
'Cannot find EAV attribute "{}"'.format(term[1])
|
||||
)
|
||||
f'Cannot find EAV attribute "{term[1]}"',
|
||||
) from err
|
||||
|
||||
field_name = 'value_%s' % attr.datatype
|
||||
field_name = f"value_{attr.datatype}"
|
||||
|
||||
pks_values = (
|
||||
Value.objects.filter(
|
||||
|
|
@ -318,12 +318,12 @@ class EavQuerySet(QuerySet):
|
|||
.order_by(
|
||||
# Order values by their value-field of
|
||||
# appropriate attribute data-type.
|
||||
field_name
|
||||
field_name,
|
||||
)
|
||||
.values_list(
|
||||
# Retrieve only primary-keys of the entities
|
||||
# in the current queryset.
|
||||
'entity_id',
|
||||
"entity_id",
|
||||
field_name,
|
||||
)
|
||||
)
|
||||
|
|
@ -352,16 +352,16 @@ class EavQuerySet(QuerySet):
|
|||
|
||||
order_clause = Case(*when_clauses, output_field=IntegerField())
|
||||
|
||||
clause_name = '__'.join(term)
|
||||
clause_name = "__".join(term)
|
||||
# Use when-clause to construct
|
||||
# custom order-by clause.
|
||||
query_clause = query_clause.annotate(**{clause_name: order_clause})
|
||||
|
||||
order_clauses.append(clause_name)
|
||||
|
||||
elif len(term) >= 2 and term[0] == config_cls.eav_attr:
|
||||
elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
||||
raise NotSupportedError(
|
||||
'EAV does not support ordering through ' 'foreign-key chains'
|
||||
"EAV does not support ordering through foreign-key chains",
|
||||
)
|
||||
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
from django.contrib.contenttypes import fields as generic
|
||||
from django.db.models.signals import post_init, post_save, pre_save
|
||||
|
||||
from eav.logic.entity_pk import get_entity_pk_type
|
||||
from eav.managers import EntityManager
|
||||
from eav.models import Attribute, Entity, Value
|
||||
|
||||
from eav.logic.entity_pk import get_entity_pk_type
|
||||
|
||||
|
||||
class EavConfig(object):
|
||||
class EavConfig:
|
||||
"""
|
||||
The default ``EavConfig`` class used if it is not overridden on registration.
|
||||
This is where all the default eav attribute names are defined.
|
||||
|
|
@ -29,10 +28,10 @@ class EavConfig(object):
|
|||
if not overridden, it is not possible to query Values by Entities.
|
||||
"""
|
||||
|
||||
manager_attr = 'objects'
|
||||
manager_attr = "objects"
|
||||
manager_only = False
|
||||
eav_attr = 'eav'
|
||||
generic_relation_attr = 'eav_values'
|
||||
eav_attr = "eav"
|
||||
generic_relation_attr = "eav_values"
|
||||
generic_relation_related_name = None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -44,7 +43,7 @@ class EavConfig(object):
|
|||
return Attribute.objects.all()
|
||||
|
||||
|
||||
class Registry(object):
|
||||
class Registry:
|
||||
"""
|
||||
Handles registration through the
|
||||
:meth:`register` and :meth:`unregister` methods.
|
||||
|
|
@ -59,14 +58,14 @@ class Registry(object):
|
|||
.. note::
|
||||
Multiple registrations for the same entity are harmlessly ignored.
|
||||
"""
|
||||
if hasattr(model_cls, '_eav_config_cls'):
|
||||
if hasattr(model_cls, "_eav_config_cls"):
|
||||
return
|
||||
|
||||
if config_cls is EavConfig or config_cls is None:
|
||||
config_cls = type("%sConfig" % model_cls.__name__, (EavConfig,), {})
|
||||
config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {})
|
||||
|
||||
# set _eav_config_cls on the model so we can access it there
|
||||
setattr(model_cls, '_eav_config_cls', config_cls)
|
||||
model_cls._eav_config_cls = config_cls
|
||||
|
||||
reg = Registry(model_cls)
|
||||
reg._register_self()
|
||||
|
|
@ -79,19 +78,19 @@ class Registry(object):
|
|||
.. note::
|
||||
Unregistering a class not already registered is harmlessly ignored.
|
||||
"""
|
||||
if not getattr(model_cls, '_eav_config_cls', None):
|
||||
if not getattr(model_cls, "_eav_config_cls", None):
|
||||
return
|
||||
reg = Registry(model_cls)
|
||||
reg._unregister_self()
|
||||
|
||||
delattr(model_cls, '_eav_config_cls')
|
||||
delattr(model_cls, "_eav_config_cls")
|
||||
|
||||
@staticmethod
|
||||
def attach_eav_attr(sender, *args, **kwargs):
|
||||
"""
|
||||
Attach EAV Entity toolkit to an instance after init.
|
||||
"""
|
||||
instance = kwargs['instance']
|
||||
instance = kwargs["instance"]
|
||||
config_cls = instance.__class__._eav_config_cls
|
||||
setattr(instance, config_cls.eav_attr, Entity(instance))
|
||||
|
||||
|
|
@ -102,25 +101,41 @@ class Registry(object):
|
|||
self.model_cls = model_cls
|
||||
self.config_cls = model_cls._eav_config_cls
|
||||
|
||||
def _attach_manager(self):
|
||||
def _attach_manager(self) -> None:
|
||||
"""
|
||||
Attach the manager to *manager_attr* specified in *config_cls*
|
||||
Attach the EntityManager to the model class.
|
||||
|
||||
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.
|
||||
"""
|
||||
# Save the old manager if the attribute name conflicts with the new one.
|
||||
if hasattr(self.model_cls, self.config_cls.manager_attr):
|
||||
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
|
||||
manager_attr = self.config_cls.manager_attr
|
||||
model_meta = self.model_cls._meta
|
||||
current_manager = getattr(self.model_cls, manager_attr, None)
|
||||
|
||||
# For some models, `local_managers` may be empty, eg.
|
||||
# django.contrib.auth.models.User and AbstractUser
|
||||
if mgr in self.model_cls._meta.local_managers:
|
||||
self.config_cls.old_mgr = mgr
|
||||
self.model_cls._meta.local_managers.remove(mgr)
|
||||
if isinstance(current_manager, EntityManager):
|
||||
# EntityManager is already attached, no need to proceed
|
||||
return
|
||||
|
||||
self.model_cls._meta._expire_cache()
|
||||
# Create a new EntityManager
|
||||
new_manager = EntityManager()
|
||||
|
||||
# Attach the new manager to the model.
|
||||
mgr = EntityManager()
|
||||
mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr)
|
||||
# Save and remove the old manager if it exists
|
||||
if current_manager and current_manager in model_meta.local_managers:
|
||||
self.config_cls.old_mgr = current_manager
|
||||
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):
|
||||
"""
|
||||
|
|
@ -131,9 +146,10 @@ class Registry(object):
|
|||
self.model_cls._meta._expire_cache()
|
||||
delattr(self.model_cls, self.config_cls.manager_attr)
|
||||
|
||||
if hasattr(self.config_cls, 'old_mgr'):
|
||||
if hasattr(self.config_cls, "old_mgr"):
|
||||
self.config_cls.old_mgr.contribute_to_class(
|
||||
self.model_cls, self.config_cls.manager_attr
|
||||
self.model_cls,
|
||||
self.config_cls.manager_attr,
|
||||
)
|
||||
|
||||
def _attach_signals(self):
|
||||
|
|
@ -165,7 +181,7 @@ class Registry(object):
|
|||
generic_relation = generic.GenericRelation(
|
||||
Value,
|
||||
object_id_field=get_entity_pk_type(self.model_cls),
|
||||
content_type_field='entity_ct',
|
||||
content_type_field="entity_ct",
|
||||
related_query_name=rel_name,
|
||||
)
|
||||
generic_relation.contribute_to_class(self.model_cls, gr_name)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def validate_text(value):
|
|||
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError(_(u"Must be str or unicode"))
|
||||
raise ValidationError(_("Must be str or unicode"))
|
||||
|
||||
|
||||
def validate_float(value):
|
||||
|
|
@ -32,8 +32,8 @@ def validate_float(value):
|
|||
"""
|
||||
try:
|
||||
float(value)
|
||||
except ValueError:
|
||||
raise ValidationError(_(u"Must be a float"))
|
||||
except ValueError as err:
|
||||
raise ValidationError(_("Must be a float")) from err
|
||||
|
||||
|
||||
def validate_int(value):
|
||||
|
|
@ -42,8 +42,8 @@ def validate_int(value):
|
|||
"""
|
||||
try:
|
||||
int(value)
|
||||
except ValueError:
|
||||
raise ValidationError(_(u"Must be an integer"))
|
||||
except ValueError as err:
|
||||
raise ValidationError(_("Must be an integer")) from err
|
||||
|
||||
|
||||
def validate_date(value):
|
||||
|
|
@ -52,9 +52,10 @@ def validate_date(value):
|
|||
or ``date``
|
||||
"""
|
||||
if not isinstance(value, datetime.datetime) and not isinstance(
|
||||
value, datetime.date
|
||||
value,
|
||||
datetime.date,
|
||||
):
|
||||
raise ValidationError(_(u"Must be a date or datetime"))
|
||||
raise ValidationError(_("Must be a date or datetime"))
|
||||
|
||||
|
||||
def validate_bool(value):
|
||||
|
|
@ -62,7 +63,7 @@ def validate_bool(value):
|
|||
Raises ``ValidationError`` unless *value* type is ``bool``
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValidationError(_(u"Must be a boolean"))
|
||||
raise ValidationError(_("Must be a boolean"))
|
||||
|
||||
|
||||
def validate_object(value):
|
||||
|
|
@ -71,10 +72,10 @@ def validate_object(value):
|
|||
django model instance.
|
||||
"""
|
||||
if not isinstance(value, models.Model):
|
||||
raise ValidationError(_(u"Must be a django model object instance"))
|
||||
raise ValidationError(_("Must be a django model object instance"))
|
||||
|
||||
if not value.pk:
|
||||
raise ValidationError(_(u"Model has not been saved yet"))
|
||||
raise ValidationError(_("Model has not been saved yet"))
|
||||
|
||||
|
||||
def validate_enum(value):
|
||||
|
|
@ -85,7 +86,7 @@ def validate_enum(value):
|
|||
from eav.models import EnumValue
|
||||
|
||||
if isinstance(value, EnumValue) and not value.pk:
|
||||
raise ValidationError(_(u"EnumValue has not been saved yet"))
|
||||
raise ValidationError(_("EnumValue has not been saved yet"))
|
||||
|
||||
|
||||
def validate_json(value):
|
||||
|
|
@ -96,9 +97,9 @@ def validate_json(value):
|
|||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(_(u"Must be a JSON Serializable object"))
|
||||
except ValueError:
|
||||
raise ValidationError(_(u"Must be a JSON Serializable object"))
|
||||
raise ValidationError(_("Must be a JSON Serializable object"))
|
||||
except ValueError as err:
|
||||
raise ValidationError(_("Must be a JSON Serializable object")) from err
|
||||
|
||||
|
||||
def validate_csv(value):
|
||||
|
|
@ -108,4 +109,4 @@ def validate_csv(value):
|
|||
if isinstance(value, str):
|
||||
value = value.split(";")
|
||||
if not isinstance(value, list):
|
||||
raise ValidationError(_(u"Must be Comma-Separated-Value."))
|
||||
raise ValidationError(_("Must be Comma-Separated-Value."))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.core import validators
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.forms.widgets import Textarea
|
||||
|
||||
EMPTY_VALUES = validators.EMPTY_VALUES + ('[]',)
|
||||
EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]")
|
||||
|
||||
|
||||
class CSVWidget(Textarea):
|
||||
|
|
@ -12,11 +12,11 @@ class CSVWidget(Textarea):
|
|||
"""Prepare value before effectively render widget"""
|
||||
if value in EMPTY_VALUES:
|
||||
return ""
|
||||
elif isinstance(value, str):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, list):
|
||||
if isinstance(value, list):
|
||||
return ";".join(value)
|
||||
raise ValidationError('Invalid format.')
|
||||
raise ValidationError("Invalid format.")
|
||||
|
||||
def render(self, name, value, **kwargs):
|
||||
value = self.prep_value(value)
|
||||
|
|
@ -31,11 +31,9 @@ class CSVWidget(Textarea):
|
|||
key, we need to loop through each field checking if the eav attribute
|
||||
exists with the given 'name'.
|
||||
"""
|
||||
widget_value = None
|
||||
for data_value in data:
|
||||
try:
|
||||
widget_value = getattr(data.get(data_value), name)
|
||||
except AttributeError:
|
||||
pass # noqa: WPS420
|
||||
for data_value in data.values():
|
||||
widget_value = getattr(data_value, name, None)
|
||||
if widget_value is not None:
|
||||
return widget_value
|
||||
|
||||
return widget_value
|
||||
return None
|
||||
|
|
|
|||
14
manage.py
14
manage.py
|
|
@ -13,19 +13,19 @@ def main() -> None:
|
|||
2. Warns if Django is not installed
|
||||
3. Executes any given command
|
||||
"""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
|
||||
|
||||
try:
|
||||
from django.core import management # noqa: WPS433
|
||||
except ImportError:
|
||||
from django.core import management
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
+ 'available on your PYTHONPATH environment variable? Did you '
|
||||
+ 'forget to activate a virtual environment?',
|
||||
)
|
||||
+ "available on your PYTHONPATH environment variable? Did you "
|
||||
+ "forget to activate a virtual environment?",
|
||||
) from err
|
||||
|
||||
management.execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
2989
poetry.lock
generated
2989
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,12 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
requires = ["poetry-core>=1.9"]
|
||||
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]
|
||||
name = "django-eav2"
|
||||
description = "Entity-Attribute-Value storage for Django"
|
||||
version = "1.6.0"
|
||||
version = "1.8.1"
|
||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||
packages = [
|
||||
{ include = "eav" }
|
||||
|
|
@ -47,17 +37,17 @@ classifiers = [
|
|||
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Database",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
]
|
||||
|
||||
[tool.semantic_release]
|
||||
|
|
@ -70,22 +60,17 @@ upload_to_release = false
|
|||
build_command = "pip install poetry && poetry build"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
django = ">=3.2,<5.1"
|
||||
pyyaml = { version = "^6.0.1", python = "^3.12" }
|
||||
python = "^3.9"
|
||||
django = ">=4.2,<5.3"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
mypy = "^1.6"
|
||||
|
||||
wemake-python-styleguide = "^0.17"
|
||||
flake8-pytest-style = "^1.7"
|
||||
nitpick = ">=0.34,<0.36"
|
||||
black = ">=22.12,<25.0"
|
||||
ruff = ">=0.6.3,<0.13.0"
|
||||
|
||||
safety = ">=2.3,<4.0"
|
||||
|
||||
pytest = ">=7.4.3,<9.0.0"
|
||||
pytest-cov = "^4.1"
|
||||
pytest-cov = ">=4.1,<7.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-django = "^4.5.2"
|
||||
hypothesis = "^6.87.1"
|
||||
|
|
@ -97,7 +82,53 @@ optional = true
|
|||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = ">=5.0,<8.0"
|
||||
sphinx-rtd-theme = ">=1.3,<3.0"
|
||||
sphinx-rtd-theme = ">=1.3,<4.0"
|
||||
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||
m2r2 = "^0.3"
|
||||
tomlkit = ">=0.11,<0.13"
|
||||
tomlkit = ">=0.13.0,<0.14"
|
||||
|
||||
|
||||
[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,63 +3,6 @@
|
|||
# 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]
|
||||
# Django options:
|
||||
# https://pytest-django.readthedocs.io/en/latest/
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class TestAppConfig(AppConfig):
|
||||
name = 'test_project'
|
||||
name = "test_project"
|
||||
|
|
|
|||
|
|
@ -14,136 +14,136 @@ class Migration(migrations.Migration):
|
|||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExampleMetaclassModel',
|
||||
name="ExampleMetaclassModel",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
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={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExampleModel',
|
||||
name="ExampleModel",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
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={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RegisterTestModel',
|
||||
name="RegisterTestModel",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
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={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Patient',
|
||||
name="Patient",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
('email', models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
|
||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
("email", models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
|
||||
(
|
||||
'example',
|
||||
"example",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.PROTECT,
|
||||
to='test_project.examplemodel',
|
||||
to="test_project.examplemodel",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='M2MModel',
|
||||
name="M2MModel",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
('models', models.ManyToManyField(to='test_project.ExampleModel')),
|
||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
("models", models.ManyToManyField(to="test_project.ExampleModel")),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Encounter',
|
||||
name="Encounter",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
('num', models.PositiveSmallIntegerField()),
|
||||
("num", models.PositiveSmallIntegerField()),
|
||||
(
|
||||
'patient',
|
||||
"patient",
|
||||
models.ForeignKey(
|
||||
on_delete=models.deletion.PROTECT,
|
||||
to='test_project.patient',
|
||||
to="test_project.patient",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Doctor',
|
||||
name="Doctor",
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
|
|
@ -151,10 +151,10 @@ class Migration(migrations.Migration):
|
|||
serialize=False,
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import sys
|
||||
import uuid
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Final, final
|
||||
else:
|
||||
from typing_extensions import Final, final
|
||||
from typing import Final, final
|
||||
|
||||
from django.db import models
|
||||
|
||||
from eav.decorators import register_eav
|
||||
from eav.managers import EntityManager
|
||||
from eav.models import EAVModelMeta
|
||||
|
||||
#: Constants
|
||||
|
|
@ -18,13 +14,55 @@ MAX_CHARFIELD_LEN: Final = 254
|
|||
class TestBase(models.Model):
|
||||
"""Base class for test models."""
|
||||
|
||||
class Meta(object):
|
||||
class Meta:
|
||||
"""Define common options."""
|
||||
|
||||
app_label = 'test_project'
|
||||
app_label = "test_project"
|
||||
abstract = True
|
||||
|
||||
|
||||
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
|
||||
@register_eav()
|
||||
class Doctor(TestBase):
|
||||
|
|
@ -33,13 +71,19 @@ class Doctor(TestBase):
|
|||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||
|
||||
objects = DoctorManager()
|
||||
substrings = DoctorSubstringManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@final
|
||||
class Patient(TestBase):
|
||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||
email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True)
|
||||
example = models.ForeignKey(
|
||||
'ExampleModel',
|
||||
"ExampleModel",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
|
|
@ -57,7 +101,7 @@ class Encounter(TestBase):
|
|||
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
||||
|
||||
def __str__(self):
|
||||
return '%s: encounter num %d' % (self.patient, self.num)
|
||||
return f"{self.patient}: encounter num {self.num}"
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
|
@ -68,7 +112,7 @@ class Encounter(TestBase):
|
|||
class ExampleModel(TestBase):
|
||||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||
|
||||
def __unicode__(self):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
|
|
@ -78,7 +122,7 @@ class M2MModel(TestBase):
|
|||
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||
models = models.ManyToManyField(ExampleModel)
|
||||
|
||||
def __unicode__(self):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
|
|
@ -9,51 +10,51 @@ BASE_DIR = Path(__file__).parent.parent
|
|||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'secret!' # noqa: S105
|
||||
SECRET_KEY = "secret!" # noqa: S105
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS: List[str] = []
|
||||
ALLOWED_HOSTS: list[str] = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Test Project:
|
||||
'test_project.apps.TestAppConfig',
|
||||
"test_project.apps.TestAppConfig",
|
||||
# Our app:
|
||||
'eav',
|
||||
"eav",
|
||||
]
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -64,15 +65,15 @@ TEMPLATES = [
|
|||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
EAV2_PRIMARY_KEY_FIELD = 'django.db.models.AutoField'
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
|
||||
|
||||
|
||||
# Password validation
|
||||
|
|
@ -84,9 +85,9 @@ AUTH_PASSWORD_VALIDATORS = []
|
|||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
|
|
@ -98,4 +99,4 @@ USE_TZ = False
|
|||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_URL = "/static/"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import uuid
|
||||
import string
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from hypothesis import given, settings
|
||||
from hypothesis.extra import django
|
||||
from django.conf import settings as django_settings
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.extra import django
|
||||
from hypothesis.strategies import just
|
||||
|
||||
import eav
|
||||
|
|
@ -15,7 +16,6 @@ from eav.models import Attribute, Value
|
|||
from eav.registry import EavConfig
|
||||
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
|
||||
|
||||
|
||||
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
|
||||
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
|
||||
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
|
||||
|
|
@ -27,22 +27,22 @@ else:
|
|||
class Attributes(TestCase):
|
||||
def setUp(self):
|
||||
class EncounterEavConfig(EavConfig):
|
||||
manager_attr = 'eav_objects'
|
||||
eav_attr = 'eav_field'
|
||||
generic_relation_attr = 'encounter_eav_values'
|
||||
generic_relation_related_name = 'encounters'
|
||||
manager_attr = "eav_objects"
|
||||
eav_attr = "eav_field"
|
||||
generic_relation_attr = "encounter_eav_values"
|
||||
generic_relation_related_name = "encounters"
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls, instance=None):
|
||||
return Attribute.objects.filter(slug__contains='a')
|
||||
return Attribute.objects.filter(slug__contains="a")
|
||||
|
||||
eav.register(Encounter, EncounterEavConfig)
|
||||
eav.register(Patient)
|
||||
|
||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||
|
||||
def tearDown(self):
|
||||
eav.unregister(Encounter)
|
||||
|
|
@ -53,14 +53,14 @@ class Attributes(TestCase):
|
|||
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
|
||||
|
||||
def test_duplicate_attributs(self):
|
||||
'''
|
||||
"""
|
||||
Ensure that no two Attributes with the same slug can exist.
|
||||
'''
|
||||
"""
|
||||
with self.assertRaises(ValidationError):
|
||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||
|
||||
def test_setting_attributes(self):
|
||||
p = Patient.objects.create(name='Jon')
|
||||
p = Patient.objects.create(name="Jon")
|
||||
e = Encounter.objects.create(patient=p, num=1)
|
||||
|
||||
p.eav.age = 3
|
||||
|
|
@ -73,7 +73,7 @@ class Attributes(TestCase):
|
|||
t.eav.age = 6
|
||||
t.eav.height = 10
|
||||
t.save()
|
||||
p = Patient.objects.get(name='Jon')
|
||||
p = Patient.objects.get(name="Jon")
|
||||
self.assertEqual(p.eav.age, 3)
|
||||
self.assertEqual(p.eav.height, 2.3)
|
||||
e = Encounter.objects.get(num=1)
|
||||
|
|
@ -96,20 +96,21 @@ class Attributes(TestCase):
|
|||
eav.unregister(Encounter)
|
||||
eav.register(Encounter, EncounterEavConfig)
|
||||
|
||||
p = Patient.objects.create(name='Jon')
|
||||
p = Patient.objects.create(name="Jon")
|
||||
e = Encounter.objects.create(patient=p, num=1)
|
||||
|
||||
with self.assertRaises(IllegalAssignmentException):
|
||||
e.eav.color = 'red'
|
||||
e.eav.color = "red"
|
||||
e.save()
|
||||
|
||||
def test_uuid_pk(self):
|
||||
"""Tests for when model pk is UUID."""
|
||||
d1 = Doctor.objects.create(name='Lu')
|
||||
d1.eav.age = 10
|
||||
expected_age = 10
|
||||
d1 = Doctor.objects.create(name="Lu")
|
||||
d1.eav.age = expected_age
|
||||
d1.save()
|
||||
|
||||
assert d1.eav.age == 10
|
||||
assert d1.eav.age == expected_age
|
||||
|
||||
# Validate repr of Value for an entity with a UUID PK
|
||||
v1 = Value.objects.filter(entity_uuid=d1.pk).first()
|
||||
|
|
@ -119,7 +120,7 @@ class Attributes(TestCase):
|
|||
def test_big_integer(self):
|
||||
"""Tests an integer larger than 32-bit a value."""
|
||||
big_num = 3147483647
|
||||
patient = Patient.objects.create(name='Jon')
|
||||
patient = Patient.objects.create(name="Jon")
|
||||
patient.eav.age = big_num
|
||||
|
||||
patient.save()
|
||||
|
|
@ -136,6 +137,7 @@ class TestAttributeModel(django.TestCase):
|
|||
id=auto_field_strategy,
|
||||
datatype=just(Attribute.TYPE_TEXT),
|
||||
enum_group=just(None),
|
||||
slug=just(None), # Let Attribute.save() handle
|
||||
),
|
||||
)
|
||||
@settings(deadline=None)
|
||||
|
|
@ -162,3 +164,20 @@ class TestAttributeModel(django.TestCase):
|
|||
)
|
||||
|
||||
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,75 +12,85 @@ class DataValidation(TestCase):
|
|||
def setUp(self):
|
||||
eav.register(Patient)
|
||||
|
||||
Attribute.objects.create(name='Age', datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name='DoB', datatype=Attribute.TYPE_DATE)
|
||||
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN)
|
||||
Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT)
|
||||
Attribute.objects.create(name='Extra', datatype=Attribute.TYPE_JSON)
|
||||
Attribute.objects.create(name='Multi', datatype=Attribute.TYPE_CSV)
|
||||
Attribute.objects.create(name="Age", datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name="DoB", datatype=Attribute.TYPE_DATE)
|
||||
Attribute.objects.create(name="Height", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="City", datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="Pregnant", datatype=Attribute.TYPE_BOOLEAN)
|
||||
Attribute.objects.create(name="User", datatype=Attribute.TYPE_OBJECT)
|
||||
Attribute.objects.create(name="Extra", datatype=Attribute.TYPE_JSON)
|
||||
Attribute.objects.create(name="Multi", datatype=Attribute.TYPE_CSV)
|
||||
|
||||
def tearDown(self):
|
||||
eav.unregister(Patient)
|
||||
|
||||
def test_required_field(self):
|
||||
p = Patient(name='Bob')
|
||||
p = Patient(name="Bob")
|
||||
p.eav.age = 5
|
||||
p.save()
|
||||
|
||||
Attribute.objects.create(
|
||||
name='Weight', datatype=Attribute.TYPE_INT, required=True
|
||||
name="Weight",
|
||||
datatype=Attribute.TYPE_INT,
|
||||
required=True,
|
||||
)
|
||||
p.eav.age = 6
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p = Patient.objects.get(name='Bob')
|
||||
p = Patient.objects.get(name="Bob")
|
||||
self.assertEqual(p.eav.age, 5)
|
||||
p.eav.weight = 23
|
||||
p.save()
|
||||
p = Patient.objects.get(name='Bob')
|
||||
p = Patient.objects.get(name="Bob")
|
||||
self.assertEqual(p.eav.weight, 23)
|
||||
|
||||
def test_create_required_field(self):
|
||||
Attribute.objects.create(
|
||||
name='Weight', datatype=Attribute.TYPE_INT, required=True
|
||||
name="Weight",
|
||||
datatype=Attribute.TYPE_INT,
|
||||
required=True,
|
||||
)
|
||||
self.assertRaises(
|
||||
ValidationError, Patient.objects.create, name='Joe', eav__age=5
|
||||
ValidationError,
|
||||
Patient.objects.create,
|
||||
name="Joe",
|
||||
eav__age=5,
|
||||
)
|
||||
self.assertEqual(Patient.objects.count(), 0)
|
||||
self.assertEqual(Value.objects.count(), 0)
|
||||
|
||||
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(Value.objects.count(), 2)
|
||||
|
||||
def test_validation_error_create(self):
|
||||
self.assertRaises(
|
||||
ValidationError, Patient.objects.create, name='Joe', eav__age='df'
|
||||
ValidationError,
|
||||
Patient.objects.create,
|
||||
name="Joe",
|
||||
eav__age="df",
|
||||
)
|
||||
self.assertEqual(Patient.objects.count(), 0)
|
||||
self.assertEqual(Value.objects.count(), 0)
|
||||
|
||||
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.save()
|
||||
Patient.objects.create(name='Bob', eav__color='brown')
|
||||
Patient.objects.create(name="Bob", eav__color="brown")
|
||||
a.datatype = Attribute.TYPE_INT
|
||||
self.assertRaises(ValidationError, a.save)
|
||||
|
||||
def test_int_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p.eav.age = 'bad'
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.age = "bad"
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.age = 15
|
||||
p.save()
|
||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15)
|
||||
|
||||
def test_date_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p.eav.dob = '12'
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.dob = "12"
|
||||
self.assertRaises(ValidationError, lambda: p.save())
|
||||
p.eav.dob = 15
|
||||
self.assertRaises(ValidationError, lambda: p.save())
|
||||
|
|
@ -94,26 +104,26 @@ class DataValidation(TestCase):
|
|||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today)
|
||||
|
||||
def test_float_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p.eav.height = 'bad'
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.height = "bad"
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.height = 15
|
||||
p.save()
|
||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15)
|
||||
p.eav.height = '2.3'
|
||||
p.eav.height = "2.3"
|
||||
p.save()
|
||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3)
|
||||
|
||||
def test_text_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.city = 5
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.city = 'El Dorado'
|
||||
p.eav.city = "El Dorado"
|
||||
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):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.pregnant = 5
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.pregnant = True
|
||||
|
|
@ -121,70 +131,72 @@ class DataValidation(TestCase):
|
|||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True)
|
||||
|
||||
def test_object_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.user = 5
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.user = object
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.user = User(username='joe')
|
||||
p.eav.user = User(username="joe")
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
u = User.objects.create(username='joe')
|
||||
u = User.objects.create(username="joe")
|
||||
p.eav.user = u
|
||||
p.save()
|
||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u)
|
||||
|
||||
def test_enum_validation(self):
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
unkown = EnumValue.objects.create(value='unkown')
|
||||
green = EnumValue.objects.create(value='green')
|
||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
yes = EnumValue.objects.create(value="yes")
|
||||
no = EnumValue.objects.create(value="no")
|
||||
unkown = EnumValue.objects.create(value="unkown")
|
||||
green = EnumValue.objects.create(value="green")
|
||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||
ynu.values.add(yes)
|
||||
ynu.values.add(no)
|
||||
ynu.values.add(unkown)
|
||||
Attribute.objects.create(
|
||||
name='Fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||
name="Fever",
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=ynu,
|
||||
)
|
||||
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.fever = 5
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.fever = object
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.fever = green
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.fever = EnumValue(value='yes')
|
||||
p.eav.fever = EnumValue(value="yes")
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.fever = no
|
||||
p.save()
|
||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no)
|
||||
|
||||
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)
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
unkown = EnumValue.objects.create(value='unkown')
|
||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
yes = EnumValue.objects.create(value="yes")
|
||||
no = EnumValue.objects.create(value="no")
|
||||
unkown = EnumValue.objects.create(value="unkown")
|
||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||
ynu.values.add(yes)
|
||||
ynu.values.add(no)
|
||||
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()
|
||||
|
||||
def test_enum_group_on_other_datatype(self):
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
unkown = EnumValue.objects.create(value='unkown')
|
||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
yes = EnumValue.objects.create(value="yes")
|
||||
no = EnumValue.objects.create(value="no")
|
||||
unkown = EnumValue.objects.create(value="unkown")
|
||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||
ynu.values.add(yes)
|
||||
ynu.values.add(no)
|
||||
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)
|
||||
|
||||
def test_json_validation(self):
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.extra = 5
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.extra = {"eyes": "blue", "hair": "brown"}
|
||||
|
|
@ -192,12 +204,13 @@ class DataValidation(TestCase):
|
|||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue")
|
||||
|
||||
def test_csv_validation(self):
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
p = Patient.objects.create(name='Mike')
|
||||
yes = EnumValue.objects.create(value="yes")
|
||||
p = Patient.objects.create(name="Mike")
|
||||
p.eav.multi = yes
|
||||
self.assertRaises(ValidationError, p.save)
|
||||
p.eav.multi = "one;two;three"
|
||||
p.save()
|
||||
self.assertEqual(
|
||||
Patient.objects.get(pk=p.pk).eav.multi, ["one", "two", "three"]
|
||||
Patient.objects.get(pk=p.pk).eav.multi,
|
||||
["one", "two", "three"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import sys
|
||||
|
||||
import pytest
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.core.handlers.base import BaseHandler
|
||||
|
|
@ -8,9 +6,9 @@ from django.test import TestCase
|
|||
from django.test.client import RequestFactory
|
||||
|
||||
import eav
|
||||
from eav.admin import *
|
||||
from eav.admin import BaseEntityAdmin
|
||||
from eav.forms import BaseDynamicEntityForm
|
||||
from eav.models import Attribute
|
||||
from eav.models import Attribute, EnumGroup, EnumValue
|
||||
from test_project.models import ExampleModel, M2MModel, Patient
|
||||
|
||||
|
||||
|
|
@ -20,15 +18,7 @@ class MockRequest(RequestFactory):
|
|||
request = RequestFactory.request(self, **request)
|
||||
handler = BaseHandler()
|
||||
handler.load_middleware()
|
||||
# BaseHandler_request_middleware is not set in Django2.0
|
||||
# and removed in Django2.1
|
||||
if sys.version_info[0] < 2:
|
||||
for middleware_method in handler._request_middleware:
|
||||
if middleware_method(request):
|
||||
raise Exception(
|
||||
"Couldn't create request mock object - "
|
||||
"request middleware returned a response"
|
||||
)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
|
|
@ -48,49 +38,51 @@ request.user = MockSuperUser()
|
|||
class PatientForm(ModelForm):
|
||||
class Meta:
|
||||
model = Patient
|
||||
fields = '__all__'
|
||||
fields = ("name", "email", "example")
|
||||
|
||||
|
||||
class PatientDynamicForm(BaseDynamicEntityForm):
|
||||
class Meta:
|
||||
model = Patient
|
||||
fields = '__all__'
|
||||
fields = ("name", "email", "example")
|
||||
|
||||
|
||||
class M2MModelForm(ModelForm):
|
||||
class Meta:
|
||||
model = M2MModel
|
||||
fields = '__all__'
|
||||
fields = ("name", "models")
|
||||
|
||||
|
||||
class Forms(TestCase):
|
||||
def setUp(self):
|
||||
eav.register(Patient)
|
||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||
|
||||
self.female = EnumValue.objects.create(value='Female')
|
||||
self.male = EnumValue.objects.create(value='Male')
|
||||
gender_group = EnumGroup.objects.create(name='Gender')
|
||||
self.female = EnumValue.objects.create(value="Female")
|
||||
self.male = EnumValue.objects.create(value="Male")
|
||||
gender_group = EnumGroup.objects.create(name="Gender")
|
||||
gender_group.values.add(self.female, self.male)
|
||||
|
||||
Attribute.objects.create(
|
||||
name='gender', datatype=Attribute.TYPE_ENUM, enum_group=gender_group
|
||||
name="gender",
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=gender_group,
|
||||
)
|
||||
|
||||
self.instance = Patient.objects.create(name='Jim Morrison')
|
||||
self.instance = Patient.objects.create(name="Jim Morrison")
|
||||
|
||||
def test_valid_submit(self):
|
||||
self.instance.eav.color = 'Blue'
|
||||
self.instance.eav.color = "Blue"
|
||||
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
||||
jim = form.save()
|
||||
|
||||
self.assertEqual(jim.eav.color, 'Blue')
|
||||
self.assertEqual(jim.eav.color, "Blue")
|
||||
|
||||
def test_invalid_submit(self):
|
||||
form = PatientForm(dict(color='Blue'), instance=self.instance)
|
||||
form = PatientForm({"color": "Blue"}, instance=self.instance)
|
||||
with self.assertRaises(ValueError):
|
||||
jim = form.save()
|
||||
form.save()
|
||||
|
||||
def test_valid_enums(self):
|
||||
self.instance.eav.gender = self.female
|
||||
|
|
@ -100,41 +92,41 @@ class Forms(TestCase):
|
|||
self.assertEqual(rose.eav.gender, self.female)
|
||||
|
||||
def test_m2m(self):
|
||||
m2mmodel = M2MModel.objects.create(name='name')
|
||||
model = ExampleModel.objects.create(name='name')
|
||||
form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel)
|
||||
m2mmodel = M2MModel.objects.create(name="name")
|
||||
model = ExampleModel.objects.create(name="name")
|
||||
form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel)
|
||||
form.save()
|
||||
self.assertEqual(len(m2mmodel.models.all()), 1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def patient() -> Patient:
|
||||
"""Return an eav enabled Patient instance."""
|
||||
eav.register(Patient)
|
||||
return Patient.objects.create(name='Jim Morrison')
|
||||
return Patient.objects.create(name="Jim Morrison")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def create_attributes() -> None:
|
||||
"""Create some Attributes to use for testing."""
|
||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
'csv_data, separator',
|
||||
("csv_data", "separator"),
|
||||
[
|
||||
('', ';'),
|
||||
('justone', ','),
|
||||
('one;two;three', ';'),
|
||||
('alpha,beta,gamma', ','),
|
||||
(None, ','),
|
||||
("", ";"),
|
||||
("justone", ","),
|
||||
("one;two;three", ";"),
|
||||
("alpha,beta,gamma", ","),
|
||||
(None, ","),
|
||||
],
|
||||
)
|
||||
def test_csvdynamicform(patient, csv_data, separator) -> None:
|
||||
"""Ensure that a TYPE_CSV field works correctly with forms."""
|
||||
Attribute.objects.create(name='csv', datatype=Attribute.TYPE_CSV)
|
||||
Attribute.objects.create(name="csv", datatype=Attribute.TYPE_CSV)
|
||||
patient.eav.csv = csv_data
|
||||
patient.save()
|
||||
patient.refresh_from_db()
|
||||
|
|
@ -143,7 +135,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
|
|||
patient.__dict__,
|
||||
instance=patient,
|
||||
)
|
||||
form.fields['csv'].separator = separator
|
||||
form.fields["csv"].separator = separator
|
||||
assert form.is_valid()
|
||||
jim = form.save()
|
||||
|
||||
|
|
@ -151,7 +143,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
|
|||
assert jim.eav.csv == expected_result
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
@pytest.mark.django_db
|
||||
def test_csvdynamicform_empty(patient) -> None:
|
||||
"""Test to ensure an instance with no eav values is correct."""
|
||||
form = PatientDynamicForm(
|
||||
|
|
@ -162,29 +154,31 @@ def test_csvdynamicform_empty(patient) -> None:
|
|||
assert form.save()
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
@pytest.mark.usefixtures('create_attributes')
|
||||
@pytest.mark.parametrize('define_fieldsets', (True, False))
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("create_attributes")
|
||||
@pytest.mark.parametrize("define_fieldsets", [True, False])
|
||||
def test_entity_admin_form(patient, define_fieldsets):
|
||||
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
|
||||
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||
admin.readonly_fields = ('email',)
|
||||
admin.readonly_fields = ("email",)
|
||||
admin.form = BaseDynamicEntityForm
|
||||
expected_fieldsets = 2
|
||||
|
||||
if define_fieldsets:
|
||||
# Use all fields in Patient model
|
||||
admin.fieldsets = (
|
||||
(None, {'fields': ['name', 'example']}),
|
||||
('Contact Info', {'fields': ['email']}),
|
||||
(None, {"fields": ["name", "example"]}),
|
||||
("Contact Info", {"fields": ["email"]}),
|
||||
)
|
||||
expected_fieldsets = 3
|
||||
|
||||
view = admin.change_view(request, str(patient.pk))
|
||||
|
||||
adminform = view.context_data['adminform']
|
||||
adminform = view.context_data["adminform"]
|
||||
|
||||
# Count the total fields in fieldsets
|
||||
total_fields = sum(
|
||||
len(fields_info['fields']) for _, fields_info in adminform.fieldsets
|
||||
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||
)
|
||||
|
||||
# 3 for 'name', 'email', 'example'
|
||||
|
|
@ -193,27 +187,65 @@ def test_entity_admin_form(patient, define_fieldsets):
|
|||
assert total_fields == expected_fields_count
|
||||
|
||||
# Ensure our fieldset count is correct
|
||||
if define_fieldsets:
|
||||
assert len(adminform.fieldsets) == 3
|
||||
else:
|
||||
assert len(adminform.fieldsets) == 2
|
||||
assert len(adminform.fieldsets) == expected_fieldsets
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
@pytest.mark.django_db
|
||||
def test_entity_admin_form_no_attributes(patient):
|
||||
"""Test the BaseEntityAdmin form with no Attributes created."""
|
||||
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||
admin.readonly_fields = ('email',)
|
||||
admin.readonly_fields = ("email",)
|
||||
admin.form = BaseDynamicEntityForm
|
||||
|
||||
# Only fields defined in Patient model
|
||||
expected_fields = 3
|
||||
|
||||
view = admin.change_view(request, str(patient.pk))
|
||||
|
||||
adminform = view.context_data['adminform']
|
||||
adminform = view.context_data["adminform"]
|
||||
|
||||
# Count the total fields in fieldsets
|
||||
total_fields = sum(
|
||||
len(fields_info['fields']) for _, fields_info in adminform.fieldsets
|
||||
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||
)
|
||||
|
||||
# 3 for 'name', 'email', 'example'
|
||||
assert total_fields == 3
|
||||
assert total_fields == expected_fields
|
||||
|
||||
|
||||
@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,3 +1,4 @@
|
|||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
|
|
@ -18,3 +19,58 @@ def test_generate_long_slug_text(name: str) -> None:
|
|||
slug = generate_slug(name)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def enumgroup(db):
|
||||
"""Sample `EnumGroup` object for testing."""
|
||||
test_group = EnumGroup.objects.create(name='Yes / No')
|
||||
value_yes = EnumValue.objects.create(value='Yes')
|
||||
value_no = EnumValue.objects.create(value='No')
|
||||
test_group = EnumGroup.objects.create(name="Yes / No")
|
||||
value_yes = EnumValue.objects.create(value="Yes")
|
||||
value_no = EnumValue.objects.create(value="No")
|
||||
test_group.values.add(value_yes)
|
||||
test_group.values.add(value_no)
|
||||
return test_group
|
||||
|
|
@ -19,14 +19,14 @@ def enumgroup(db):
|
|||
|
||||
def test_enumgroup_display(enumgroup):
|
||||
"""Test repr() and str() of EnumGroup."""
|
||||
assert '<EnumGroup {0}>'.format(enumgroup.name) == repr(enumgroup)
|
||||
assert f"<EnumGroup {enumgroup.name}>" == repr(enumgroup)
|
||||
assert str(enumgroup) == str(enumgroup.name)
|
||||
|
||||
|
||||
def test_enumvalue_display(enumgroup):
|
||||
"""Test repr() and str() of EnumValue."""
|
||||
test_value = enumgroup.values.first()
|
||||
assert '<EnumValue {0}>'.format(test_value.value) == repr(test_value)
|
||||
assert f"<EnumValue {test_value.value}>" == repr(test_value)
|
||||
assert str(test_value) == test_value.value
|
||||
|
||||
|
||||
|
|
@ -34,33 +34,37 @@ class MiscModels(TestCase):
|
|||
"""Miscellaneous tests on models."""
|
||||
|
||||
def test_attribute_help_text(self):
|
||||
desc = 'Patient Age'
|
||||
desc = "Patient Age"
|
||||
a = Attribute.objects.create(
|
||||
name='age', description=desc, datatype=Attribute.TYPE_INT
|
||||
name="age",
|
||||
description=desc,
|
||||
datatype=Attribute.TYPE_INT,
|
||||
)
|
||||
self.assertEqual(a.help_text, desc)
|
||||
|
||||
def test_setting_to_none_deletes_value(self):
|
||||
eav.register(Patient)
|
||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||
p = Patient.objects.create(name='Bob', eav__age=5)
|
||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||
p = Patient.objects.create(name="Bob", eav__age=5)
|
||||
self.assertEqual(Value.objects.count(), 1)
|
||||
p.eav.age = None
|
||||
p.save()
|
||||
self.assertEqual(Value.objects.count(), 0)
|
||||
|
||||
def test_string_enum_value_assignment(self):
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
yes = EnumValue.objects.create(value="yes")
|
||||
no = EnumValue.objects.create(value="no")
|
||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||
ynu.values.add(yes)
|
||||
ynu.values.add(no)
|
||||
Attribute.objects.create(
|
||||
name='is_patient', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||
name="is_patient",
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=ynu,
|
||||
)
|
||||
eav.register(Patient)
|
||||
p = Patient.objects.create(name='Joe')
|
||||
p.eav.is_patient = 'yes'
|
||||
p = Patient.objects.create(name="Joe")
|
||||
p.eav.is_patient = "yes"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
from django.test import TestCase
|
||||
|
||||
import eav
|
||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||
from test_project.models import Patient
|
||||
import eav
|
||||
|
||||
|
||||
class ModelTest(TestCase):
|
||||
def setUp(self):
|
||||
eav.register(Patient)
|
||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||
|
||||
EnumGroup.objects.create(name='Yes / No')
|
||||
EnumValue.objects.create(value='yes')
|
||||
EnumValue.objects.create(value='no')
|
||||
EnumValue.objects.create(value='unknown')
|
||||
EnumGroup.objects.create(name="Yes / No")
|
||||
EnumValue.objects.create(value="yes")
|
||||
EnumValue.objects.create(value="no")
|
||||
EnumValue.objects.create(value="unknown")
|
||||
|
||||
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_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key)
|
||||
self.assertEqual(attr_retrieved_model, attr)
|
||||
|
||||
def test_value_natural_keys(self):
|
||||
p = Patient.objects.create(name='Jon')
|
||||
p = Patient.objects.create(name="Jon")
|
||||
p.eav.age = 5
|
||||
p.save()
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ class ModelTest(TestCase):
|
|||
enum_group = EnumGroup.objects.first()
|
||||
enum_group_natural_key = enum_group.natural_key()
|
||||
enum_group_retrieved_model = EnumGroup.objects.get_by_natural_key(
|
||||
*enum_group_natural_key
|
||||
*enum_group_natural_key,
|
||||
)
|
||||
self.assertEqual(enum_group_retrieved_model, enum_group)
|
||||
|
||||
|
|
@ -46,6 +47,6 @@ class ModelTest(TestCase):
|
|||
enum_value = EnumValue.objects.first()
|
||||
enum_value_natural_key = enum_value.natural_key()
|
||||
enum_value_retrieved_model = EnumValue.objects.get_by_natural_key(
|
||||
*enum_value_natural_key
|
||||
*enum_value_natural_key,
|
||||
)
|
||||
self.assertEqual(enum_value_retrieved_model, enum_value)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.db import models
|
||||
|
||||
from eav.logic.object_pk import get_pk_format
|
||||
from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format
|
||||
|
||||
|
||||
def test_get_uuid_primary_key(settings) -> None:
|
||||
|
|
@ -21,7 +20,7 @@ def test_get_char_primary_key(settings) -> None:
|
|||
assert isinstance(primary_field, models.CharField)
|
||||
assert primary_field.primary_key
|
||||
assert not primary_field.editable
|
||||
assert primary_field.max_length == 40
|
||||
assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN
|
||||
|
||||
|
||||
def test_get_default_primary_key(settings) -> None:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.db.utils import NotSupportedError
|
||||
|
|
@ -6,7 +9,7 @@ from django.test import TestCase
|
|||
import eav
|
||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||
from eav.registry import EavConfig
|
||||
from test_project.models import Encounter, Patient, ExampleModel
|
||||
from test_project.models import Encounter, ExampleModel, Patient
|
||||
|
||||
|
||||
class Queries(TestCase):
|
||||
|
|
@ -14,32 +17,34 @@ class Queries(TestCase):
|
|||
eav.register(Encounter)
|
||||
eav.register(Patient)
|
||||
|
||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name='extras', datatype=Attribute.TYPE_JSON)
|
||||
Attribute.objects.create(name='illness', datatype=Attribute.TYPE_CSV)
|
||||
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||
Attribute.objects.create(name="city", datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="country", datatype=Attribute.TYPE_TEXT)
|
||||
Attribute.objects.create(name="extras", datatype=Attribute.TYPE_JSON)
|
||||
Attribute.objects.create(name="illness", datatype=Attribute.TYPE_CSV)
|
||||
|
||||
self.yes = EnumValue.objects.create(value='yes')
|
||||
self.no = EnumValue.objects.create(value='no')
|
||||
self.unknown = EnumValue.objects.create(value='unknown')
|
||||
self.yes = EnumValue.objects.create(value="yes")
|
||||
self.no = EnumValue.objects.create(value="no")
|
||||
self.unknown = EnumValue.objects.create(value="unknown")
|
||||
|
||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||
ynu.values.add(self.yes)
|
||||
ynu.values.add(self.no)
|
||||
ynu.values.add(self.unknown)
|
||||
|
||||
Attribute.objects.create(
|
||||
name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu
|
||||
name="fever",
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=ynu,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
eav.unregister(Encounter)
|
||||
eav.unregister(Patient)
|
||||
|
||||
def init_data(self):
|
||||
def init_data(self) -> None:
|
||||
yes = self.yes
|
||||
no = self.no
|
||||
|
||||
|
|
@ -47,24 +52,24 @@ class Queries(TestCase):
|
|||
# Name, age, fever,
|
||||
# city, country, extras
|
||||
# possible illness
|
||||
['Anne', 3, no, 'New York', 'USA', {"chills": "yes"}, "cold"],
|
||||
['Bob', 15, no, 'Bamako', 'Mali', {}, ""],
|
||||
["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"],
|
||||
["Bob", 15, no, "Bamako", "Mali", {}, ""],
|
||||
[
|
||||
'Cyrill',
|
||||
"Cyrill",
|
||||
15,
|
||||
yes,
|
||||
'Kisumu',
|
||||
'Kenya',
|
||||
"Kisumu",
|
||||
"Kenya",
|
||||
{"chills": "yes", "headache": "no"},
|
||||
"flu",
|
||||
],
|
||||
['Daniel', 3, no, 'Nice', 'France', {"headache": "yes"}, "cold"],
|
||||
["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"],
|
||||
[
|
||||
'Eugene',
|
||||
"Eugene",
|
||||
2,
|
||||
yes,
|
||||
'France',
|
||||
'Nice',
|
||||
"France",
|
||||
"Nice",
|
||||
{"chills": "no", "headache": "yes"},
|
||||
"flu;cold",
|
||||
],
|
||||
|
|
@ -82,26 +87,26 @@ class Queries(TestCase):
|
|||
)
|
||||
|
||||
def test_get_or_create_with_eav(self):
|
||||
Patient.objects.get_or_create(name='Bob', eav__age=5)
|
||||
Patient.objects.get_or_create(name="Bob", eav__age=5)
|
||||
self.assertEqual(Patient.objects.count(), 1)
|
||||
self.assertEqual(Value.objects.count(), 1)
|
||||
Patient.objects.get_or_create(name='Bob', eav__age=5)
|
||||
Patient.objects.get_or_create(name="Bob", eav__age=5)
|
||||
self.assertEqual(Patient.objects.count(), 1)
|
||||
self.assertEqual(Value.objects.count(), 1)
|
||||
Patient.objects.get_or_create(name='Bob', eav__age=6)
|
||||
Patient.objects.get_or_create(name="Bob", eav__age=6)
|
||||
self.assertEqual(Patient.objects.count(), 2)
|
||||
self.assertEqual(Value.objects.count(), 2)
|
||||
|
||||
def test_get_or_create_with_defaults(self):
|
||||
"""Tests EntityManager.get_or_create() with defaults keyword."""
|
||||
city_name = 'Tokyo'
|
||||
email = 'mari@test.com'
|
||||
city_name = "Tokyo"
|
||||
email = "mari@test.com"
|
||||
p1, _ = Patient.objects.get_or_create(
|
||||
name='Mari',
|
||||
name="Mari",
|
||||
eav__age=27,
|
||||
defaults={
|
||||
'email': email,
|
||||
'eav__city': city_name,
|
||||
"email": email,
|
||||
"eav__city": city_name,
|
||||
},
|
||||
)
|
||||
assert Patient.objects.count() == 1
|
||||
|
|
@ -109,175 +114,258 @@ class Queries(TestCase):
|
|||
assert p1.eav.city == city_name
|
||||
|
||||
def test_get_with_eav(self):
|
||||
p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6)
|
||||
p1, _ = Patient.objects.get_or_create(name="Bob", eav__age=6)
|
||||
self.assertEqual(Patient.objects.get(eav__age=6), p1)
|
||||
|
||||
Patient.objects.create(name='Fred', eav__age=6)
|
||||
Patient.objects.create(name="Fred", eav__age=6)
|
||||
self.assertRaises(
|
||||
MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)
|
||||
MultipleObjectsReturned,
|
||||
lambda: Patient.objects.get(eav__age=6),
|
||||
)
|
||||
|
||||
def test_filtering_on_normal_and_eav_fields(self):
|
||||
def test_no_results_for_contradictory_conditions(self) -> None:
|
||||
"""Test that contradictory conditions return no results."""
|
||||
self.init_data()
|
||||
|
||||
# Check number of objects in DB.
|
||||
self.assertEqual(Patient.objects.count(), 5)
|
||||
self.assertEqual(Value.objects.count(), 29)
|
||||
|
||||
# Nobody
|
||||
q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no)
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 0)
|
||||
|
||||
# Anne, Daniel
|
||||
# Should return no patients due to contradictory conditions
|
||||
assert p.count() == 0
|
||||
|
||||
def test_filtering_on_numeric_eav_fields(self) -> None:
|
||||
"""Test filtering on numeric EAV fields."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__age__gte=3) # Everyone except Eugene
|
||||
q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene
|
||||
p = Patient.objects.filter(q2 & q1)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# Anne
|
||||
q1 = Q(eav__city__contains='Y') & Q(eav__fever='no')
|
||||
# Should return Anne and Daniel
|
||||
assert p.count() == 2
|
||||
|
||||
def test_filtering_on_text_and_boolean_eav_fields(self) -> None:
|
||||
"""Test filtering on text and boolean EAV fields."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__city__contains="Y") & Q(eav__fever="no")
|
||||
q2 = Q(eav__age=3)
|
||||
p = Patient.objects.filter(q1 & q2)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
# Anne
|
||||
q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no)
|
||||
# Should return only Anne
|
||||
assert p.count() == 1
|
||||
|
||||
def test_filtering_with_enum_eav_fields(self) -> None:
|
||||
"""Test filtering with enum EAV fields."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__city__contains="Y") & Q(eav__fever=self.no)
|
||||
q2 = Q(eav__age=3)
|
||||
p = Patient.objects.filter(q1 & q2)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
# Anne, Daniel
|
||||
q1 = Q(eav__city__contains='Y', eav__fever=self.no)
|
||||
q2 = Q(eav__city='Nice')
|
||||
# Should return only Anne
|
||||
assert p.count() == 1
|
||||
|
||||
def test_complex_query_with_or_conditions(self) -> None:
|
||||
"""Test complex query with OR conditions."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__city__contains="Y", eav__fever=self.no)
|
||||
q2 = Q(eav__city="Nice")
|
||||
q3 = Q(eav__age=3)
|
||||
p = Patient.objects.filter((q1 | q2) & q3)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# Everyone
|
||||
# Should return Anne and Daniel
|
||||
assert p.count() == 2
|
||||
|
||||
def test_filtering_with_multiple_enum_values(self) -> None:
|
||||
"""Test filtering with multiple enum values."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes)
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 5)
|
||||
|
||||
# Anne, Bob, Daniel
|
||||
# Should return all patients
|
||||
assert p.count() == 5
|
||||
|
||||
def test_complex_query_with_multiple_conditions(self) -> None:
|
||||
"""Test complex query with multiple conditions."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel
|
||||
q2 = Q(eav__fever=self.yes) # Cyrill, Eugene
|
||||
q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene
|
||||
q3 = Q(eav__country__contains="e") # Cyrill, Daniel, Eugene
|
||||
q4 = q2 & q3 # Cyrill, Daniel, Eugene
|
||||
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
|
||||
p = Patient.objects.filter(q5)
|
||||
self.assertEqual(p.count(), 3)
|
||||
|
||||
# Everyone except Anne
|
||||
q1 = Q(eav__city__contains='Y')
|
||||
# Should return Anne, Bob, and Daniel
|
||||
assert p.count() == 3
|
||||
|
||||
def test_excluding_with_eav_fields(self) -> None:
|
||||
"""Test excluding with EAV fields."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__city__contains="Y")
|
||||
p = Patient.objects.exclude(q1)
|
||||
self.assertEqual(p.count(), 4)
|
||||
|
||||
# Anne, Bob, Daniel
|
||||
q1 = Q(eav__city__contains='Y')
|
||||
# Should return all patients except Anne
|
||||
assert p.count() == 4
|
||||
|
||||
def test_filtering_with_or_conditions(self) -> None:
|
||||
"""Test filtering with OR conditions."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__city__contains="Y")
|
||||
q2 = Q(eav__fever=self.no)
|
||||
q3 = q1 | q2
|
||||
p = Patient.objects.filter(q3)
|
||||
self.assertEqual(p.count(), 3)
|
||||
|
||||
# Anne, Daniel
|
||||
# Should return Anne, Bob, and Daniel
|
||||
assert p.count() == 3
|
||||
|
||||
def test_filtering_on_single_eav_field(self) -> None:
|
||||
"""Test filtering on a single EAV field."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__age=3)
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# Eugene
|
||||
q1 = Q(name__contains='E', eav__fever=self.yes)
|
||||
# Should return Anne and Daniel
|
||||
assert p.count() == 2
|
||||
|
||||
def test_combining_normal_and_eav_fields(self) -> None:
|
||||
"""Test combining normal and EAV fields in a query."""
|
||||
self.init_data()
|
||||
q1 = Q(name__contains="E", eav__fever=self.yes)
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
# Extras: Chills
|
||||
# Without
|
||||
# Should return only Eugene
|
||||
assert p.count() == 1
|
||||
|
||||
def test_filtering_on_json_eav_field(self) -> None:
|
||||
"""Test filtering on JSON EAV field."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__extras__has_key="chills")
|
||||
p = Patient.objects.exclude(q1)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# With
|
||||
# Should return patients without 'chills' in extras
|
||||
assert p.count() == 2
|
||||
|
||||
q1 = Q(eav__extras__has_key="chills")
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 3)
|
||||
|
||||
# No chills
|
||||
# Should return patients with 'chills' in extras
|
||||
assert p.count() == 3
|
||||
|
||||
q1 = Q(eav__extras__chills="no")
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
# Has chills
|
||||
# Should return patients with 'chills' set to 'no'
|
||||
assert p.count() == 1
|
||||
|
||||
q1 = Q(eav__extras__chills="yes")
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# Extras: Empty
|
||||
# Yes
|
||||
# Should return patients with 'chills' set to 'yes'
|
||||
assert p.count() == 2
|
||||
|
||||
def test_filtering_on_empty_json_eav_field(self) -> None:
|
||||
"""Test filtering on empty JSON EAV field."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__extras={})
|
||||
p = Patient.objects.filter(q1)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
# No
|
||||
# Should return patients with empty extras
|
||||
assert p.count() == 1
|
||||
|
||||
q1 = Q(eav__extras={})
|
||||
p = Patient.objects.exclude(q1)
|
||||
self.assertEqual(p.count(), 4)
|
||||
|
||||
# Illness:
|
||||
# Cold
|
||||
# Should return patients with non-empty extras
|
||||
assert p.count() == 4
|
||||
|
||||
def test_filtering_on_text_eav_field_with_icontains(self) -> None:
|
||||
"""Test filtering on text EAV field with icontains."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__illness__icontains="cold")
|
||||
p = Patient.objects.exclude(q1)
|
||||
self.assertEqual(p.count(), 2)
|
||||
|
||||
# Flu
|
||||
# Should return patients without 'cold' in illness
|
||||
assert p.count() == 2
|
||||
|
||||
q1 = Q(eav__illness__icontains="flu")
|
||||
p = Patient.objects.exclude(q1)
|
||||
self.assertEqual(p.count(), 3)
|
||||
|
||||
# Empty
|
||||
# Should return patients without 'flu' in illness
|
||||
assert p.count() == 3
|
||||
|
||||
def test_filtering_on_null_eav_field(self) -> None:
|
||||
"""Test filtering on null EAV field."""
|
||||
self.init_data()
|
||||
q1 = Q(eav__illness__isnull=False)
|
||||
p = Patient.objects.filter(~q1)
|
||||
self.assertEqual(p.count(), 1)
|
||||
|
||||
def _order(self, ordering):
|
||||
# Should return patients with null illness
|
||||
assert p.count() == 1
|
||||
|
||||
def _order(self, ordering) -> list[str]:
|
||||
query = Patient.objects.all().order_by(*ordering)
|
||||
return list(query.values_list('name', flat=True))
|
||||
return list(query.values_list("name", flat=True))
|
||||
|
||||
def assert_order_by_results(self, eav_attr='eav'):
|
||||
self.assertEqual(
|
||||
['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'],
|
||||
self._order(['%s__city' % eav_attr]),
|
||||
)
|
||||
def assert_order_by_results(self, eav_attr="eav") -> None:
|
||||
"""Test the ordering functionality of EAV attributes."""
|
||||
# Ordering by a single EAV attribute
|
||||
assert self._order([f"{eav_attr}__city"]) == [
|
||||
"Bob",
|
||||
"Eugene",
|
||||
"Cyrill",
|
||||
"Anne",
|
||||
"Daniel",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'],
|
||||
self._order(['%s__age' % eav_attr, '%s__city' % eav_attr]),
|
||||
)
|
||||
# Ordering by multiple EAV attributes
|
||||
assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [
|
||||
"Eugene",
|
||||
"Anne",
|
||||
"Daniel",
|
||||
"Bob",
|
||||
"Cyrill",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'],
|
||||
self._order(['%s__fever' % eav_attr, '%s__age' % eav_attr]),
|
||||
)
|
||||
# Ordering by EAV attributes with different data types
|
||||
assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [
|
||||
"Eugene",
|
||||
"Cyrill",
|
||||
"Anne",
|
||||
"Daniel",
|
||||
"Bob",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'],
|
||||
self._order(['%s__fever' % eav_attr, '-name']),
|
||||
)
|
||||
# Combining EAV and regular model field ordering
|
||||
assert self._order([f"{eav_attr}__fever", "-name"]) == [
|
||||
"Eugene",
|
||||
"Cyrill",
|
||||
"Daniel",
|
||||
"Bob",
|
||||
"Anne",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'],
|
||||
self._order(['-name', '%s__age' % eav_attr]),
|
||||
)
|
||||
# Mixing regular and EAV field ordering
|
||||
assert self._order(["-name", f"{eav_attr}__age"]) == [
|
||||
"Eugene",
|
||||
"Daniel",
|
||||
"Cyrill",
|
||||
"Bob",
|
||||
"Anne",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'],
|
||||
self._order(['example__name']),
|
||||
)
|
||||
# Ordering by a related model field
|
||||
assert self._order(["example__name"]) == [
|
||||
"Anne",
|
||||
"Bob",
|
||||
"Cyrill",
|
||||
"Daniel",
|
||||
"Eugene",
|
||||
]
|
||||
|
||||
with self.assertRaises(NotSupportedError):
|
||||
Patient.objects.all().order_by('%s__first__second' % eav_attr)
|
||||
# Error handling for unsupported nested EAV attributes
|
||||
with pytest.raises(NotSupportedError):
|
||||
Patient.objects.all().order_by(f"{eav_attr}__first__second")
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
Patient.objects.all().order_by('%s__nonsense' % eav_attr)
|
||||
# Error handling for non-existent EAV attributes
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
Patient.objects.all().order_by(f"{eav_attr}__nonsense")
|
||||
|
||||
def test_order_by(self):
|
||||
self.init_data()
|
||||
|
|
@ -291,11 +379,11 @@ class Queries(TestCase):
|
|||
self.init_data()
|
||||
eav.unregister(Patient)
|
||||
eav.register(Patient, config_cls=CustomConfig)
|
||||
self.assert_order_by_results(eav_attr='data')
|
||||
self.assert_order_by_results(eav_attr="data")
|
||||
|
||||
def test_fk_filter(self):
|
||||
e = ExampleModel.objects.create(name='test1')
|
||||
p = Patient.objects.get_or_create(name='Beth', example=e)[0]
|
||||
e = ExampleModel.objects.create(name="test1")
|
||||
p = Patient.objects.get_or_create(name="Beth", example=e)[0]
|
||||
c = ExampleModel.objects.filter(patient=p)
|
||||
self.assertEqual(c.count(), 1)
|
||||
|
||||
|
|
@ -310,12 +398,12 @@ class Queries(TestCase):
|
|||
|
||||
# Use the filter method with 3 EAV attribute conditions
|
||||
patients = Patient.objects.filter(
|
||||
name='Anne',
|
||||
name="Anne",
|
||||
eav__age=3,
|
||||
eav__illness='cold',
|
||||
eav__fever='no',
|
||||
eav__illness="cold",
|
||||
eav__fever="no",
|
||||
)
|
||||
|
||||
# Assert that the expected patient is returned
|
||||
self.assertEqual(len(patients), 1)
|
||||
self.assertEqual(patients[0].name, 'Anne')
|
||||
self.assertEqual(patients[0].name, "Anne")
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ from django.contrib.auth.models import User
|
|||
from django.test import TestCase
|
||||
|
||||
import eav
|
||||
from eav.managers import EntityManager
|
||||
from eav.registry import EavConfig
|
||||
from test_project.models import (
|
||||
Doctor,
|
||||
Encounter,
|
||||
ExampleMetaclassModel,
|
||||
ExampleModel,
|
||||
|
|
@ -20,72 +22,72 @@ class RegistryTests(TestCase):
|
|||
|
||||
def register_encounter(self):
|
||||
class EncounterEav(EavConfig):
|
||||
manager_attr = 'eav_objects'
|
||||
eav_attr = 'eav_field'
|
||||
generic_relation_attr = 'encounter_eav_values'
|
||||
generic_relation_related_name = 'encounters'
|
||||
manager_attr = "eav_objects"
|
||||
eav_attr = "eav_field"
|
||||
generic_relation_attr = "encounter_eav_values"
|
||||
generic_relation_related_name = "encounters"
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
return 'testing'
|
||||
return "testing"
|
||||
|
||||
eav.register(Encounter, EncounterEav)
|
||||
|
||||
def test_registering_with_defaults(self):
|
||||
eav.register(Patient)
|
||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
||||
self.assertFalse(Patient._eav_config_cls.manager_only)
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 'eav_values')
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_attr, "eav_values")
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
||||
eav.unregister(Patient)
|
||||
|
||||
def test_registering_overriding_defaults(self):
|
||||
eav.register(Patient)
|
||||
self.register_encounter()
|
||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
||||
|
||||
self.assertTrue(hasattr(Encounter, '_eav_config_cls'))
|
||||
self.assertEqual(Encounter._eav_config_cls.get_attributes(), 'testing')
|
||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects')
|
||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field')
|
||||
self.assertTrue(hasattr(Encounter, "_eav_config_cls"))
|
||||
self.assertEqual(Encounter._eav_config_cls.get_attributes(), "testing")
|
||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects")
|
||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field")
|
||||
eav.unregister(Patient)
|
||||
eav.unregister(Encounter)
|
||||
|
||||
def test_registering_via_decorator_with_defaults(self):
|
||||
self.assertTrue(hasattr(ExampleModel, '_eav_config_cls'))
|
||||
self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertTrue(hasattr(ExampleModel, "_eav_config_cls"))
|
||||
self.assertEqual(ExampleModel._eav_config_cls.manager_attr, "objects")
|
||||
self.assertEqual(ExampleModel._eav_config_cls.eav_attr, "eav")
|
||||
|
||||
def test_register_via_metaclass_with_defaults(self):
|
||||
self.assertTrue(hasattr(ExampleMetaclassModel, '_eav_config_cls'))
|
||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertTrue(hasattr(ExampleMetaclassModel, "_eav_config_cls"))
|
||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, "objects")
|
||||
self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, "eav")
|
||||
|
||||
def test_unregistering(self):
|
||||
old_mgr = Patient.objects
|
||||
eav.register(Patient)
|
||||
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager")
|
||||
eav.unregister(Patient)
|
||||
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager")
|
||||
self.assertEqual(Patient.objects, old_mgr)
|
||||
self.assertFalse(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertFalse(hasattr(Patient, "_eav_config_cls"))
|
||||
|
||||
def test_unregistering_via_decorator(self):
|
||||
self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertTrue(ExampleModel.objects.__class__.__name__ == "EntityManager")
|
||||
eav.unregister(ExampleModel)
|
||||
self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertFalse(ExampleModel.objects.__class__.__name__ == "EntityManager")
|
||||
|
||||
def test_unregistering_via_metaclass(self):
|
||||
self.assertTrue(
|
||||
ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager'
|
||||
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
|
||||
)
|
||||
eav.unregister(ExampleMetaclassModel)
|
||||
self.assertFalse(
|
||||
ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager'
|
||||
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
|
||||
)
|
||||
|
||||
def test_unregistering_unregistered_model_proceeds_silently(self):
|
||||
|
|
@ -96,10 +98,10 @@ class RegistryTests(TestCase):
|
|||
eav.register(Patient)
|
||||
|
||||
def test_doesnt_register_nonmodel(self):
|
||||
with self.assertRaises(ValueError):
|
||||
with self.assertRaises(TypeError):
|
||||
|
||||
@eav.decorators.register_eav()
|
||||
class Foo(object):
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
def test_model_without_local_managers(self):
|
||||
|
|
@ -112,3 +114,23 @@ class RegistryTests(TestCase):
|
|||
# Reverse check: managers should be empty again
|
||||
eav.unregister(User)
|
||||
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):
|
||||
class EncounterEav(EavConfig):
|
||||
manager_attr = 'eav_objects'
|
||||
eav_attr = 'eav_field'
|
||||
generic_relation_attr = 'encounter_eav_values'
|
||||
generic_relation_related_name = 'encounters'
|
||||
manager_attr = "eav_objects"
|
||||
eav_attr = "eav_field"
|
||||
generic_relation_attr = "encounter_eav_values"
|
||||
generic_relation_related_name = "encounters"
|
||||
|
||||
eav.register(Encounter, EncounterEav)
|
||||
|
||||
def test_registering_with_defaults(self):
|
||||
eav.register(Patient)
|
||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
||||
self.assertFalse(Patient._eav_config_cls.manager_only)
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 'eav_values')
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_attr, "eav_values")
|
||||
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
||||
eav.unregister(Patient)
|
||||
|
||||
def test_registering_overriding_defaults(self):
|
||||
eav.register(Patient)
|
||||
self.register_encounter()
|
||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
||||
self.assertTrue(hasattr(Patient, "_eav_config_cls"))
|
||||
self.assertEqual(Patient._eav_config_cls.manager_attr, "objects")
|
||||
self.assertEqual(Patient._eav_config_cls.eav_attr, "eav")
|
||||
|
||||
self.assertTrue(hasattr(Encounter, '_eav_config_cls'))
|
||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects')
|
||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field')
|
||||
self.assertTrue(hasattr(Encounter, "_eav_config_cls"))
|
||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects")
|
||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field")
|
||||
eav.unregister(Patient)
|
||||
eav.unregister(Encounter)
|
||||
|
||||
def test_unregistering(self):
|
||||
old_mgr = Patient.objects
|
||||
eav.register(Patient)
|
||||
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager")
|
||||
eav.unregister(Patient)
|
||||
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
|
||||
self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager")
|
||||
self.assertEqual(Patient.objects, old_mgr)
|
||||
self.assertFalse(hasattr(Patient, '_eav_config_cls'))
|
||||
self.assertFalse(hasattr(Patient, "_eav_config_cls"))
|
||||
|
||||
def test_unregistering_unregistered_model_proceeds_silently(self):
|
||||
eav.unregister(Patient)
|
||||
|
|
|
|||
319
tests/test_value.py
Normal file
319
tests/test_value.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
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