mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-03-17 06:50:24 +00:00
Compare commits
419 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 | ||
|
|
83e598a14d | ||
|
|
8318cfd8cc | ||
|
|
aea2f96874 | ||
|
|
49587d0800 | ||
|
|
792fc65003 | ||
|
|
d6af6a004a | ||
|
|
4d2c080857 | ||
|
|
2d7b06c956 | ||
|
|
93c434bf72 | ||
|
|
5bc35da842 | ||
|
|
84bfbf9063 | ||
|
|
c97fdd2b2b | ||
|
|
e0e5602cbd | ||
|
|
90cd78d8f7 | ||
|
|
9712b9152c | ||
|
|
fecebffd1b | ||
|
|
64555d861b | ||
|
|
75473be13e | ||
|
|
0d9b2b4b48 | ||
|
|
af46fec191 | ||
|
|
176d74e0bc | ||
|
|
76c92da8fe | ||
|
|
bdde209f30 | ||
|
|
27e8aee9b8 | ||
|
|
159d16671f | ||
|
|
5e97e2ab9a | ||
|
|
aad9a79396 | ||
|
|
e30fdf7a8c | ||
|
|
23f10d2955 | ||
|
|
8a067fe88b | ||
|
|
8be3cedf04 | ||
|
|
aba1ca4b2e | ||
|
|
c2776cca52 | ||
|
|
d592c61e78 | ||
|
|
9713c0d1c9 | ||
|
|
eb31634b9e | ||
|
|
91c1b1452d | ||
|
|
24a35e77dc | ||
|
|
1a46ddc9a7 | ||
|
|
fa56adc94a | ||
|
|
6d97fce0b0 | ||
|
|
932bcd2b02 | ||
|
|
188f21a120 | ||
|
|
ec1d9737ab | ||
|
|
3b8ce78010 | ||
|
|
6c4c418b7c | ||
|
|
6ec6e008dc | ||
|
|
b51be65f44 | ||
|
|
0e899c4c21 | ||
|
|
0b683ef3d5 | ||
|
|
560fa4aea5 | ||
|
|
f7ef931b3e | ||
|
|
43dfefaaeb | ||
|
|
cd9f32bb21 | ||
|
|
91565d476e | ||
|
|
45c15759ba | ||
|
|
f0a8e0636b | ||
|
|
a9c7c7a85e | ||
|
|
7aa9b62d6d | ||
|
|
5ed4f1c1e2 | ||
|
|
549e5c9efb | ||
|
|
a8e7fb0b18 | ||
|
|
3ca27bb65e | ||
|
|
76f52835e9 | ||
|
|
5019052792 | ||
|
|
9c1b5971c0 | ||
|
|
fb1d3f8423 | ||
|
|
141a274d74 | ||
|
|
163d973455 | ||
|
|
ba8e737810 | ||
|
|
330607d403 | ||
|
|
f7b806afed | ||
|
|
b3d2e7ee21 | ||
|
|
c7948e1fdf | ||
|
|
c5185f2afc | ||
|
|
f8990447b7 | ||
|
|
2aba31144b | ||
|
|
9bfd7f994c | ||
|
|
8168d4d74b | ||
|
|
6e512833fe | ||
|
|
561a9054c7 | ||
|
|
35a681c597 | ||
|
|
ae6d311460 | ||
|
|
190621966b | ||
|
|
4151cfc167 | ||
|
|
e1bc6673b0 | ||
|
|
ac90a17bd9 | ||
|
|
ee3f5662de | ||
|
|
289e86e03f | ||
|
|
08086b00c6 | ||
|
|
6382edcfba | ||
|
|
1cdc2c4a41 | ||
|
|
7153bedce0 | ||
|
|
ef0647cfb3 | ||
|
|
87c73c9b0b | ||
|
|
bb80c75647 | ||
|
|
ef88025b92 | ||
|
|
6c66ebc3d0 | ||
|
|
b53474661a | ||
|
|
3858d9f1d2 | ||
|
|
40a50cd8a3 | ||
|
|
25c7f9cf14 | ||
|
|
8b179c0029 | ||
|
|
4680b45026 | ||
|
|
ab9f33c772 | ||
|
|
bb3d0c333b | ||
|
|
0e70322562 | ||
|
|
35869a56f0 | ||
|
|
568e704d26 | ||
|
|
48190e0426 | ||
|
|
010e629f2a | ||
|
|
000c7605d4 | ||
|
|
6296f1abca | ||
|
|
ab5a2af67a | ||
|
|
9ce261428c | ||
|
|
debc0d099e | ||
|
|
d32f0d0e74 | ||
|
|
36221042b7 | ||
|
|
e1dba28fff | ||
|
|
205e16d25d | ||
|
|
62d5bcc0a2 | ||
|
|
1e4d5742ad | ||
|
|
100a72df96 | ||
|
|
6ddd6e01e8 | ||
|
|
0a23169da1 | ||
|
|
6d43889223 | ||
|
|
a90533fcf9 | ||
|
|
3b1be10e9b | ||
|
|
0b1e01dde9 | ||
|
|
48c48532e4 | ||
|
|
8109402ce2 | ||
|
|
d3da79bc90 | ||
|
|
85e770ece3 | ||
|
|
964f77dc54 | ||
|
|
6791df9ba0 | ||
|
|
537ef3792a | ||
|
|
691e5a8a52 | ||
|
|
f1192bc2b2 | ||
|
|
714f26be9e | ||
|
|
829becd370 | ||
|
|
79ab62a74d | ||
|
|
9053dd6306 | ||
|
|
71dd789c3e | ||
|
|
e2d9d56a9a | ||
|
|
98e3b2948b | ||
|
|
efd9416819 | ||
|
|
c8697c7f29 | ||
|
|
e76bb17958 | ||
|
|
a823d8849c | ||
|
|
8cad58b22e | ||
|
|
d2ceab3af4 | ||
|
|
d45e31520e | ||
|
|
dd5f375640 | ||
|
|
b31f5c2d37 | ||
|
|
a50f8ccfb5 | ||
|
|
e015a987ab | ||
|
|
f3b04472c9 | ||
|
|
4b9f10b4a5 | ||
|
|
7fec64d6c1 | ||
|
|
6a78b02f5a | ||
|
|
c7cef5350a | ||
|
|
5d50478fe3 | ||
|
|
921576042c | ||
|
|
ec957dc01e | ||
|
|
4f95e6cda7 | ||
|
|
860bc7bcf1 | ||
|
|
170c95a94c | ||
|
|
f84542b115 | ||
|
|
5be3e8006a | ||
|
|
bb381255bc | ||
|
|
70354a96e5 | ||
|
|
6df9405a98 | ||
|
|
83e2e3e47e | ||
|
|
137c77e4e0 | ||
|
|
31a354216f | ||
|
|
78469530f9 | ||
|
|
eeb66ca73f | ||
|
|
c9b3068adf | ||
|
|
a6fc17ee2f | ||
|
|
245424d215 | ||
|
|
50ce01bfad | ||
|
|
9f30879186 | ||
|
|
d897e82f62 | ||
|
|
643a959342 | ||
|
|
ba45668048 | ||
|
|
918a3b02f8 | ||
|
|
2b47d463e4 | ||
|
|
26d8984fe4 | ||
|
|
d5e6998b07 | ||
|
|
f18e2d5c99 | ||
|
|
ea129e7df1 | ||
|
|
dcfc74e8ae | ||
|
|
ed7af1bf11 | ||
|
|
7732ca60f4 | ||
|
|
0acc6fc926 | ||
|
|
a5a0e132ee | ||
|
|
a576fc8b68 | ||
|
|
9c0aeb6f90 | ||
|
|
208317322c | ||
|
|
d1e13cab4a | ||
|
|
ac90f43db0 | ||
|
|
fbe4b86cb0 | ||
|
|
d05bde1ef2 | ||
|
|
8ebf8327e2 | ||
|
|
bb39b757ec | ||
|
|
6b512cfa1a | ||
|
|
d982bdf1bd | ||
|
|
7b3f9ffde0 | ||
|
|
07b2f6c95a | ||
|
|
bae84976d6 | ||
|
|
1abaf9c161 | ||
|
|
870ea499f2 | ||
|
|
15514ff671 | ||
|
|
847c48ac78 | ||
|
|
07bc101e5e | ||
|
|
21ea6e584d | ||
|
|
199c1c83f9 | ||
|
|
ec664671b8 | ||
|
|
4268ce318a | ||
|
|
074eba2b1f | ||
|
|
a6b8c924de | ||
|
|
e4212676ae | ||
|
|
c52ef43b9c | ||
|
|
254fdfa16d | ||
|
|
fc9864ffcb | ||
|
|
3d91eb7df7 | ||
|
|
09563d839e | ||
|
|
93445341e1 | ||
|
|
e0c3ef3db3 | ||
|
|
674d5ecb2b | ||
|
|
2cf8c86bc5 | ||
|
|
1a0df67fed | ||
|
|
38a5516500 | ||
|
|
811e981eb4 | ||
|
|
579fd904fc | ||
|
|
21a74f377b | ||
|
|
634b8c14ad | ||
|
|
7164ba4aac | ||
|
|
a2aef1fe06 | ||
|
|
fce27c840f | ||
|
|
ae04b34925 | ||
|
|
4dc89cfd20 | ||
|
|
fbd671903f | ||
|
|
667680d479 | ||
|
|
fb124d533b | ||
|
|
30112dc805 | ||
|
|
8ed82e9173 | ||
|
|
891b257a50 | ||
|
|
0b8d20d07c | ||
|
|
b352f3ef30 | ||
|
|
95a2d5217b | ||
|
|
437fcc4335 | ||
|
|
e55af3f1fc | ||
|
|
0a3ca02876 | ||
|
|
b7aae3980d | ||
|
|
4ae6d71c28 | ||
|
|
1a6dc445db | ||
|
|
2f9f41b153 | ||
|
|
9818ec12f8 | ||
|
|
15de2e054c | ||
|
|
6b9e32e256 | ||
|
|
c3c009d5ea | ||
|
|
c426a99907 | ||
|
|
ef2da8b68a | ||
|
|
75f70aa3d6 | ||
|
|
a6ade067cc | ||
|
|
4c67210346 | ||
|
|
48e90dd276 | ||
|
|
daf7975fe0 | ||
|
|
8fbd18bf79 | ||
|
|
f1831deafa | ||
|
|
8cc237e7a3 | ||
|
|
21ad1cc78a | ||
|
|
d8c3a47352 | ||
|
|
fc1638e5c9 | ||
|
|
157e490815 | ||
|
|
c9efddb009 | ||
|
|
90de1963c9 | ||
|
|
97e7d1df85 | ||
|
|
5db82070e6 | ||
|
|
38c98515f9 | ||
|
|
1857129417 | ||
|
|
0c05f27771 |
63 changed files with 5224 additions and 3331 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
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
36
.github/workflows/test.yml
vendored
36
.github/workflows/test.yml
vendored
|
|
@ -1,32 +1,48 @@
|
|||
# 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']
|
||||
django-version: ['3.2', '4.0', '4.1']
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
django-version: ['4.2', '5.1', '5.2']
|
||||
exclude:
|
||||
# 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@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: 1.8.4
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
installer-parallel: true
|
||||
|
||||
- name: Set up cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
|
||||
|
|
@ -34,8 +50,8 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
poetry run pip install -U pip
|
||||
poetry run pip install -U "django==${{ matrix.django-version }}.*"
|
||||
poetry run pip install --upgrade pip
|
||||
poetry run pip install --upgrade "django==${{ matrix.django-version }}.*"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
|
|
@ -44,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.4.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
|
@ -9,9 +9,19 @@ repos:
|
|||
- id: check-added-large-files
|
||||
- id: mixed-line-ending
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.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
|
||||
hooks:
|
||||
- id: check-migrations-created
|
||||
args: [--manage-path=manage.py]
|
||||
additional_dependencies: [django==4.1]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ build:
|
|||
- asdf global poetry latest
|
||||
- poetry config virtualenvs.create false
|
||||
post_install:
|
||||
- poetry install -E docs --no-dev
|
||||
- . "$(pwd | rev | sed 's/stuokcehc/svne/' | rev)/bin/activate" && poetry install --only main --only docs
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
|
|
|
|||
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -2,12 +2,78 @@
|
|||
|
||||
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||
|
||||
## {{ Next Version }}
|
||||
## 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
|
||||
|
||||
- Corrects `BaseEntityAdmin` integration into Django Admin site
|
||||
- Split model modules by @iacobfred in https://github.com/jazzband/django-eav2/pull/467
|
||||
- Add Django 5.0 and Python 3.12 to the testing by @cclauss in https://github.com/jazzband/django-eav2/pull/487
|
||||
- Fix typos with codespell by @cclauss in https://github.com/jazzband/django-eav2/pull/489
|
||||
- Enhance BaseEntityAdmin by @Dresdn in https://github.com/jazzband/django-eav2/pull/541
|
||||
- Remove support for Django < 3.2 and Python < 3.8 by @Dresdn in https://github.com/jazzband/django-eav2/pull/542
|
||||
|
||||
## 1.5.0 (2023-11-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes querying with multiple eav kwargs [#395](https://github.com/jazzband/django-eav2/issues/395)
|
||||
|
||||
### Features
|
||||
|
||||
- Support for many type of primary key (UUIDField, BigAutoField)
|
||||
- Support for natural key use for some models for serialization (EnumValue, EnumGroup, Attribute, Value)
|
||||
- Add support for Django 4.2
|
||||
- Add support for Python 3.11
|
||||
|
||||
## 1.4.0 (2023-07-07)
|
||||
|
||||
### Features
|
||||
|
||||
- Support Bahasa Indonesia Translations
|
||||
- Support Django 4.2
|
||||
|
||||
## 1.3.1 (2023-02-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Generate missing migrations [#331](https://github.com/jazzband/django-eav2/issues/331)
|
||||
|
||||
## 1.3.0 (2023-02-10)
|
||||
|
||||
### Features
|
||||
|
|
@ -31,6 +97,7 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
|
|||
- Make Read the Docs dependencies all optional
|
||||
|
||||
## 1.2.2 (2022-08-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes AttributeError when using CSVFormField [#187](https://github.com/jazzband/django-eav2/issues/187)
|
||||
|
|
@ -38,6 +105,7 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
|
|||
- Migrates Attribute.slug to django.db.models.SlugField() [#223](https://github.com/jazzband/django-eav2/issues/223)
|
||||
|
||||
## 1.2.1 (2022-02-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes FieldError when filtering on foreign keys [#163](https://github.com/jazzband/django-eav2/issues/163)
|
||||
|
|
@ -68,7 +136,7 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
|
|||
|
||||
- Bumps min python version to `3.6.2`
|
||||
|
||||
**Full Changelog**: https://github.com/jazzband/django-eav2/compare/1.0.0...1.1.0
|
||||
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/1.0.0...1.1.0>
|
||||
|
||||
## 1.0.0 (2021-10-21)
|
||||
|
||||
|
|
@ -89,7 +157,7 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
|
|||
- Revamps all tooling, including moving to `poetry`, `pytest`, and `black`
|
||||
- Adds Github Actions and Dependabot
|
||||
|
||||
**Full Changelog**: https://github.com/jazzband/django-eav2/compare/0.14.0...1.0.0
|
||||
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.14.0...1.0.0>
|
||||
|
||||
## 0.14.0 (2021-04-23)
|
||||
|
||||
|
|
@ -98,6 +166,6 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
|
|||
- This release will be the last to support this range of Django versions: 1.11, 2.0, 2.1, 2.2, 3.0. SInce all of their extended support was ended by Django Project.
|
||||
- From the next release only will be supported 2.2 LTS, 3.1, and 3.2 LTS (eventually 4.x)
|
||||
|
||||
**Full Changelog**: https://github.com/jazzband/django-eav2/compare/0.13.0...0.14.0
|
||||
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.13.0...0.14.0>
|
||||
|
||||
(Anything before 0.14.0 was not recorded.)
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -28,7 +28,7 @@ You will find detailed description of the EAV here:
|
|||
|
||||
## EAV - The Good, the Bad or the Ugly?
|
||||
|
||||
EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](https://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintainance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV.
|
||||
EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](https://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintenance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV.
|
||||
|
||||
### When to use EAV?
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ As a rule of thumb, EAV can be used when:
|
|||
- Numerous classes of data need to be represented, each class has a limited number of attributes, but the number of instances of each class is very small.
|
||||
- We want to minimise programmer's input when changing the data model.
|
||||
|
||||
For more throughout discussion on the appriopriate use-cases see:
|
||||
For more throughout discussion on the appropriate use-cases see:
|
||||
|
||||
1. [Wikipedia - Scenarios that are appropriate for EAV modeling](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model#Scenarios_that_are_appropriate_for_EAV_modeling)
|
||||
2. [StackOverflow - Entity Attribute Value Database vs. strict Relational Model E-commerce](https://stackoverflow.com/questions/870808/entity-attribute-value-database-vs-strict-relational-model-ecommerce)
|
||||
|
|
@ -83,6 +83,7 @@ However, it is important to note that:
|
|||
|
||||
In some use-cases, JSONB (binary JSON data) datatype (Postgres 9.4+ and analogous in other RDMSs) can be used as an alternative to EAV. JSONB supports indexing, which amortizes performance trade-off. It's important to keep in mind that JSONB is not RDMS-agnostic solution and has it's own problems, such as typing.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Install with pip
|
||||
|
|
@ -102,6 +103,44 @@ INSTALLED_APPS = [
|
|||
]
|
||||
```
|
||||
|
||||
Add `django.db.models.UUIDField` or `django.db.models.BigAutoField` as value of `EAV2_PRIMARY_KEY_FIELD` in your settings
|
||||
|
||||
``` python
|
||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" # as example
|
||||
```
|
||||
|
||||
### Note: Primary key mandatory modification field
|
||||
|
||||
If the primary key of eav models are to be modified (UUIDField -> BigAutoField, BigAutoField -> UUIDField) in the middle of the project when the migrations are already done, you have to change the value of `EAV2_PRIMARY_KEY_FIELD` in your settings.
|
||||
|
||||
##### Step 1
|
||||
Change the value of `EAV2_PRIMARY_KEY_FIELD` into `django.db.models.CharField` in your settings.
|
||||
|
||||
```python
|
||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField"
|
||||
```
|
||||
|
||||
Run the migrations
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
##### Step 2
|
||||
Change the value of `EAV2_PRIMARY_KEY_FIELD` into the desired value (`django.db.models.BigAutoField` or `django.db.models.UUIDField`) in your settings.
|
||||
|
||||
```python
|
||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.BigAutoField" # as example
|
||||
```
|
||||
|
||||
Run again the migrations.
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### Note: Django 2.2 Users
|
||||
|
||||
Since `models.JSONField()` isn't supported in Django 2.2, we use [django-jsonfield-backport](https://github.com/laymonage/django-jsonfield-backport) to provide [JSONField](https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends) functionality.
|
||||
|
|
|
|||
|
|
@ -2,30 +2,36 @@
|
|||
#
|
||||
# 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",
|
||||
)
|
||||
|
||||
# Call django.setup to load installed apps and other stuff.
|
||||
|
|
@ -33,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,
|
||||
),
|
||||
)
|
||||
|
|
@ -58,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"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -119,8 +125,8 @@ latex_documents = [
|
|||
man_pages = [
|
||||
(
|
||||
master_doc,
|
||||
'djangoeav2',
|
||||
'Django EAV 2 Documentation',
|
||||
"djangoeav2",
|
||||
"Django EAV 2 Documentation",
|
||||
[author],
|
||||
1,
|
||||
),
|
||||
|
|
@ -135,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",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -149,7 +155,7 @@ texinfo_documents = [
|
|||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||
|
||||
# -- Autodoc configuration ---------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ or with decorators:
|
|||
class Supplier(models.Model):
|
||||
...
|
||||
|
||||
Generally, if you chose the former, the most appriopriate place for the
|
||||
statement would be at the bottom of your ``models.py`` or immmediately after
|
||||
Generally, if you chose the former, the most appropriate place for the
|
||||
statement would be at the bottom of your ``models.py`` or immediately after
|
||||
model definition.
|
||||
|
||||
Advanced Registration
|
||||
|
|
@ -286,8 +286,10 @@ You can use ``Q`` expressions too:
|
|||
Admin Integration
|
||||
-----------------
|
||||
|
||||
Django EAV 2 includes integration for Django's admin. As usual, you need to
|
||||
register your model first:
|
||||
Django EAV 2 seamlessly integrates with Django's admin interface by providing
|
||||
dynamic attribute management directly within the admin panel. This feature
|
||||
provides the EAV Attributes as a separate fieldset, whether use the base
|
||||
fieldset or when providing your own.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
|
|
@ -302,3 +304,11 @@ register your model first:
|
|||
form = PatientAdminForm
|
||||
|
||||
admin.site.register(Patient, PatientAdmin)
|
||||
|
||||
Customizing the EAV Fieldset
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The Django EAV 2 integration allows you to customize the presentation of EAV
|
||||
attributes in the admin interface through the use of a dedicated fieldset. You
|
||||
can configure this fieldset by setting ``eav_fieldset_title`` and
|
||||
``eav_fieldset_description`` within your admin class.
|
||||
|
|
|
|||
124
eav/admin.py
124
eav/admin.py
|
|
@ -1,5 +1,9 @@
|
|||
"""This module contains classes used for admin integration."""
|
||||
|
||||
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
|
||||
from django.forms.models import BaseInlineFormSet
|
||||
|
|
@ -7,30 +11,106 @@ 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.
|
||||
|
||||
Overrides the default rendering of the change form in the Django admin to
|
||||
dynamically integrate EAV fields into the form fieldsets. This approach
|
||||
allows EAV attributes to be rendered alongside standard model fields within
|
||||
the admin interface.
|
||||
|
||||
Attributes:
|
||||
eav_fieldset_title (str): Title for the dynamically added EAV fieldset.
|
||||
eav_fieldset_description (str): Optional description for the EAV fieldset.
|
||||
"""
|
||||
|
||||
eav_fieldset_title: str = "EAV Attributes"
|
||||
eav_fieldset_description: str | None = None
|
||||
|
||||
def render_change_form(self, request, context, *args, **kwargs):
|
||||
"""
|
||||
Wrapper for ``ModelAdmin.render_change_form``. Replaces standard static
|
||||
``AdminForm`` with an EAV-friendly one. The point is that our form
|
||||
generates fields dynamically and fieldsets must be inferred from a
|
||||
prepared and validated form instance, not just the form class. Django
|
||||
does not seem to provide hooks for this purpose, so we simply wrap the
|
||||
view and substitute some data.
|
||||
"""
|
||||
form = context['adminform'].form
|
||||
media = context["media"]
|
||||
"""Dynamically modifies the admin form to include EAV fields.
|
||||
|
||||
# Infer correct data from the form.
|
||||
fieldsets = self.fieldsets or [(None, {'fields': form.fields.keys()})]
|
||||
adminform = admin.helpers.AdminForm(form, fieldsets, self.prepopulated_fields)
|
||||
media = mark_safe(media + adminform.media)
|
||||
Identifies EAV fields associated with the instance being edited and
|
||||
dynamically inserts them into the admin form's fieldsets. This method
|
||||
ensures EAV fields are appropriately displayed in a dedicated fieldset
|
||||
and avoids field duplication.
|
||||
|
||||
Args:
|
||||
request: HttpRequest object representing the current request.
|
||||
context: Dictionary containing context data for the form template.
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
|
||||
Returns:
|
||||
HttpResponse object representing the rendered change form.
|
||||
"""
|
||||
form = context["adminform"].form
|
||||
|
||||
# Identify EAV fields based on the form instance's configuration.
|
||||
eav_fields = self._get_eav_fields(form.instance)
|
||||
|
||||
# # Fallback to default if no EAV fields exist
|
||||
if not eav_fields:
|
||||
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.append(self._get_eav_fieldset(eav_fields))
|
||||
|
||||
# Reconstruct the admin form with updated fieldsets.
|
||||
adminform = admin.helpers.AdminForm(
|
||||
form,
|
||||
fieldsets,
|
||||
# Clear prepopulated fields on a view-only form to avoid a crash.
|
||||
(
|
||||
self.prepopulated_fields
|
||||
if self.has_change_permission(request, kwargs["obj"])
|
||||
else {}
|
||||
),
|
||||
readonly_fields=self.readonly_fields,
|
||||
model_admin=self,
|
||||
)
|
||||
media = mark_safe(context["media"] + adminform.media) # noqa: S308
|
||||
context.update(adminform=adminform, media=media)
|
||||
|
||||
return super(BaseEntityAdmin, self).render_change_form(
|
||||
request, context, *args, **kwargs
|
||||
)
|
||||
return super().render_change_form(request, context, *args, **kwargs)
|
||||
|
||||
def _get_eav_fields(self, instance) -> list[str]:
|
||||
"""Retrieves a list of EAV field slugs for the given instance.
|
||||
|
||||
Args:
|
||||
instance: The model instance for which EAV fields are determined.
|
||||
|
||||
Returns:
|
||||
A list of strings representing the slugs of EAV fields.
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
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},
|
||||
]
|
||||
|
||||
|
||||
class BaseEntityInlineFormSet(BaseInlineFormSet):
|
||||
|
|
@ -41,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):
|
||||
|
|
@ -74,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,19 +16,24 @@ 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
|
||||
|
||||
# added
|
||||
if not type(instance).objects.filter(pk=instance.pk).exists():
|
||||
return
|
||||
|
||||
if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype:
|
||||
return
|
||||
|
||||
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.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,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)
|
||||
|
|
@ -69,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)
|
||||
|
|
|
|||
79
eav/forms.py
79
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
|
||||
|
|
@ -11,6 +14,7 @@ from django.forms import (
|
|||
Field,
|
||||
FloatField,
|
||||
IntegerField,
|
||||
JSONField,
|
||||
ModelForm,
|
||||
SplitDateTimeField,
|
||||
)
|
||||
|
|
@ -18,21 +22,16 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from eav.widgets import CSVWidget
|
||||
|
||||
try:
|
||||
from django.forms import JSONField
|
||||
except:
|
||||
JSONField = CharField
|
||||
|
||||
|
||||
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):
|
||||
|
|
@ -42,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)
|
||||
|
||||
|
||||
|
|
@ -74,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()
|
||||
|
||||
|
|
@ -99,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``.
|
||||
|
|
@ -135,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)
|
||||
|
||||
|
|
|
|||
278
eav/locale/id/LC_MESSAGES/django.po
Normal file
278
eav/locale/id/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Indonesian translation for django-eav2
|
||||
# Copyright (C) 2023
|
||||
# This file is distributed under the same license as the django-eav2 package.
|
||||
# Kira <kiraware@github.com>, 2023.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-eav2 1.3.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-06-29 16:43+0800\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Kira <kiraware@github.com>, 2023\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: id\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
#: .\eav\fields.py:30
|
||||
msgid "You cannot change the datatype of an attribute that is already in use."
|
||||
msgstr "Anda tidak dapat mengubah tipe data atribut yang sudah digunakan."
|
||||
|
||||
#: .\eav\fields.py:36
|
||||
msgid "A Comma-Separated-Value field."
|
||||
msgstr "Bidang Nilai-yang-Dipisahkan-Koma."
|
||||
|
||||
#: .\eav\forms.py:28
|
||||
msgid "Enter comma-separated-values. eg: one;two;three."
|
||||
msgstr "Masukkan nilai-yang-dipisahkan-koma. misalnya: satu;dua;tiga."
|
||||
|
||||
#: .\eav\forms.py:138
|
||||
#, python-format
|
||||
msgid "The %s could not be saved because the datadidn't validate."
|
||||
msgstr "%s tidak dapat disimpan karena datanya tidak tervalidasi."
|
||||
|
||||
#: .\eav\models.py:77
|
||||
msgid "EnumValue"
|
||||
msgstr "EnumValue"
|
||||
|
||||
#: .\eav\models.py:78
|
||||
msgid "EnumValues"
|
||||
msgstr "EnumValues"
|
||||
|
||||
#: .\eav\models.py:81 .\eav\models.py:439
|
||||
msgid "Value"
|
||||
msgstr "Nilai"
|
||||
|
||||
#: .\eav\models.py:106
|
||||
msgid "EnumGroup"
|
||||
msgstr "EnumGroup"
|
||||
|
||||
#: .\eav\models.py:107
|
||||
msgid "EnumGroups"
|
||||
msgstr "EnumGroups"
|
||||
|
||||
#: .\eav\models.py:112 .\eav\models.py:218
|
||||
msgid "Name"
|
||||
msgstr "Nama"
|
||||
|
||||
#: .\eav\models.py:116
|
||||
msgid "Enum group"
|
||||
msgstr "Grup enum"
|
||||
|
||||
#: .\eav\models.py:182 .\eav\models.py:447
|
||||
msgid "Attribute"
|
||||
msgstr "Atribut"
|
||||
|
||||
#: .\eav\models.py:183
|
||||
msgid "Attributes"
|
||||
msgstr "Atribut"
|
||||
|
||||
#: .\eav\models.py:196
|
||||
msgid "Text"
|
||||
msgstr "Teks"
|
||||
|
||||
#: .\eav\models.py:197
|
||||
msgid "Date"
|
||||
msgstr "Tanggal"
|
||||
|
||||
#: .\eav\models.py:198
|
||||
msgid "Float"
|
||||
msgstr "Bilangan desimal"
|
||||
|
||||
#: .\eav\models.py:199
|
||||
msgid "Integer"
|
||||
msgstr "Bilangan bulat"
|
||||
|
||||
#: .\eav\models.py:200
|
||||
msgid "True / False"
|
||||
msgstr "Benar / Salah"
|
||||
|
||||
#: .\eav\models.py:201
|
||||
msgid "Django Object"
|
||||
msgstr "Objek Django"
|
||||
|
||||
#: .\eav\models.py:202
|
||||
msgid "Multiple Choice"
|
||||
msgstr "Pilihan Ganda"
|
||||
|
||||
#: .\eav\models.py:203
|
||||
msgid "JSON Object"
|
||||
msgstr "Objek JSON"
|
||||
|
||||
#: .\eav\models.py:204
|
||||
msgid "Comma-Separated-Value"
|
||||
msgstr "Nilai-yang-Dipisahkan-Koma"
|
||||
|
||||
#: .\eav\models.py:212
|
||||
msgid "Data Type"
|
||||
msgstr "Tipe Data"
|
||||
|
||||
#: .\eav\models.py:217
|
||||
msgid "User-friendly attribute name"
|
||||
msgstr "Nama atribut yang ramah pengguna"
|
||||
|
||||
#: .\eav\models.py:230
|
||||
msgid "Short unique attribute label"
|
||||
msgstr "Label atribut unik yang pendek"
|
||||
|
||||
#: .\eav\models.py:231
|
||||
msgid "Slug"
|
||||
msgstr "Slug"
|
||||
|
||||
#: .\eav\models.py:242
|
||||
msgid "Required"
|
||||
msgstr "Diperlukan"
|
||||
|
||||
#: .\eav\models.py:248
|
||||
msgid "Entity content type"
|
||||
msgstr "Jenis konten entitas"
|
||||
|
||||
#: .\eav\models.py:262
|
||||
msgid "Choice Group"
|
||||
msgstr "Grup Pilihan"
|
||||
|
||||
#: .\eav\models.py:269
|
||||
msgid "Short description"
|
||||
msgstr "Deskripsi singkat"
|
||||
|
||||
#: .\eav\models.py:270
|
||||
msgid "Description"
|
||||
msgstr "Deskripsi"
|
||||
|
||||
#: .\eav\models.py:277
|
||||
msgid "Display order"
|
||||
msgstr "Urutan tampilan"
|
||||
|
||||
#: .\eav\models.py:282 .\eav\models.py:490
|
||||
msgid "Modified"
|
||||
msgstr "Dimodifikasi"
|
||||
|
||||
#: .\eav\models.py:288 .\eav\models.py:485
|
||||
msgid "Created"
|
||||
msgstr "Dibuat"
|
||||
|
||||
#: .\eav\models.py:332
|
||||
#, python-format
|
||||
msgid "%(val)s is not a valid choice for %(attr)s"
|
||||
msgstr "%(val)s bukan pilihan yang valid untuk %(attr)s"
|
||||
|
||||
#: .\eav\models.py:355
|
||||
msgid "You must set the choice group for multiple choice attributes"
|
||||
msgstr "Anda harus mengatur grup pilihan untuk atribut pilihan ganda"
|
||||
|
||||
#: .\eav\models.py:360
|
||||
msgid "You can only assign a choice group to multiple choice attributes"
|
||||
msgstr "Anda hanya dapat menetapkan grup pilihan ke atribut pilihan ganda"
|
||||
|
||||
#: .\eav\models.py:440
|
||||
msgid "Values"
|
||||
msgstr "Nilai"
|
||||
|
||||
#: .\eav\models.py:456
|
||||
msgid "Entity id"
|
||||
msgstr "id entitas"
|
||||
|
||||
#: .\eav\models.py:462
|
||||
msgid "Entity uuid"
|
||||
msgstr "uuid entitas"
|
||||
|
||||
#: .\eav\models.py:469
|
||||
msgid "Entity ct"
|
||||
msgstr "Entitas ct"
|
||||
|
||||
#: .\eav\models.py:497
|
||||
msgid "Value bool"
|
||||
msgstr "Nilai bool"
|
||||
|
||||
#: .\eav\models.py:502
|
||||
msgid "Value CSV"
|
||||
msgstr "Nilai CSV"
|
||||
|
||||
#: .\eav\models.py:507
|
||||
msgid "Value date"
|
||||
msgstr "Nilai tanggal"
|
||||
|
||||
#: .\eav\models.py:512
|
||||
msgid "Value float"
|
||||
msgstr "Nilai float"
|
||||
|
||||
#: .\eav\models.py:517
|
||||
msgid "Value int"
|
||||
msgstr "Nilai int"
|
||||
|
||||
#: .\eav\models.py:522
|
||||
msgid "Value text"
|
||||
msgstr "Nilai teks"
|
||||
|
||||
#: .\eav\models.py:530
|
||||
msgid "Value JSON"
|
||||
msgstr "Nilai JSON"
|
||||
|
||||
#: .\eav\models.py:539
|
||||
msgid "Value enum"
|
||||
msgstr "Nilai enum"
|
||||
|
||||
#: .\eav\models.py:546
|
||||
msgid "Generic value id"
|
||||
msgstr "Id nilai generik"
|
||||
|
||||
#: .\eav\models.py:555
|
||||
msgid "Generic value content type"
|
||||
msgstr "Jenis konten nilai generik"
|
||||
|
||||
#: .\eav\models.py:653
|
||||
#, python-format
|
||||
msgid "%(obj)s has no EAV attribute named %(attr)s"
|
||||
msgstr "%(obj)s tidak memiliki atribut EAV bernama %(attr)s"
|
||||
|
||||
#: .\eav\models.py:725
|
||||
msgid "{} EAV field cannot be blank"
|
||||
msgstr "{} Bidang EAV tidak boleh kosong"
|
||||
|
||||
#: .\eav\models.py:732
|
||||
#, python-format
|
||||
msgid "%(attr)s EAV field %(err)s"
|
||||
msgstr "%(attr)s bidang EAV %(err)s"
|
||||
|
||||
#: .\eav\validators.py:26
|
||||
msgid "Must be str or unicode"
|
||||
msgstr "Harus berupa str atau unicode"
|
||||
|
||||
#: .\eav\validators.py:36
|
||||
msgid "Must be a float"
|
||||
msgstr "Harus berupa float"
|
||||
|
||||
#: .\eav\validators.py:46
|
||||
msgid "Must be an integer"
|
||||
msgstr "Harus berupa integer"
|
||||
|
||||
#: .\eav\validators.py:57
|
||||
msgid "Must be a date or datetime"
|
||||
msgstr "Harus berupa date atau datetime"
|
||||
|
||||
#: .\eav\validators.py:65
|
||||
msgid "Must be a boolean"
|
||||
msgstr "Harus berupa boolean"
|
||||
|
||||
#: .\eav\validators.py:74
|
||||
msgid "Must be a django model object instance"
|
||||
msgstr "Harus berupa instance objek model django"
|
||||
|
||||
#: .\eav\validators.py:77
|
||||
msgid "Model has not been saved yet"
|
||||
msgstr "Model belum disimpan"
|
||||
|
||||
#: .\eav\validators.py:88
|
||||
msgid "EnumValue has not been saved yet"
|
||||
msgstr "EnumValue belum disimpan"
|
||||
|
||||
#: .\eav\validators.py:99 .\eav\validators.py:101
|
||||
msgid "Must be a JSON Serializable object"
|
||||
msgstr "Harus berupa objek JSON yang dapat diserialisasikan"
|
||||
|
||||
#: .\eav\validators.py:111
|
||||
msgid "Must be Comma-Separated-Value."
|
||||
msgstr "Harus berupa Nilai-ang-Dipisahkan-Koma."
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
97
eav/logic/managers.py
Normal file
97
eav/logic/managers.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class EnumValueManager(models.Manager):
|
||||
"""
|
||||
Custom manager for `EnumValue` model.
|
||||
|
||||
This manager adds utility methods specific to the `EnumValue` model.
|
||||
"""
|
||||
|
||||
def get_by_natural_key(self, value):
|
||||
"""
|
||||
Retrieves an EnumValue instance using its `value` as a natural key.
|
||||
|
||||
Args:
|
||||
value (str): The value of the EnumValue instance.
|
||||
|
||||
Returns:
|
||||
EnumValue: The instance matching the provided value.
|
||||
"""
|
||||
return self.get(value=value)
|
||||
|
||||
|
||||
class EnumGroupManager(models.Manager):
|
||||
"""
|
||||
Custom manager for `EnumGroup` model.
|
||||
|
||||
This manager adds utility methods specific to the `EnumGroup` model.
|
||||
"""
|
||||
|
||||
def get_by_natural_key(self, name):
|
||||
"""
|
||||
Retrieves an EnumGroup instance using its `name` as a natural key.
|
||||
|
||||
Args:
|
||||
name (str): The name of the EnumGroup instance.
|
||||
|
||||
Returns:
|
||||
EnumGroup: The instance matching the provided name.
|
||||
"""
|
||||
return self.get(name=name)
|
||||
|
||||
|
||||
class AttributeManager(models.Manager):
|
||||
"""
|
||||
Custom manager for `Attribute` model.
|
||||
|
||||
This manager adds utility methods specific to the `Attribute` model.
|
||||
"""
|
||||
|
||||
def get_by_natural_key(self, name, slug):
|
||||
"""
|
||||
Retrieves an Attribute instance using its `name` and `slug` as natural keys.
|
||||
|
||||
Args:
|
||||
name (str): The name of the Attribute instance.
|
||||
slug (str): The slug of the Attribute instance.
|
||||
|
||||
Returns:
|
||||
Attribute: The instance matching the provided name and slug.
|
||||
"""
|
||||
return self.get(name=name, slug=slug)
|
||||
|
||||
|
||||
class ValueManager(models.Manager):
|
||||
"""
|
||||
Custom manager for `Value` model.
|
||||
|
||||
This manager adds utility methods specific to the `Value` model.
|
||||
"""
|
||||
|
||||
def get_by_natural_key(self, attribute, entity_id, entity_uuid):
|
||||
"""
|
||||
Retrieve a Value instance using multiple natural keys.
|
||||
|
||||
This method utilizes a combination of an `attribute` (defined by its
|
||||
name and slug), `entity_id`, and `entity_uuid` to retrieve a unique
|
||||
Value instance.
|
||||
|
||||
Args:
|
||||
attribute (tuple): A tuple containing the name and slug of the
|
||||
Attribute instance.
|
||||
entity_id (int): The ID of the associated entity.
|
||||
entity_uuid (str): The UUID of the associated entity.
|
||||
|
||||
Returns:
|
||||
Value: The instance matching the provided keys.
|
||||
"""
|
||||
from eav.models import Attribute
|
||||
|
||||
attribute = Attribute.objects.get(name=attribute[0], slug=attribute[1])
|
||||
|
||||
return self.get(
|
||||
attribute=attribute,
|
||||
entity_id=entity_id,
|
||||
entity_uuid=entity_uuid,
|
||||
)
|
||||
44
eav/logic/object_pk.py
Normal file
44
eav/logic/object_pk.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import uuid
|
||||
from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
#: Constants
|
||||
_DEFAULT_CHARFIELD_LEN: int = 40
|
||||
|
||||
_FIELD_MAPPING = {
|
||||
"django.db.models.UUIDField": partial(
|
||||
models.UUIDField,
|
||||
primary_key=True,
|
||||
editable=False,
|
||||
default=uuid.uuid4,
|
||||
),
|
||||
"django.db.models.CharField": partial(
|
||||
models.CharField,
|
||||
primary_key=True,
|
||||
editable=False,
|
||||
max_length=_DEFAULT_CHARFIELD_LEN,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_pk_format() -> models.Field:
|
||||
"""
|
||||
Get the primary key field format based on the Django settings.
|
||||
|
||||
This function returns a field factory function that corresponds to the
|
||||
primary key format specified in Django settings. If the primary key
|
||||
format is not recognized, it defaults to using BigAutoField.
|
||||
|
||||
Returns:
|
||||
Type[models.Field]: A field factory function that can be used to
|
||||
create the primary key field instance.
|
||||
"""
|
||||
field_factory = _FIELD_MAPPING.get(
|
||||
settings.EAV2_PRIMARY_KEY_FIELD,
|
||||
partial(models.BigAutoField, primary_key=True, editable=False),
|
||||
)
|
||||
|
||||
# Create and return the field instance
|
||||
return field_factory()
|
||||
|
|
@ -1,26 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from typing import Final
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
try:
|
||||
from typing import Final
|
||||
except ImportError:
|
||||
from typing_extensions import Final
|
||||
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
178
eav/migrations/0009_enchance_naming.py
Normal file
178
eav/migrations/0009_enchance_naming.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import migrations, models
|
||||
|
||||
from eav.fields import CSVField
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Define verbose naming for models and fields."""
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("eav", "0008_use_native_slugfield"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="attribute",
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
"verbose_name": "Attribute",
|
||||
"verbose_name_plural": "Attributes",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="enumgroup",
|
||||
options={
|
||||
"verbose_name": "EnumGroup",
|
||||
"verbose_name_plural": "EnumGroups",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="enumvalue",
|
||||
options={
|
||||
"verbose_name": "EnumValue",
|
||||
"verbose_name_plural": "EnumValues",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="value",
|
||||
options={"verbose_name": "Value", "verbose_name_plural": "Values"},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attribute",
|
||||
name="entity_ct",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Entity content type",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
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",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="entity_id",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Entity id",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="entity_uuid",
|
||||
field=models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Entity uuid",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
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",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="generic_value_id",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Generic value id",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_bool",
|
||||
field=models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Value bool",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_csv",
|
||||
field=CSVField(
|
||||
blank=True,
|
||||
default="",
|
||||
null=True,
|
||||
verbose_name="Value CSV",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_date",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Value date",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
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",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_float",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Value float",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_int",
|
||||
field=models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Value int",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_json",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
null=True,
|
||||
verbose_name="Value JSON",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="value_text",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Value text",
|
||||
),
|
||||
),
|
||||
]
|
||||
48
eav/migrations/0010_dynamic_pk_type_for_models.py
Normal file
48
eav/migrations/0010_dynamic_pk_type_for_models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Migration to use BigAutoField as default for all models."""
|
||||
|
||||
dependencies = [
|
||||
("eav", "0009_enchance_naming"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="attribute",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="enumgroup",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="enumvalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="value",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
797
eav/models.py
797
eav/models.py
|
|
@ -1,797 +0,0 @@
|
|||
"""
|
||||
This module defines the four concrete, non-abstract models:
|
||||
* :class:`Value`
|
||||
* :class:`Attribute`
|
||||
* :class:`EnumValue`
|
||||
* :class:`EnumGroup`
|
||||
|
||||
Along with the :class:`Entity` helper class and :class:`EAVModelMeta`
|
||||
optional metaclass for each eav model class.
|
||||
"""
|
||||
|
||||
from copy import copy
|
||||
|
||||
from django.contrib.contenttypes import fields as generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.db.models.base import ModelBase
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav import register
|
||||
from eav.exceptions import IllegalAssignmentException
|
||||
from eav.fields import CSVField, EavDatatypeField
|
||||
from eav.logic.entity_pk import get_entity_pk_type
|
||||
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
|
||||
from eav.validators import (
|
||||
validate_bool,
|
||||
validate_csv,
|
||||
validate_date,
|
||||
validate_enum,
|
||||
validate_float,
|
||||
validate_int,
|
||||
validate_json,
|
||||
validate_object,
|
||||
validate_text,
|
||||
)
|
||||
|
||||
try:
|
||||
from typing import Final
|
||||
except ImportError:
|
||||
from typing_extensions import Final
|
||||
|
||||
|
||||
CHARFIELD_LENGTH: Final = 100
|
||||
|
||||
|
||||
class EnumValue(models.Model):
|
||||
"""
|
||||
*EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM*
|
||||
:class:`Attribute` objects. They have only one field, *value*, a
|
||||
``CharField`` that must be unique.
|
||||
|
||||
For example::
|
||||
|
||||
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
|
||||
no = EnumValue.objects.create(value='No')
|
||||
unknown = EnumValue.objects.create(value='Unknown')
|
||||
|
||||
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: has fever? (Multiple Choice)>
|
||||
|
||||
.. note::
|
||||
The same *EnumValue* objects should be reused within multiple
|
||||
*EnumGroups*. For example, if you have one *EnumGroup* called: *Yes /
|
||||
No / Unknown* and another called *Yes / No / Not applicable*, you should
|
||||
only have a total of four *EnumValues* objects, as you should have used
|
||||
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('EnumValue')
|
||||
verbose_name_plural = _('EnumValues')
|
||||
|
||||
value = models.CharField(
|
||||
_('Value'),
|
||||
db_index=True,
|
||||
unique=True,
|
||||
max_length=SLUGFIELD_MAX_LENGTH,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of `EnumValue` instance."""
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of `EnumValue` object."""
|
||||
return '<EnumValue {0}>'.format(self.value)
|
||||
|
||||
|
||||
class EnumGroup(models.Model):
|
||||
"""
|
||||
*EnumGroup* objects have two fields - a *name* ``CharField`` and *values*,
|
||||
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
|
||||
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
|
||||
|
||||
See :class:`EnumValue` for an example.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('EnumGroup')
|
||||
verbose_name_plural = _('EnumGroups')
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
verbose_name=_('Name'),
|
||||
)
|
||||
values = models.ManyToManyField(
|
||||
EnumValue,
|
||||
verbose_name=_('Enum group'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of `EnumGroup` instance."""
|
||||
return str(self.name)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of `EnumGroup` object."""
|
||||
return '<EnumGroup {0}>'.format(self.name)
|
||||
|
||||
|
||||
class Attribute(models.Model):
|
||||
"""
|
||||
Putting the **A** in *EAV*. This holds the attributes, or concepts.
|
||||
Examples of possible *Attributes*: color, height, weight, number of
|
||||
children, number of patients, has fever?, etc...
|
||||
|
||||
Each attribute has a name, and a description, along with a slug that must
|
||||
be unique. If you don't provide a slug, a default slug (derived from
|
||||
name), will be created.
|
||||
|
||||
The *required* field is a boolean that indicates whether this EAV attribute
|
||||
is required for entities to which it applies. It defaults to *False*.
|
||||
|
||||
.. warning::
|
||||
Just like a normal model field that is required, you will not be able
|
||||
to save or create any entity object for which this attribute applies,
|
||||
without first setting this EAV attribute.
|
||||
|
||||
There are 7 possible values for datatype:
|
||||
|
||||
* int (TYPE_INT)
|
||||
* float (TYPE_FLOAT)
|
||||
* text (TYPE_TEXT)
|
||||
* date (TYPE_DATE)
|
||||
* bool (TYPE_BOOLEAN)
|
||||
* object (TYPE_OBJECT)
|
||||
* enum (TYPE_ENUM)
|
||||
* json (TYPE_JSON)
|
||||
* csv (TYPE_CSV)
|
||||
|
||||
|
||||
Examples::
|
||||
|
||||
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
|
||||
# = <Attribute: Height (Integer)>
|
||||
|
||||
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
|
||||
# = <Attribute: Color (Text)>
|
||||
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
unknown = EnumValue.objects.create(value='unknown')
|
||||
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: has fever? (Multiple Choice)>
|
||||
|
||||
.. warning:: Once an Attribute has been used by an entity, you can not
|
||||
change it's datatype.
|
||||
"""
|
||||
|
||||
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'
|
||||
|
||||
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')),
|
||||
)
|
||||
|
||||
# Core attributes
|
||||
|
||||
datatype = EavDatatypeField(
|
||||
choices=DATATYPE_CHOICES,
|
||||
max_length=6,
|
||||
verbose_name=_('Data Type'),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
help_text=_('User-friendly attribute name'),
|
||||
verbose_name=_('Name'),
|
||||
)
|
||||
|
||||
"""
|
||||
Main identifer for the attribute.
|
||||
Upon creation, slug is autogenerated from the name.
|
||||
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
|
||||
"""
|
||||
slug = models.SlugField(
|
||||
max_length=SLUGFIELD_MAX_LENGTH,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
help_text=_('Short unique attribute label'),
|
||||
verbose_name=_('Slug'),
|
||||
)
|
||||
|
||||
"""
|
||||
.. warning::
|
||||
This attribute should be used with caution. Setting this to *True*
|
||||
means that *all* entities that *can* have this attribute will
|
||||
be required to have a value for it.
|
||||
"""
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Required'),
|
||||
)
|
||||
|
||||
entity_ct = models.ManyToManyField(
|
||||
ContentType,
|
||||
blank=True,
|
||||
verbose_name=_('Entity content type'),
|
||||
)
|
||||
"""
|
||||
This field allows you to specify a relationship with any number of content types.
|
||||
This would be useful, for example, if you wanted an attribute to apply only to
|
||||
a subset of entities. In that case, you could filter by content type in the
|
||||
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
||||
"""
|
||||
|
||||
enum_group = models.ForeignKey(
|
||||
EnumGroup,
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Choice Group'),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=256,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Short description'),
|
||||
verbose_name=_('Description'),
|
||||
)
|
||||
|
||||
# Useful meta-information
|
||||
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Display order'),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Modified'),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
editable=False,
|
||||
verbose_name=_('Created'),
|
||||
)
|
||||
|
||||
@property
|
||||
def help_text(self):
|
||||
return self.description
|
||||
|
||||
def get_validators(self):
|
||||
"""
|
||||
Returns the appropriate validator function from :mod:`~eav.validators`
|
||||
as a list (of length one) for the datatype.
|
||||
|
||||
.. note::
|
||||
The reason it returns it as a list, is eventually we may want this
|
||||
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,
|
||||
}
|
||||
|
||||
return [DATATYPE_VALIDATORS[self.datatype]]
|
||||
|
||||
def validate_value(self, value):
|
||||
"""
|
||||
Check *value* against the validators returned by
|
||||
:meth:`get_validators` for this attribute.
|
||||
"""
|
||||
for validator in self.get_validators():
|
||||
validator(value)
|
||||
|
||||
if self.datatype == self.TYPE_ENUM:
|
||||
if isinstance(value, EnumValue):
|
||||
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')
|
||||
% dict(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(Attribute, self).save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validates the attribute. Will raise ``ValidationError`` if the
|
||||
attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if
|
||||
the attribute is not *TYPE_ENUM* and the enum group is set.
|
||||
"""
|
||||
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
||||
raise ValidationError(
|
||||
_('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')
|
||||
)
|
||||
|
||||
def get_choices(self):
|
||||
"""
|
||||
Returns a query set of :class:`EnumValue` objects for this attribute.
|
||||
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
|
||||
"""
|
||||
return (
|
||||
self.enum_group.values.all()
|
||||
if self.datatype == Attribute.TYPE_ENUM
|
||||
else None
|
||||
)
|
||||
|
||||
def save_value(self, entity, value):
|
||||
"""
|
||||
Called with *entity*, any Django object registered with eav, and
|
||||
*value*, the :class:`Value` this attribute for *entity* should
|
||||
be set to.
|
||||
|
||||
If a :class:`Value` object for this *entity* and attribute doesn't
|
||||
exist, one will be created.
|
||||
|
||||
.. note::
|
||||
If *value* is None and a :class:`Value` object exists for this
|
||||
Attribute and *entity*, it will delete that :class:`Value` object.
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(entity)
|
||||
|
||||
entity_filter = {
|
||||
'entity_ct': ct,
|
||||
'attribute': self,
|
||||
'{0}'.format(get_entity_pk_type(entity)): entity.pk,
|
||||
}
|
||||
|
||||
try:
|
||||
value_obj = self.value_set.get(**entity_filter)
|
||||
except Value.DoesNotExist:
|
||||
if value == None or value == '':
|
||||
return
|
||||
|
||||
value_obj = Value.objects.create(**entity_filter)
|
||||
|
||||
if value == None or value == '':
|
||||
value_obj.delete()
|
||||
return
|
||||
|
||||
if value != value_obj.value:
|
||||
value_obj.value = value
|
||||
value_obj.save()
|
||||
|
||||
def __str__(self):
|
||||
return '{} ({})'.format(self.name, self.get_datatype_display())
|
||||
|
||||
|
||||
class Value(models.Model): # noqa: WPS110
|
||||
"""Putting the **V** in *EAV*.
|
||||
|
||||
This model stores the value for one particular :class:`Attribute` for
|
||||
some entity.
|
||||
|
||||
As with most EAV implementations, most of the columns of this model will
|
||||
be blank, as onle one *value_* field will be used.
|
||||
|
||||
Example::
|
||||
|
||||
import eav
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
eav.register(User)
|
||||
|
||||
u = User.objects.create(username='crazy_dev_user')
|
||||
a = Attribute.objects.create(name='Fav Drink', datatype='text')
|
||||
|
||||
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
|
||||
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Value')
|
||||
verbose_name_plural = _('Values')
|
||||
|
||||
# Direct foreign keys
|
||||
attribute = models.ForeignKey(
|
||||
Attribute,
|
||||
db_index=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_('Attribute'),
|
||||
)
|
||||
|
||||
# Entity generic relationships. Rather than rely on database casting,
|
||||
# this will instead use a separate ForeignKey field attribute that matches
|
||||
# the FK type of the entity.
|
||||
entity_id = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Entity id'),
|
||||
)
|
||||
|
||||
entity_uuid = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Entity uuid'),
|
||||
)
|
||||
|
||||
entity_ct = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='value_entities',
|
||||
verbose_name=_('Entity ct'),
|
||||
)
|
||||
|
||||
entity_pk_int = generic.GenericForeignKey(
|
||||
ct_field='entity_ct',
|
||||
fk_field='entity_id',
|
||||
)
|
||||
|
||||
entity_pk_uuid = generic.GenericForeignKey(
|
||||
ct_field='entity_ct',
|
||||
fk_field='entity_uuid',
|
||||
)
|
||||
|
||||
# Model attributes
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('Created'),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Modified'),
|
||||
)
|
||||
|
||||
# Value attributes
|
||||
value_bool = models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value bool'),
|
||||
)
|
||||
value_csv = CSVField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value CSV'),
|
||||
)
|
||||
value_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value date'),
|
||||
)
|
||||
value_float = models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value float'),
|
||||
)
|
||||
value_int = models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value int'),
|
||||
)
|
||||
value_text = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value text'),
|
||||
)
|
||||
|
||||
value_json = models.JSONField(
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Value JSON'),
|
||||
)
|
||||
|
||||
value_enum = models.ForeignKey(
|
||||
EnumValue,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
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'),
|
||||
)
|
||||
|
||||
generic_value_ct = models.ForeignKey(
|
||||
ContentType,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='value_values',
|
||||
verbose_name=_('Generic value content type'),
|
||||
)
|
||||
|
||||
value_object = generic.GenericForeignKey(
|
||||
ct_field='generic_value_ct',
|
||||
fk_field='generic_value_id',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a Value."""
|
||||
entity = self.entity_pk_int
|
||||
if self.entity_uuid:
|
||||
entity = self.entity_pk_uuid
|
||||
return '{0}: "{1}" ({2})'.format(
|
||||
self.attribute.name,
|
||||
self.value,
|
||||
entity,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""Representation of Value object."""
|
||||
entity = self.entity_pk_int
|
||||
if self.entity_uuid:
|
||||
entity = self.entity_pk_uuid
|
||||
return '{0}: "{1}" ({2})'.format(
|
||||
self.attribute.name,
|
||||
self.value,
|
||||
entity.pk,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Validate and save this value."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _get_value(self):
|
||||
"""Return the python object this value is holding."""
|
||||
return getattr(self, 'value_{0}'.format(self.attribute.datatype))
|
||||
|
||||
def _set_value(self, new_value):
|
||||
"""Set the object this value is holding."""
|
||||
setattr(self, 'value_{0}'.format(self.attribute.datatype), new_value)
|
||||
|
||||
value = property(_get_value, _set_value) # noqa: WPS110
|
||||
|
||||
|
||||
class Entity(object):
|
||||
"""
|
||||
The helper class that will be attached to any entity
|
||||
registered with eav.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def pre_save_handler(sender, *args, **kwargs):
|
||||
"""
|
||||
Pre save handler attached to self.instance. Called before the
|
||||
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)
|
||||
entity.validate_attributes()
|
||||
|
||||
@staticmethod
|
||||
def post_save_handler(sender, *args, **kwargs):
|
||||
"""
|
||||
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)
|
||||
entity.save()
|
||||
|
||||
def __init__(self, instance):
|
||||
"""
|
||||
Set self.instance equal to the instance of the model that we're attached
|
||||
to. Also, store the content type of that instance.
|
||||
"""
|
||||
self.instance = instance
|
||||
self.ct = ContentType.objects.get_for_model(instance)
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""
|
||||
Tha magic getattr helper. This is called whenever user invokes::
|
||||
|
||||
instance.<attribute>
|
||||
|
||||
Checks if *name* is a valid slug for attributes available to this
|
||||
instances. If it is, tries to lookup the :class:`Value` with that
|
||||
attribute slug. If there is one, it returns the value of the
|
||||
class:`Value` object, otherwise it hasn't been set, so it returns
|
||||
None.
|
||||
"""
|
||||
if not name.startswith('_'):
|
||||
try:
|
||||
attribute = self.get_attribute_by_slug(name)
|
||||
except Attribute.DoesNotExist:
|
||||
raise AttributeError(
|
||||
_('%(obj)s has no EAV attribute named %(attr)s')
|
||||
% dict(obj=self.instance, attr=name)
|
||||
)
|
||||
|
||||
try:
|
||||
return self.get_value_by_attribute(attribute).value
|
||||
except Value.DoesNotExist:
|
||||
return None
|
||||
|
||||
return getattr(super(Entity, self), name)
|
||||
|
||||
def get_all_attributes(self):
|
||||
"""
|
||||
Return a query set of all :class:`Attribute` objects that can be set
|
||||
for this entity.
|
||||
"""
|
||||
return self.instance._eav_config_cls.get_attributes(
|
||||
instance=self.instance
|
||||
).order_by('display_order')
|
||||
|
||||
def _hasattr(self, attribute_slug):
|
||||
"""
|
||||
Since we override __getattr__ with a backdown to the database, this
|
||||
exists as a way of checking whether a user has set a real attribute on
|
||||
ourselves, without going to the db if not.
|
||||
"""
|
||||
return attribute_slug in self.__dict__
|
||||
|
||||
def _getattr(self, attribute_slug):
|
||||
"""
|
||||
Since we override __getattr__ with a backdown to the database, this
|
||||
exists as a way of getting the value a user set for one of our
|
||||
attributes, without going to the db to check.
|
||||
"""
|
||||
return self.__dict__[attribute_slug]
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Saves all the EAV values that have been set on this entity.
|
||||
"""
|
||||
for attribute in self.get_all_attributes():
|
||||
if self._hasattr(attribute.slug):
|
||||
attribute_value = self._getattr(attribute.slug)
|
||||
if attribute.datatype == Attribute.TYPE_ENUM and not isinstance(
|
||||
attribute_value, EnumValue
|
||||
):
|
||||
if attribute_value is not None:
|
||||
attribute_value = EnumValue.objects.get(value=attribute_value)
|
||||
attribute.save_value(self.instance, attribute_value)
|
||||
|
||||
def validate_attributes(self):
|
||||
"""
|
||||
Called before :meth:`save`, first validate all the entity values to
|
||||
make sure they can be created / saved cleanly.
|
||||
Raises ``ValidationError`` if they can't be.
|
||||
"""
|
||||
values_dict = self.get_values_dict()
|
||||
|
||||
for attribute in self.get_all_attributes():
|
||||
value = None
|
||||
|
||||
# Value was assigned to this instance.
|
||||
if self._hasattr(attribute.slug):
|
||||
value = self._getattr(attribute.slug)
|
||||
values_dict.pop(attribute.slug, None)
|
||||
# Otherwise try pre-loaded from DB.
|
||||
else:
|
||||
value = values_dict.pop(attribute.slug, None)
|
||||
|
||||
if value is None:
|
||||
if attribute.required:
|
||||
raise ValidationError(
|
||||
_('{} EAV field cannot be blank'.format(attribute.slug))
|
||||
)
|
||||
else:
|
||||
try:
|
||||
attribute.validate_value(value)
|
||||
except ValidationError as e:
|
||||
raise ValidationError(
|
||||
_('%(attr)s EAV field %(err)s')
|
||||
% dict(attr=attribute.slug, err=e)
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
def get_values_dict(self):
|
||||
return {v.attribute.slug: v.value for v in self.get_values()}
|
||||
|
||||
def get_values(self):
|
||||
"""Get all set :class:`Value` objects for self.instance."""
|
||||
entity_filter = {
|
||||
'entity_ct': self.ct,
|
||||
'{0}'.format(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))
|
||||
|
||||
def get_attribute_by_slug(self, slug):
|
||||
"""
|
||||
Returns a single :class:`Attribute` with *slug*.
|
||||
"""
|
||||
return self.get_all_attributes().get(slug=slug)
|
||||
|
||||
def get_value_by_attribute(self, attribute):
|
||||
"""
|
||||
Returns a single :class:`Value` for *attribute*.
|
||||
"""
|
||||
return self.get_values().get(attribute=attribute)
|
||||
|
||||
def get_object_attributes(self):
|
||||
"""
|
||||
Returns entity instance attributes, except for
|
||||
``instance`` and ``ct`` which are used internally.
|
||||
"""
|
||||
return set(copy(self.__dict__).keys()) - set(['instance', 'ct'])
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Iterate over set eav values. This would allow you to do::
|
||||
|
||||
for i in m.eav: print(i)
|
||||
"""
|
||||
return iter(self.get_values())
|
||||
|
||||
|
||||
class EAVModelMeta(ModelBase):
|
||||
def __new__(cls, name, bases, namespace, **kwds):
|
||||
result = super(EAVModelMeta, cls).__new__(cls, name, bases, dict(namespace))
|
||||
register(result)
|
||||
return result
|
||||
25
eav/models/__init__.py
Normal file
25
eav/models/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
This module defines the four concrete, non-abstract models:
|
||||
* :class:`Value`
|
||||
* :class:`Attribute`
|
||||
* :class:`EnumValue`
|
||||
* :class:`EnumGroup`.
|
||||
|
||||
Along with the :class:`Entity` helper class and :class:`EAVModelMeta`
|
||||
optional metaclass for each eav model class.
|
||||
"""
|
||||
|
||||
from .attribute import Attribute
|
||||
from .entity import EAVModelMeta, Entity
|
||||
from .enum_group import EnumGroup
|
||||
from .enum_value import EnumValue
|
||||
from .value import Value
|
||||
|
||||
__all__ = [
|
||||
"Attribute",
|
||||
"EAVModelMeta",
|
||||
"Entity",
|
||||
"EnumGroup",
|
||||
"EnumValue",
|
||||
"Value",
|
||||
]
|
||||
367
eav/models/attribute.py
Normal file
367
eav/models/attribute.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# ruff: noqa: UP007
|
||||
|
||||
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
|
||||
from django.db import models
|
||||
from django.db.models import ForeignKey
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav.fields import EavDatatypeField
|
||||
from eav.logic.entity_pk import get_entity_pk_type
|
||||
from eav.logic.managers import AttributeManager
|
||||
from eav.logic.object_pk import get_pk_format
|
||||
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
|
||||
from eav.settings import CHARFIELD_LENGTH
|
||||
from eav.validators import (
|
||||
validate_bool,
|
||||
validate_csv,
|
||||
validate_date,
|
||||
validate_enum,
|
||||
validate_float,
|
||||
validate_int,
|
||||
validate_json,
|
||||
validate_object,
|
||||
validate_text,
|
||||
)
|
||||
|
||||
from .enum_value import EnumValue
|
||||
from .value import Value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .enum_group import EnumGroup
|
||||
|
||||
|
||||
class Attribute(models.Model):
|
||||
"""
|
||||
Putting the **A** in *EAV*. This holds the attributes, or concepts.
|
||||
Examples of possible *Attributes*: color, height, weight, number of
|
||||
children, number of patients, has fever?, etc...
|
||||
|
||||
Each attribute has a name, and a description, along with a slug that must
|
||||
be unique. If you don't provide a slug, a default slug (derived from
|
||||
name), will be created.
|
||||
|
||||
The *required* field is a boolean that indicates whether this EAV attribute
|
||||
is required for entities to which it applies. It defaults to *False*.
|
||||
|
||||
.. warning::
|
||||
Just like a normal model field that is required, you will not be able
|
||||
to save or create any entity object for which this attribute applies,
|
||||
without first setting this EAV attribute.
|
||||
|
||||
There are 7 possible values for datatype:
|
||||
|
||||
* int (TYPE_INT)
|
||||
* float (TYPE_FLOAT)
|
||||
* text (TYPE_TEXT)
|
||||
* date (TYPE_DATE)
|
||||
* bool (TYPE_BOOLEAN)
|
||||
* object (TYPE_OBJECT)
|
||||
* enum (TYPE_ENUM)
|
||||
* json (TYPE_JSON)
|
||||
* csv (TYPE_CSV)
|
||||
|
||||
|
||||
Examples::
|
||||
|
||||
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
|
||||
# = <Attribute: Height (Integer)>
|
||||
|
||||
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
|
||||
# = <Attribute: Color (Text)>
|
||||
|
||||
yes = EnumValue.objects.create(value='yes')
|
||||
no = EnumValue.objects.create(value='no')
|
||||
unknown = EnumValue.objects.create(value='unknown')
|
||||
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: has fever? (Multiple Choice)>
|
||||
|
||||
.. warning:: Once an Attribute has been used by an entity, you can not
|
||||
change it's datatype.
|
||||
"""
|
||||
|
||||
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")),
|
||||
)
|
||||
|
||||
# Core attributes
|
||||
id = get_pk_format()
|
||||
|
||||
datatype = EavDatatypeField(
|
||||
choices=DATATYPE_CHOICES,
|
||||
max_length=6,
|
||||
verbose_name=_("Data Type"),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
help_text=_("User-friendly attribute name"),
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
|
||||
"""
|
||||
Main identifier for the attribute.
|
||||
Upon creation, slug is autogenerated from the name.
|
||||
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
|
||||
"""
|
||||
slug = models.SlugField(
|
||||
max_length=SLUGFIELD_MAX_LENGTH,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
help_text=_("Short unique attribute label"),
|
||||
verbose_name=_("Slug"),
|
||||
)
|
||||
|
||||
"""
|
||||
.. warning::
|
||||
This attribute should be used with caution. Setting this to *True*
|
||||
means that *all* entities that *can* have this attribute will
|
||||
be required to have a value for it.
|
||||
"""
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required"),
|
||||
)
|
||||
|
||||
entity_ct = models.ManyToManyField(
|
||||
ContentType,
|
||||
blank=True,
|
||||
verbose_name=_("Entity content type"),
|
||||
)
|
||||
"""
|
||||
This field allows you to specify a relationship with any number of content types.
|
||||
This would be useful, for example, if you wanted an attribute to apply only to
|
||||
a subset of entities. In that case, you could filter by content type in the
|
||||
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
||||
"""
|
||||
|
||||
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
|
||||
"eav.EnumGroup",
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Choice Group"),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=256,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=_("Short description"),
|
||||
verbose_name=_("Description"),
|
||||
)
|
||||
|
||||
# Useful meta-information
|
||||
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_("Display order"),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_("Modified"),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
editable=False,
|
||||
verbose_name=_("Created"),
|
||||
)
|
||||
|
||||
objects = AttributeManager()
|
||||
|
||||
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.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple: A tuple containing the name and slug of the Attribute instance.
|
||||
"""
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
)
|
||||
|
||||
@property
|
||||
def help_text(self):
|
||||
return self.description
|
||||
|
||||
def get_validators(self):
|
||||
"""
|
||||
Returns the appropriate validator function from :mod:`~eav.validators`
|
||||
as a list (of length one) for the datatype.
|
||||
|
||||
.. note::
|
||||
The reason it returns it as a list, is eventually we may want this
|
||||
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,
|
||||
}
|
||||
|
||||
return [datatype_validators[self.datatype]]
|
||||
|
||||
def validate_value(self, value):
|
||||
"""
|
||||
Check *value* against the validators returned by
|
||||
:meth:`get_validators` for this attribute.
|
||||
"""
|
||||
for validator in self.get_validators():
|
||||
validator(value)
|
||||
|
||||
if self.datatype == self.TYPE_ENUM:
|
||||
if isinstance(value, EnumValue):
|
||||
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},
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validates the attribute. Will raise ``ValidationError`` if the
|
||||
attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if
|
||||
the attribute is not *TYPE_ENUM* and the enum group is set.
|
||||
"""
|
||||
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
||||
raise ValidationError(
|
||||
_("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"),
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Returns a query set of :class:`EnumValue` objects for this attribute.
|
||||
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
|
||||
"""
|
||||
return (
|
||||
self.enum_group.values.all()
|
||||
if self.datatype == Attribute.TYPE_ENUM
|
||||
else None
|
||||
)
|
||||
|
||||
def save_value(self, entity, value):
|
||||
"""
|
||||
Called with *entity*, any Django object registered with eav, and
|
||||
*value*, the :class:`Value` this attribute for *entity* should
|
||||
be set to.
|
||||
|
||||
If a :class:`Value` object for this *entity* and attribute doesn't
|
||||
exist, one will be created.
|
||||
|
||||
.. note::
|
||||
If *value* is None and a :class:`Value` object exists for this
|
||||
Attribute and *entity*, it will delete that :class:`Value` object.
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(entity)
|
||||
|
||||
entity_filter = {
|
||||
"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 == "":
|
||||
return
|
||||
|
||||
value_obj = Value.objects.create(**entity_filter)
|
||||
|
||||
if value is None or value == "":
|
||||
value_obj.delete()
|
||||
return
|
||||
|
||||
if value != value_obj.value:
|
||||
value_obj.value = value
|
||||
value_obj.save()
|
||||
208
eav/models/entity.py
Normal file
208
eav/models/entity.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from copy import copy
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.base import ModelBase
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav import register
|
||||
from eav.exceptions import IllegalAssignmentException
|
||||
from eav.logic.entity_pk import get_entity_pk_type
|
||||
|
||||
from .attribute import Attribute
|
||||
from .enum_value import EnumValue
|
||||
from .value import Value
|
||||
|
||||
|
||||
class Entity:
|
||||
"""Helper class that will be attached to entities registered with eav."""
|
||||
|
||||
@staticmethod
|
||||
def pre_save_handler(sender, *args, **kwargs):
|
||||
"""
|
||||
Pre save handler attached to self.instance. Called before the
|
||||
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) # noqa: SLF001
|
||||
entity.validate_attributes()
|
||||
|
||||
@staticmethod
|
||||
def post_save_handler(sender, *args, **kwargs):
|
||||
"""
|
||||
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) # noqa: SLF001
|
||||
entity.save()
|
||||
|
||||
def __init__(self, instance) -> None:
|
||||
"""
|
||||
Set self.instance equal to the instance of the model that we're attached
|
||||
to. Also, store the content type of that instance.
|
||||
"""
|
||||
self.instance = instance
|
||||
self.ct = ContentType.objects.get_for_model(instance)
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""
|
||||
The magic getattr helper. This is called whenever user invokes::
|
||||
|
||||
instance.<attribute>
|
||||
|
||||
Checks if *name* is a valid slug for attributes available to this
|
||||
instances. If it is, tries to lookup the :class:`Value` with that
|
||||
attribute slug. If there is one, it returns the value of the
|
||||
class:`Value` object, otherwise it hasn't been set, so it returns
|
||||
None.
|
||||
"""
|
||||
if not name.startswith("_"):
|
||||
try:
|
||||
attribute = self.get_attribute_by_slug(name)
|
||||
except Attribute.DoesNotExist as err:
|
||||
raise AttributeError(
|
||||
_("%(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
|
||||
except Value.DoesNotExist:
|
||||
return None
|
||||
|
||||
return getattr(super(), name)
|
||||
|
||||
def get_all_attributes(self):
|
||||
"""
|
||||
Return a query set of all :class:`Attribute` objects that can be set
|
||||
for this entity.
|
||||
"""
|
||||
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
|
||||
instance=self.instance,
|
||||
).order_by("display_order")
|
||||
|
||||
def _hasattr(self, attribute_slug):
|
||||
"""
|
||||
Since we override __getattr__ with a backdown to the database, this
|
||||
exists as a way of checking whether a user has set a real attribute on
|
||||
ourselves, without going to the db if not.
|
||||
"""
|
||||
return attribute_slug in self.__dict__
|
||||
|
||||
def _getattr(self, attribute_slug):
|
||||
"""
|
||||
Since we override __getattr__ with a backdown to the database, this
|
||||
exists as a way of getting the value a user set for one of our
|
||||
attributes, without going to the db to check.
|
||||
"""
|
||||
return self.__dict__[attribute_slug]
|
||||
|
||||
def save(self):
|
||||
"""Saves all the EAV values that have been set on this entity."""
|
||||
for attribute in self.get_all_attributes():
|
||||
if self._hasattr(attribute.slug):
|
||||
attribute_value = self._getattr(attribute.slug)
|
||||
if (
|
||||
attribute.datatype == Attribute.TYPE_ENUM
|
||||
and not isinstance(
|
||||
attribute_value,
|
||||
EnumValue,
|
||||
)
|
||||
and attribute_value is not None
|
||||
):
|
||||
attribute_value = EnumValue.objects.get(value=attribute_value)
|
||||
attribute.save_value(self.instance, attribute_value)
|
||||
|
||||
def validate_attributes(self):
|
||||
"""
|
||||
Called before :meth:`save`, first validate all the entity values to
|
||||
make sure they can be created / saved cleanly.
|
||||
Raises ``ValidationError`` if they can't be.
|
||||
"""
|
||||
values_dict = self.get_values_dict()
|
||||
|
||||
for attribute in self.get_all_attributes():
|
||||
value = None
|
||||
|
||||
# Value was assigned to this instance.
|
||||
if self._hasattr(attribute.slug):
|
||||
value = self._getattr(attribute.slug)
|
||||
values_dict.pop(attribute.slug, None)
|
||||
# Otherwise try pre-loaded from DB.
|
||||
else:
|
||||
value = values_dict.pop(attribute.slug, None)
|
||||
|
||||
if value is None:
|
||||
if attribute.required:
|
||||
raise ValidationError(
|
||||
_("%s EAV field cannot be blank") % attribute.slug,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
attribute.validate_value(value)
|
||||
except ValidationError as err:
|
||||
raise ValidationError(
|
||||
_("%(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:
|
||||
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()}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
def get_attribute_by_slug(self, slug):
|
||||
"""Returns a single :class:`Attribute` with *slug*."""
|
||||
return self.get_all_attributes().get(slug=slug)
|
||||
|
||||
def get_value_by_attribute(self, attribute):
|
||||
"""Returns a single :class:`Value` for *attribute*."""
|
||||
return self.get_values().get(attribute=attribute)
|
||||
|
||||
def get_object_attributes(self):
|
||||
"""
|
||||
Returns entity instance attributes, except for
|
||||
``instance`` and ``ct`` which are used internally.
|
||||
"""
|
||||
return set(copy(self.__dict__).keys()) - {"instance", "ct"}
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Iterate over set eav values. This would allow you to do::
|
||||
|
||||
for i in m.eav: print(i)
|
||||
"""
|
||||
return iter(self.get_values())
|
||||
|
||||
|
||||
class EAVModelMeta(ModelBase):
|
||||
def __new__(cls, name, bases, namespace, **kwds):
|
||||
result = super().__new__(cls, name, bases, dict(namespace))
|
||||
register(result)
|
||||
return result
|
||||
63
eav/models/enum_group.py
Normal file
63
eav/models/enum_group.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import ManyToManyField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav.logic.managers import EnumGroupManager
|
||||
from eav.logic.object_pk import get_pk_format
|
||||
from eav.settings import CHARFIELD_LENGTH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .enum_value import EnumValue
|
||||
|
||||
|
||||
class EnumGroup(models.Model):
|
||||
"""
|
||||
*EnumGroup* objects have two fields - a *name* ``CharField`` and *values*,
|
||||
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
|
||||
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
|
||||
|
||||
See :class:`EnumValue` for an example.
|
||||
"""
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
max_length=CHARFIELD_LENGTH,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
|
||||
"eav.EnumValue",
|
||||
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}>"
|
||||
|
||||
def natural_key(self) -> tuple[str]:
|
||||
"""
|
||||
Retrieve the natural key for the EnumGroup instance.
|
||||
|
||||
The natural key for an EnumGroup is defined by its `name`. This method
|
||||
returns the name of the instance as a single-element tuple.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple: A tuple containing the name of the EnumGroup instance.
|
||||
"""
|
||||
return (self.name,)
|
||||
74
eav/models/enum_value.py
Normal file
74
eav/models/enum_value.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav.logic.managers import EnumValueManager
|
||||
from eav.logic.object_pk import get_pk_format
|
||||
from eav.logic.slug import SLUGFIELD_MAX_LENGTH
|
||||
|
||||
|
||||
class EnumValue(models.Model):
|
||||
"""
|
||||
*EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM*
|
||||
:class:`Attribute` objects. They have only one field, *value*, a
|
||||
``CharField`` that must be unique.
|
||||
|
||||
For example::
|
||||
|
||||
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
|
||||
no = EnumValue.objects.create(value='No')
|
||||
unknown = EnumValue.objects.create(value='Unknown')
|
||||
|
||||
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: has fever? (Multiple Choice)>
|
||||
|
||||
.. note::
|
||||
The same *EnumValue* objects should be reused within multiple
|
||||
*EnumGroups*. For example, if you have one *EnumGroup* called: *Yes /
|
||||
No / Unknown* and another called *Yes / No / Not applicable*, you should
|
||||
only have a total of four *EnumValues* objects, as you should have used
|
||||
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
|
||||
"""
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
value = models.CharField(
|
||||
_("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(
|
||||
self.value,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of `EnumValue` object."""
|
||||
return f"<EnumValue {self.value}>"
|
||||
|
||||
def natural_key(self) -> tuple[str]:
|
||||
"""
|
||||
Retrieve the natural key for the EnumValue instance.
|
||||
|
||||
The natural key for an EnumValue is defined by its `value`. This method returns
|
||||
the value of the instance as a single-element tuple.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple: A tuple containing the value of the EnumValue instance.
|
||||
"""
|
||||
return (self.value,)
|
||||
232
eav/models/value.py
Normal file
232
eav/models/value.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# ruff: noqa: UP007
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||
|
||||
from django.contrib.contenttypes import fields as generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.db.models import ForeignKey
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from eav.fields import CSVField
|
||||
from eav.logic.managers import ValueManager
|
||||
from eav.logic.object_pk import get_pk_format
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .attribute import Attribute
|
||||
from .enum_value import EnumValue
|
||||
|
||||
|
||||
class Value(models.Model):
|
||||
"""
|
||||
Putting the **V** in *EAV*.
|
||||
|
||||
This model stores the value for one particular :class:`Attribute` for
|
||||
some entity.
|
||||
|
||||
As with most EAV implementations, most of the columns of this model will
|
||||
be blank, as onle one *value_* field will be used.
|
||||
|
||||
Example::
|
||||
|
||||
import eav
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
eav.register(User)
|
||||
|
||||
u = User.objects.create(username='crazy_dev_user')
|
||||
a = Attribute.objects.create(name='Fav Drink', datatype='text')
|
||||
|
||||
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
|
||||
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
||||
"""
|
||||
|
||||
id = get_pk_format()
|
||||
|
||||
# Direct foreign keys
|
||||
attribute: ForeignKey[Attribute] = ForeignKey(
|
||||
"eav.Attribute",
|
||||
db_index=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("Attribute"),
|
||||
)
|
||||
|
||||
# Entity generic relationships. Rather than rely on database casting,
|
||||
# this will instead use a separate ForeignKey field attribute that matches
|
||||
# the FK type of the entity.
|
||||
entity_id = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Entity id"),
|
||||
)
|
||||
|
||||
entity_uuid = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Entity uuid"),
|
||||
)
|
||||
|
||||
entity_ct = ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="value_entities",
|
||||
verbose_name=_("Entity ct"),
|
||||
)
|
||||
|
||||
entity_pk_int = generic.GenericForeignKey(
|
||||
ct_field="entity_ct",
|
||||
fk_field="entity_id",
|
||||
)
|
||||
|
||||
entity_pk_uuid = generic.GenericForeignKey(
|
||||
ct_field="entity_ct",
|
||||
fk_field="entity_uuid",
|
||||
)
|
||||
|
||||
# Model attributes
|
||||
created = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("Created"),
|
||||
)
|
||||
|
||||
modified = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_("Modified"),
|
||||
)
|
||||
|
||||
# Value attributes
|
||||
value_bool = models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value bool"),
|
||||
)
|
||||
value_csv = CSVField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value CSV"),
|
||||
)
|
||||
value_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value date"),
|
||||
)
|
||||
value_float = models.FloatField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value float"),
|
||||
)
|
||||
value_int = models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value int"),
|
||||
)
|
||||
value_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("Value text"),
|
||||
)
|
||||
|
||||
value_json = models.JSONField(
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Value JSON"),
|
||||
)
|
||||
|
||||
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
|
||||
"eav.EnumValue",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
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"),
|
||||
)
|
||||
|
||||
generic_value_ct = ForeignKey(
|
||||
ContentType,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="value_values",
|
||||
verbose_name=_("Generic value content type"),
|
||||
)
|
||||
|
||||
value_object = generic.GenericForeignKey(
|
||||
ct_field="generic_value_ct",
|
||||
fk_field="generic_value_id",
|
||||
)
|
||||
|
||||
objects = ValueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Value")
|
||||
verbose_name_plural = _("Values")
|
||||
|
||||
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."""
|
||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of Value object."""
|
||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Validate and save this value."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def 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}")
|
||||
|
||||
def _set_value(self, new_value):
|
||||
"""Set the object this value is holding."""
|
||||
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,10 +18,11 @@ 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
|
||||
|
|
@ -42,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"]
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ def rewrite_q_expr(model_cls, expr):
|
|||
IGNORE
|
||||
|
||||
This is done by merging dangerous AND's and substituting them with
|
||||
explicit ``pk__in`` filter, where pks are taken from evaluted
|
||||
explicit ``pk__in`` filter, where pks are taken from evaluated
|
||||
Q-expr branch.
|
||||
|
||||
Args:
|
||||
|
|
@ -97,10 +97,10 @@ 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
|
||||
|
||||
# Recurively check child nodes.
|
||||
# Recursively check child nodes.
|
||||
expr.children = [rewrite_q_expr(model_cls, c) for c in expr.children]
|
||||
# Check which ones need a rewrite.
|
||||
rewritable = [c for c in expr.children if is_eav_and_leaf(c, gr_name)]
|
||||
|
|
@ -111,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.
|
||||
|
|
@ -130,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:
|
||||
|
|
@ -139,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
|
||||
|
||||
|
|
@ -169,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():
|
||||
|
|
@ -179,8 +179,11 @@ def eav_filter(func):
|
|||
nkey, nval = expand_eav_filter(self.model, key, value)
|
||||
|
||||
if nkey in nkwargs:
|
||||
# Apply AND to both querysets.
|
||||
nkwargs[nkey] = (nkwargs[nkey] & nval).distinct()
|
||||
# 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),
|
||||
).distinct()
|
||||
else:
|
||||
nkwargs.update({nkey: nval})
|
||||
|
||||
|
|
@ -226,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
|
||||
|
|
@ -263,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):
|
||||
|
|
@ -271,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):
|
||||
|
|
@ -279,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
|
||||
|
|
@ -289,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(
|
||||
|
|
@ -314,18 +317,18 @@ class EavQuerySet(QuerySet):
|
|||
)
|
||||
.order_by(
|
||||
# Order values by their value-field of
|
||||
# appriopriate attribute data-type.
|
||||
field_name
|
||||
# appropriate attribute data-type.
|
||||
field_name,
|
||||
)
|
||||
.values_list(
|
||||
# Retrieve only primary-keys of the entities
|
||||
# in the current queryset.
|
||||
'entity_id',
|
||||
"entity_id",
|
||||
field_name,
|
||||
)
|
||||
)
|
||||
|
||||
# Retrive ordered values from pk-value list.
|
||||
# Retrieve ordered values from pk-value list.
|
||||
_, ordered_values = zip(*pks_values)
|
||||
|
||||
# Add explicit ordering and turn
|
||||
|
|
@ -349,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,15 +3,14 @@
|
|||
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 overriden on registration.
|
||||
The default ``EavConfig`` class used if it is not overridden on registration.
|
||||
This is where all the default eav attribute names are defined.
|
||||
|
||||
Available options are as follows:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
3
eav/settings.py
Normal file
3
eav/settings.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from typing import Final
|
||||
|
||||
CHARFIELD_LENGTH: Final = 100
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
3174
poetry.lock
generated
3174
poetry.lock
generated
File diff suppressed because it is too large
Load diff
132
pyproject.toml
132
pyproject.toml
|
|
@ -1,22 +1,12 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
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.3.0"
|
||||
version = "1.8.1"
|
||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||
packages = [
|
||||
{ include = "eav" }
|
||||
|
|
@ -46,49 +36,99 @@ classifiers = [
|
|||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"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.0",
|
||||
"Framework :: Django :: 4.1",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
]
|
||||
|
||||
[tool.semantic_release]
|
||||
version_variable = [
|
||||
"pyproject.toml:version"
|
||||
]
|
||||
branch = "master"
|
||||
upload_to_pypi = false
|
||||
upload_to_release = false
|
||||
build_command = "pip install poetry && poetry build"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
django = ">=3.2, <4.2"
|
||||
python = "^3.9"
|
||||
django = ">=4.2,<5.3"
|
||||
|
||||
# Docs extra:
|
||||
sphinx = { version = "^5.0", optional = true }
|
||||
sphinx-autodoc-typehints = { version = "^1.12", optional = true }
|
||||
m2r2 = { version = "^0.3", optional = true }
|
||||
tomlkit = { version = "^0.11", optional = true }
|
||||
sphinx-rtd-theme = { version = "^1.0.0", optional = true }
|
||||
[tool.poetry.group.test.dependencies]
|
||||
mypy = "^1.6"
|
||||
ruff = ">=0.6.3,<0.13.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
mypy = "^1.0"
|
||||
safety = ">=2.3,<4.0"
|
||||
|
||||
wemake-python-styleguide = "^0.17"
|
||||
flake8-pytest-style = "^1.6"
|
||||
nitpick = "^0.32"
|
||||
black = "^22.12"
|
||||
|
||||
safety = "^2.3"
|
||||
|
||||
pytest = "^6.2"
|
||||
pytest-cov = "^4.0"
|
||||
pytest-randomly = "^3.12"
|
||||
pytest-pythonpath = "^0.7.4"
|
||||
pytest = ">=7.4.3,<9.0.0"
|
||||
pytest-cov = ">=4.1,<7.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-django = "^4.5.2"
|
||||
hypothesis = "^6.68.0"
|
||||
hypothesis = "^6.87.1"
|
||||
|
||||
doc8 = "^0.11.2"
|
||||
doc8 = ">=0.11.2,<1.2.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
docs = [
|
||||
"sphinx",
|
||||
"sphinx-autodoc-typehints",
|
||||
"sphinx_rtd_theme",
|
||||
"m2r2",
|
||||
"tomlkit",
|
||||
]
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = ">=5.0,<8.0"
|
||||
sphinx-rtd-theme = ">=1.3,<4.0"
|
||||
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||
m2r2 = "^0.3"
|
||||
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"
|
||||
|
|
|
|||
60
setup.cfg
60
setup.cfg
|
|
@ -3,70 +3,14 @@
|
|||
# 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 and wrong module names:
|
||||
*/migrations/*.py: WPS102, WPS114, 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/
|
||||
DJANGO_SETTINGS_MODULE = test_project.settings
|
||||
|
||||
# PYTHONPATH configuration:
|
||||
# https://github.com/bigsassy/pytest-pythonpath
|
||||
python_paths = ./eav
|
||||
# https://docs.pytest.org/en/7.0.x/reference/reference.html#confval-pythonpath
|
||||
pythonpath = ./eav
|
||||
|
||||
# py.test options:
|
||||
norecursedirs =
|
||||
|
|
|
|||
|
|
@ -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,50 +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",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -63,13 +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'
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
|
||||
|
||||
|
||||
# Password validation
|
||||
|
|
@ -81,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
|
||||
|
||||
|
|
@ -95,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,5 +1,8 @@
|
|||
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
|
||||
|
|
@ -13,26 +16,33 @@ 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":
|
||||
auto_field_strategy = st.text(min_size=1, max_size=255)
|
||||
else:
|
||||
auto_field_strategy = st.integers(min_value=1, max_value=32)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -43,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
|
||||
|
|
@ -63,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)
|
||||
|
|
@ -86,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()
|
||||
|
|
@ -109,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()
|
||||
|
|
@ -123,8 +134,10 @@ class TestAttributeModel(django.TestCase):
|
|||
@given(
|
||||
django.from_model(
|
||||
Attribute,
|
||||
id=auto_field_strategy,
|
||||
datatype=just(Attribute.TYPE_TEXT),
|
||||
enum_group=just(None),
|
||||
slug=just(None), # Let Attribute.save() handle
|
||||
),
|
||||
)
|
||||
@settings(deadline=None)
|
||||
|
|
@ -151,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,4 +1,3 @@
|
|||
import sys
|
||||
import pytest
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.core.handlers.base import BaseHandler
|
||||
|
|
@ -7,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
|
||||
|
||||
|
||||
|
|
@ -19,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
|
||||
|
||||
|
||||
|
|
@ -47,62 +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.site = AdminSite()
|
||||
|
||||
def test_fields(self):
|
||||
admin = BaseEntityAdmin(Patient, self.site)
|
||||
admin.form = BaseDynamicEntityForm
|
||||
view = admin.change_view(request, str(self.instance.pk))
|
||||
|
||||
own_fields = 3
|
||||
adminform = view.context_data['adminform']
|
||||
|
||||
self.assertEqual(
|
||||
len(adminform.form.fields), Attribute.objects.count() + own_fields
|
||||
)
|
||||
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
|
||||
|
|
@ -112,34 +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.mark.django_db()
|
||||
@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)
|
||||
|
||||
|
||||
@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()
|
||||
|
|
@ -148,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()
|
||||
|
||||
|
|
@ -156,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(
|
||||
|
|
@ -165,3 +152,100 @@ def test_csvdynamicform_empty(patient) -> None:
|
|||
)
|
||||
assert form.is_valid()
|
||||
assert form.save()
|
||||
|
||||
|
||||
@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.form = BaseDynamicEntityForm
|
||||
expected_fieldsets = 2
|
||||
|
||||
if define_fieldsets:
|
||||
# Use all fields in Patient model
|
||||
admin.fieldsets = (
|
||||
(None, {"fields": ["name", "example"]}),
|
||||
("Contact Info", {"fields": ["email"]}),
|
||||
)
|
||||
expected_fieldsets = 3
|
||||
|
||||
view = admin.change_view(request, str(patient.pk))
|
||||
|
||||
adminform = view.context_data["adminform"]
|
||||
|
||||
# Count the total fields in fieldsets
|
||||
total_fields = sum(
|
||||
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||
)
|
||||
|
||||
# 3 for 'name', 'email', 'example'
|
||||
expected_fields_count = Attribute.objects.count() + 3
|
||||
|
||||
assert total_fields == expected_fields_count
|
||||
|
||||
# Ensure our fieldset count is correct
|
||||
assert len(adminform.fieldsets) == expected_fieldsets
|
||||
|
||||
|
||||
@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.form = BaseDynamicEntityForm
|
||||
|
||||
# Only fields defined in Patient model
|
||||
expected_fields = 3
|
||||
|
||||
view = admin.change_view(request, str(patient.pk))
|
||||
|
||||
adminform = view.context_data["adminform"]
|
||||
|
||||
# Count the total fields in fieldsets
|
||||
total_fields = sum(
|
||||
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||
)
|
||||
|
||||
# 3 for 'name', 'email', 'example'
|
||||
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)
|
||||
|
|
|
|||
52
tests/test_natural_keys.py
Normal file
52
tests/test_natural_keys.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from django.test import TestCase
|
||||
|
||||
import eav
|
||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||
from test_project.models import Patient
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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_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.eav.age = 5
|
||||
p.save()
|
||||
|
||||
val = p.eav_values.first()
|
||||
|
||||
value_natural_key = val.natural_key()
|
||||
value_retrieved_model = Value.objects.get_by_natural_key(*value_natural_key)
|
||||
self.assertEqual(value_retrieved_model, val)
|
||||
|
||||
def test_enum_group_natural_keys(self):
|
||||
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,
|
||||
)
|
||||
self.assertEqual(enum_group_retrieved_model, enum_group)
|
||||
|
||||
def test_enum_value_natural_keys(self):
|
||||
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,
|
||||
)
|
||||
self.assertEqual(enum_value_retrieved_model, enum_value)
|
||||
32
tests/test_primary_key_format.py
Normal file
32
tests/test_primary_key_format.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format
|
||||
|
||||
|
||||
def test_get_uuid_primary_key(settings) -> None:
|
||||
settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField"
|
||||
primary_field = get_pk_format()
|
||||
assert isinstance(primary_field, models.UUIDField)
|
||||
assert primary_field.primary_key
|
||||
assert not primary_field.editable
|
||||
assert primary_field.default == uuid.uuid4
|
||||
|
||||
|
||||
def test_get_char_primary_key(settings) -> None:
|
||||
settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField"
|
||||
primary_field = get_pk_format()
|
||||
assert isinstance(primary_field, models.CharField)
|
||||
assert primary_field.primary_key
|
||||
assert not primary_field.editable
|
||||
assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN
|
||||
|
||||
|
||||
def test_get_default_primary_key(settings) -> None:
|
||||
# This test covers the default case for "BigAutoField"
|
||||
settings.EAV2_PRIMARY_KEY_FIELD = "AnyOtherField"
|
||||
primary_field = get_pk_format()
|
||||
assert isinstance(primary_field, models.BigAutoField)
|
||||
assert primary_field.primary_key
|
||||
assert not primary_field.editable
|
||||
|
|
@ -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,10 +379,31 @@ 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)
|
||||
|
||||
def test_filter_with_multiple_eav_attributes(self):
|
||||
"""
|
||||
Test filtering a model using both regular and multiple EAV attributes.
|
||||
|
||||
This test initializes test data and then filters the Patient test model
|
||||
based on a combination of a regular attribute and multiple EAV attributes.
|
||||
"""
|
||||
self.init_data()
|
||||
|
||||
# Use the filter method with 3 EAV attribute conditions
|
||||
patients = Patient.objects.filter(
|
||||
name="Anne",
|
||||
eav__age=3,
|
||||
eav__illness="cold",
|
||||
eav__fever="no",
|
||||
)
|
||||
|
||||
# Assert that the expected patient is returned
|
||||
self.assertEqual(len(patients), 1)
|
||||
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