Compare commits

..

136 commits

Author SHA1 Message Date
dependabot[bot]
d941373d34 chore(deps-dev): bump hypothesis from 6.135.24 to 6.135.26
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.135.24 to 6.135.26.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.135.24...hypothesis-python-6.135.26)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-version: 6.135.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-06 20:56:28 -07:00
dependabot[bot]
8b70e6afec chore(deps-dev): bump hypothesis from 6.133.0 to 6.135.24
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.133.0 to 6.135.24.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.133.0...hypothesis-python-6.135.24)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-version: 6.135.24
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 12:25:44 -07:00
dependabot[bot]
5248498008 chore(deps-dev): bump ruff from 0.11.12 to 0.12.2
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.11.12 to 0.12.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.11.12...0.12.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.12.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:42:50 -07:00
dependabot[bot]
ce5f58dc50 chore(deps-dev): bump urllib3 from 2.4.0 to 2.5.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.4.0 to 2.5.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.4.0...2.5.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:42:41 -07:00
dependabot[bot]
f42a53bcaf chore(deps-dev): bump pytest from 8.3.5 to 8.4.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.5 to 8.4.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...8.4.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:42:34 -07:00
dependabot[bot]
32e20994f1 chore(deps-dev): bump mypy from 1.16.0 to 1.16.1
Bumps [mypy](https://github.com/python/mypy) from 1.16.0 to 1.16.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-version: 1.16.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:37:03 -07:00
dependabot[bot]
ec9a5b413d chore(deps-dev): bump safety from 3.5.1 to 3.5.2
Bumps [safety](https://github.com/pyupio/safety) from 3.5.1 to 3.5.2.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.5.1...3.5.2)

---
updated-dependencies:
- dependency-name: safety
  dependency-version: 3.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:36:53 -07:00
dependabot[bot]
dcc643ff81 chore(deps-dev): bump pytest-cov from 5.0.0 to 6.2.1
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 5.0.0 to 6.2.1.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v5.0.0...v6.2.1)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 6.2.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:36:40 -07:00
dependabot[bot]
88d367f924 chore(deps): bump django from 4.2.21 to 4.2.23
Bumps [django](https://github.com/django/django) from 4.2.21 to 4.2.23.
- [Commits](https://github.com/django/django/compare/4.2.21...4.2.23)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 4.2.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:10:34 -07:00
dependabot[bot]
f7aeed0b14 chore(deps-dev): bump requests from 2.32.3 to 2.32.4
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:10:21 -07:00
dependabot[bot]
a32e94adb6 chore(deps-dev): bump tomlkit from 0.13.2 to 0.13.3
Bumps [tomlkit](https://github.com/sdispater/tomlkit) from 0.13.2 to 0.13.3.
- [Release notes](https://github.com/sdispater/tomlkit/releases)
- [Changelog](https://github.com/python-poetry/tomlkit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sdispater/tomlkit/compare/0.13.2...0.13.3)

---
updated-dependencies:
- dependency-name: tomlkit
  dependency-version: 0.13.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-04 15:10:11 -07:00
Mike
794c71c7a6 chore: release 1.8.1 2025-06-02 10:46:04 -07:00
pre-commit-ci[bot]
8dd92753d6 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.11 → v0.11.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.11...v0.11.12)
2025-06-02 10:36:16 -07:00
pre-commit-ci[bot]
539f0003a1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.8 → v0.11.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.8...v0.11.11)
2025-06-02 09:09:58 -07:00
Mike
dc371db44f chore(deps): update downstream dependencies 2025-06-02 09:08:01 -07:00
Mike
24c1de89fa ci(workflow): update Python and Django version matrix for tests 2025-06-02 08:57:36 -07:00
Mike
3e5841af10 chore(deps): update python and django version requirements 2025-06-02 08:57:18 -07:00
dependabot[bot]
dc49b53c82 chore(deps-dev): bump m2r2 from 0.3.3.post2 to 0.3.4
Bumps [m2r2](https://github.com/crossnox/m2r2) from 0.3.3.post2 to 0.3.4.
- [Changelog](https://github.com/CrossNox/m2r2/blob/development/CHANGES.md)
- [Commits](https://github.com/crossnox/m2r2/compare/v0.3.3.post2...v0.3.4)

---
updated-dependencies:
- dependency-name: m2r2
  dependency-version: 0.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-05 14:45:08 -07:00
dependabot[bot]
f193bd41cd chore(deps-dev): bump ruff from 0.9.7 to 0.11.8
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.7 to 0.11.8.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.7...0.11.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.11.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-05 14:26:54 -07:00
dependabot[bot]
f7f3d59b30 chore(deps-dev): bump jinja2 from 3.1.5 to 3.1.6
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.5...3.1.6)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-05 14:18:04 -07:00
pre-commit-ci[bot]
439fa5046f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.7 → v0.11.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.7...v0.11.8)
2025-05-05 14:17:56 -07:00
dependabot[bot]
fe6db896bd chore(deps-dev): bump pytest from 8.3.4 to 8.3.5
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.4 to 8.3.5.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.4...8.3.5)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-05 14:17:44 -07:00
Mike
682cf61840 ci: improve test workflow configuration
- Prevent test workflow from running on tag pushes to avoid
  duplicate runs during releases
- Rename job from "build" to "test-matrix" for clarity
2025-02-24 19:22:03 -08:00
Mike
7e572801b0 ci: update PyPI publish action to use recommended tag 2025-02-24 19:15:02 -08:00
Mike
a38a6b9f5c chore: release 1.8.0 2025-02-24 19:01:08 -08:00
wolfmetr
2b9b9d7aa7 Fix for issue #648: Ensure choices are valid (value, label) tuples to avoid 'not enough values to unpack' error
(cherry picked from commit 30e8873b10db560b845f23697e00d20c42d7a989)
2025-02-24 18:53:50 -08:00
pre-commit-ci[bot]
f6b3cf0865 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.6 → v0.9.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.9.7)
2025-02-24 18:27:00 -08:00
dependabot[bot]
041b19a1d2 chore(deps-dev): bump ruff from 0.9.6 to 0.9.7
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.6 to 0.9.7.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.6...0.9.7)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 18:26:49 -08:00
dependabot[bot]
e789a0dcd3 chore(deps-dev): bump safety from 3.2.14 to 3.3.0
Bumps [safety](https://github.com/pyupio/safety) from 3.2.14 to 3.3.0.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.14...3.3.0)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-24 18:26:40 -08:00
Mike
fafe528ea5 feat(models): add database constraints to Value model 2025-02-24 18:26:28 -08:00
Mike
4deda2abc5 refactor: move Sequence import to type-checking block
- Fix Ruff TC003 linting error
2025-02-13 09:09:40 -08:00
pre-commit-ci[bot]
28c67b3d04 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-02-13 09:09:40 -08:00
pre-commit-ci[bot]
449ddc9248 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.3 → v0.9.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.3...v0.9.6)
2025-02-13 09:09:40 -08:00
Mike
a95f2a1c33 style(lint): apply ruff formatting rules with defined target-version 2025-02-13 07:40:43 -08:00
Mike
996512b04c chore(ruff): set python target version to 3.8 2025-02-13 07:39:22 -08:00
dependabot[bot]
73755c4fdf chore(deps-dev): bump ruff from 0.9.5 to 0.9.6
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.5 to 0.9.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.5...0.9.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-13 07:27:44 -08:00
dependabot[bot]
579a1e0fc7 chore(deps-dev): bump pytest-django from 4.9.0 to 4.10.0
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.9.0 to 4.10.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.9.0...v4.10.0)

---
updated-dependencies:
- dependency-name: pytest-django
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-13 07:27:31 -08:00
dependabot[bot]
b160b38309 chore(deps-dev): bump ruff from 0.8.0 to 0.9.5
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.0 to 0.9.5.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.8.0...0.9.5)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-09 13:25:33 -08:00
dependabot[bot]
436edd5492 chore(deps): bump django from 4.2.16 to 4.2.19
Bumps [django](https://github.com/django/django) from 4.2.16 to 4.2.19.
- [Commits](https://github.com/django/django/compare/4.2.16...4.2.19)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-08 08:46:37 -08:00
dependabot[bot]
9261d518da chore(deps-dev): bump jinja2 from 3.1.4 to 3.1.5
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-08 08:30:09 -08:00
Mike
18148c2b97 pin Poetry to 1.8.4 2025-02-08 08:25:33 -08:00
dependabot[bot]
eca5995616 chore(deps-dev): bump mypy from 1.13.0 to 1.14.1
Bumps [mypy](https://github.com/python/mypy) from 1.13.0 to 1.14.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.13.0...v1.14.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-08 08:17:47 -08:00
dependabot[bot]
1125887ba9 chore(deps-dev): bump safety from 3.2.11 to 3.2.14
Bumps [safety](https://github.com/pyupio/safety) from 3.2.11 to 3.2.14.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.11...3.2.14)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-08 08:17:41 -08:00
dependabot[bot]
9c68743af8 chore(deps-dev): bump pytest from 8.3.3 to 8.3.4
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-08 08:17:34 -08:00
dependabot[bot]
6c44ba988a chore(deps-dev): bump ruff from 0.7.3 to 0.8.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.3 to 0.8.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.7.3...0.8.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-27 11:24:33 -08:00
dependabot[bot]
75708e3fbb chore(deps): bump codecov/codecov-action from 3 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-27 11:24:25 -08:00
dependabot[bot]
34862ed30a chore(deps-dev): bump sphinx-rtd-theme from 3.0.1 to 3.0.2
Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 3.0.1 to 3.0.2.
- [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst)
- [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/3.0.1...3.0.2)

---
updated-dependencies:
- dependency-name: sphinx-rtd-theme
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-27 11:24:16 -08:00
dependabot[bot]
abd93a44a1 chore(deps-dev): bump safety from 3.2.10 to 3.2.11
Bumps [safety](https://github.com/pyupio/safety) from 3.2.10 to 3.2.11.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.10...3.2.11)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-27 11:24:09 -08:00
dependabot[bot]
835717bd27 chore(deps-dev): bump ruff from 0.6.8 to 0.7.3
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.8 to 0.7.3.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.6.8...0.7.3)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 15:39:32 -08:00
dependabot[bot]
3a7d8eec63 chore(deps-dev): bump safety from 3.2.8 to 3.2.10
Bumps [safety](https://github.com/pyupio/safety) from 3.2.8 to 3.2.10.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.8...3.2.10)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 14:09:02 -08:00
dependabot[bot]
fef15f0ba6 chore(deps-dev): bump mypy from 1.11.2 to 1.13.0
Bumps [mypy](https://github.com/python/mypy) from 1.11.2 to 1.13.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.11.2...v1.13.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 14:08:55 -08:00
dependabot[bot]
ae73962bb2 chore(deps-dev): bump sphinx-rtd-theme from 2.0.0 to 3.0.1
Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 2.0.0 to 3.0.1.
- [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst)
- [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/2.0.0...3.0.1)

---
updated-dependencies:
- dependency-name: sphinx-rtd-theme
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 14:08:48 -08:00
dependabot[bot]
70fceedda0 chore(deps-dev): bump hypothesis from 6.112.2 to 6.113.0
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.112.2 to 6.113.0.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.112.2...hypothesis-python-6.113.0)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 14:08:39 -08:00
pre-commit-ci[bot]
39c3540592 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.7.3)
2024-11-11 14:08:26 -08:00
dependabot[bot]
6c3c7f39e8 chore(deps-dev): bump hypothesis from 6.112.1 to 6.112.2
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.112.1 to 6.112.2.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.112.1...hypothesis-python-6.112.2)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-03 17:24:32 -07:00
dependabot[bot]
a47b1b05e0 chore(deps-dev): bump safety from 3.2.7 to 3.2.8
Bumps [safety](https://github.com/pyupio/safety) from 3.2.7 to 3.2.8.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.7...3.2.8)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-03 17:24:26 -07:00
dependabot[bot]
b276cb3e35 chore(deps-dev): bump ruff from 0.6.5 to 0.6.8
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.5 to 0.6.8.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.6.5...0.6.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-03 17:24:18 -07:00
pre-commit-ci[bot]
5a1d7546f4 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.5 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.5...v0.6.8)
2024-10-03 17:24:10 -07:00
dependabot[bot]
dc2cd2dff5 chore(deps-dev): bump ruff from 0.6.3 to 0.6.5
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.3 to 0.6.5.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.6.3...0.6.5)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-19 08:56:06 -07:00
dependabot[bot]
0e27224106 chore(deps-dev): bump hypothesis from 6.111.2 to 6.112.1
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.111.2 to 6.112.1.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.111.2...hypothesis-python-6.112.1)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-19 08:56:00 -07:00
dependabot[bot]
305740e2e6 chore(deps-dev): bump pytest from 8.3.2 to 8.3.3
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-19 08:55:53 -07:00
pre-commit-ci[bot]
d281ff97c2 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.5)
2024-09-19 08:55:44 -07:00
dependabot[bot]
e17778b522 chore(deps): bump django from 4.2.15 to 4.2.16
Bumps [django](https://github.com/django/django) from 4.2.15 to 4.2.16.
- [Commits](https://github.com/django/django/compare/4.2.15...4.2.16)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-19 08:55:36 -07:00
dependabot[bot]
0f218add0b chore(deps-dev): bump cryptography from 42.0.8 to 43.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.8 to 43.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.8...43.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-03 18:18:12 -07:00
dependabot[bot]
f07e2d0506 chore(deps-dev): bump pytest-django from 4.8.0 to 4.9.0
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.8.0 to 4.9.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.8.0...v4.9.0)

---
updated-dependencies:
- dependency-name: pytest-django
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-03 17:29:39 -07:00
dependabot[bot]
0d82d5ab5a chore(deps-dev): bump doc8 from 1.1.1 to 1.1.2
Bumps [doc8](https://github.com/pycqa/doc8) from 1.1.1 to 1.1.2.
- [Release notes](https://github.com/pycqa/doc8/releases)
- [Commits](https://github.com/pycqa/doc8/compare/v1.1.1...v1.1.2)

---
updated-dependencies:
- dependency-name: doc8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-03 17:29:31 -07:00
Mike
d0b531f7be chore: release 1.7.1 2024-09-01 21:30:59 -07:00
Mike
8f18d5e7e2 fix(attribute): restore backward compatibility for invalid slugs
Replace ValidationError with a warning when creating Attributes with invalid slugs. This change allows existing code to continue functioning while alerting users to potential issues.
2024-09-01 21:26:45 -07:00
Mike
3e7563338f chore: release 1.7.0 2024-09-01 15:39:31 -07:00
dependabot[bot]
1c4355e948 chore(deps-dev): bump mypy from 1.11.1 to 1.11.2
Bumps [mypy](https://github.com/python/mypy) from 1.11.1 to 1.11.2.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.11.1...v1.11.2)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-01 15:12:46 -07:00
dependabot[bot]
353bc5f094 chore(deps-dev): bump hypothesis from 6.111.0 to 6.111.2
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.111.0 to 6.111.2.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.111.0...hypothesis-python-6.111.2)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-01 15:09:23 -07:00
dependabot[bot]
17c94198d0 chore(deps-dev): bump safety from 3.2.5 to 3.2.7
Bumps [safety](https://github.com/pyupio/safety) from 3.2.5 to 3.2.7.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.5...3.2.7)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-01 15:03:29 -07:00
Mike
625b8a5315 chore(migrations): update defaults and meta options for Attribute and Value models 2024-09-01 14:57:47 -07:00
pre-commit-ci[bot]
6b7a04f8a7 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-09-01 14:57:47 -07:00
Mike
9f4bddb94d fix(eav): update ClassVar type hint for Python 3.8 compatibility 2024-09-01 14:57:47 -07:00
Mike
393e3e352a chore: update lockfile 2024-09-01 14:57:47 -07:00
Mike
56939d9c5e chore: remove nitpick and black configuration from pyproject.toml 2024-09-01 14:57:47 -07:00
Mike
3ccf3146eb ci(pre-commit): replace black with ruff for linting and formatting 2024-09-01 14:57:47 -07:00
Mike
50db5bead4 chore: add .git-blame-ignore-revs file 2024-09-01 14:57:47 -07:00
Mike
b5b576aca5 refactor: apply ruff linter rules and standardize code style
Replace flake8 with ruff and apply consistent linting rules across the entire codebase. Update type annotations, quotation marks, and other style-related changes to comply with the new standards.
2024-09-01 14:57:47 -07:00
Mike
5e1a7d2803 chore(linting): replace wemake-python-styleguide with ruff 2024-09-01 14:57:47 -07:00
Mike
b8bcc383a4 ci(test): update Django versions and Python compatibility
Update test matrix to use Django 4.2, 5.0, and 5.1, removing support for Django 3.2.
2024-08-31 21:37:28 -07:00
pre-commit-ci[bot]
41fa7ddc5c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-08-31 21:20:36 -07:00
Mike
1262a52282 test(attributes): add slug parameter to attribute model test 2024-08-31 21:20:36 -07:00
Mike
27d3887604 feat(attribute): add slug validation for Python identifier compliance
Implement custom validation for Attribute model's slug field to ensure it's a valid Python identifier. Add corresponding test case.
2024-08-31 21:20:36 -07:00
Mike
3990d7d6cb test: remove special chars in attribute name test data 2024-08-31 21:20:36 -07:00
Mike
6f141ff4f2 refactor(slug): ensure generated slugs are valid Python identifiers 2024-08-31 21:20:36 -07:00
dependabot[bot]
ab23ba118d chore(deps-dev): bump tomlkit from 0.13.0 to 0.13.2
Bumps [tomlkit](https://github.com/sdispater/tomlkit) from 0.13.0 to 0.13.2.
- [Release notes](https://github.com/sdispater/tomlkit/releases)
- [Changelog](https://github.com/python-poetry/tomlkit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sdispater/tomlkit/compare/0.13.0...0.13.2)

---
updated-dependencies:
- dependency-name: tomlkit
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-15 07:34:48 -07:00
dependabot[bot]
03cb115531 chore(deps-dev): bump safety from 3.2.4 to 3.2.5
Bumps [safety](https://github.com/pyupio/safety) from 3.2.4 to 3.2.5.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.4...3.2.5)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-15 07:34:40 -07:00
dependabot[bot]
a56559ebb7 chore(deps-dev): bump hypothesis from 6.108.10 to 6.111.0
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.108.10 to 6.111.0.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.108.10...hypothesis-python-6.111.0)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-15 07:34:31 -07:00
dependabot[bot]
3884d2c2aa chore(deps): bump django from 4.2.14 to 4.2.15
Bumps [django](https://github.com/django/django) from 4.2.14 to 4.2.15.
- [Commits](https://github.com/django/django/compare/4.2.14...4.2.15)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 07:49:09 -07:00
dependabot[bot]
b0a73e2b9c chore(deps): bump pyyaml from 6.0.1 to 6.0.2
Bumps [pyyaml](https://github.com/yaml/pyyaml) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/yaml/pyyaml/releases)
- [Changelog](https://github.com/yaml/pyyaml/blob/main/CHANGES)
- [Commits](https://github.com/yaml/pyyaml/compare/6.0.1...6.0.2)

---
updated-dependencies:
- dependency-name: pyyaml
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 07:49:01 -07:00
dependabot[bot]
cad0846d2c chore(deps-dev): bump hypothesis from 6.108.5 to 6.108.10
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.108.5 to 6.108.10.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.108.5...hypothesis-python-6.108.10)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 07:48:43 -07:00
pre-commit-ci[bot]
c86b909970 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 24.4.2 → 24.8.0](https://github.com/psf/black/compare/24.4.2...24.8.0)
2024-08-07 07:48:35 -07:00
dependabot[bot]
6e63441ca0 chore(deps-dev): bump black from 24.4.2 to 24.8.0
Bumps [black](https://github.com/psf/black) from 24.4.2 to 24.8.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.4.2...24.8.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 07:48:23 -07:00
dependabot[bot]
d2c34da383 chore(deps-dev): bump mypy from 1.11.0 to 1.11.1
Bumps [mypy](https://github.com/python/mypy) from 1.11.0 to 1.11.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.11...v1.11.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 07:48:11 -07:00
dependabot[bot]
c8bc9310d9 chore(deps-dev): bump hypothesis from 6.104.2 to 6.108.5
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.104.2 to 6.108.5.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.104.2...hypothesis-python-6.108.5)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:58:37 -07:00
dependabot[bot]
4940d7fa0b chore(deps-dev): bump pytest from 8.2.2 to 8.3.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.2 to 8.3.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.2...8.3.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:58:28 -07:00
dependabot[bot]
8ab1d09627 chore(deps-dev): bump mypy from 1.10.1 to 1.11.0
Bumps [mypy](https://github.com/python/mypy) from 1.10.1 to 1.11.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.10.1...v1.11)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:58:21 -07:00
dependabot[bot]
3ea0257a21 chore(deps-dev): bump tomlkit from 0.12.5 to 0.13.0
Bumps [tomlkit](https://github.com/sdispater/tomlkit) from 0.12.5 to 0.13.0.
- [Release notes](https://github.com/sdispater/tomlkit/releases)
- [Changelog](https://github.com/python-poetry/tomlkit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sdispater/tomlkit/compare/0.12.5...0.13.0)

---
updated-dependencies:
- dependency-name: tomlkit
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:58:12 -07:00
dependabot[bot]
5dba618e63 chore(deps): bump django from 4.2.13 to 4.2.14
Bumps [django](https://github.com/django/django) from 4.2.13 to 4.2.14.
- [Commits](https://github.com/django/django/compare/4.2.13...4.2.14)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:58:04 -07:00
dependabot[bot]
17e018a0a0 chore(deps-dev): bump certifi from 2024.6.2 to 2024.7.4
Bumps [certifi](https://github.com/certifi/python-certifi) from 2024.6.2 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2024.06.02...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:57:55 -07:00
dependabot[bot]
c82273e62e chore(deps-dev): bump safety from 3.2.3 to 3.2.4
Bumps [safety](https://github.com/pyupio/safety) from 3.2.3 to 3.2.4.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.3...3.2.4)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 07:57:45 -07:00
dependabot[bot]
ceaf3abf40 chore(deps-dev): bump hypothesis from 6.103.2 to 6.104.2
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.103.2 to 6.104.2.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.103.2...hypothesis-python-6.104.2)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 14:01:58 -07:00
dependabot[bot]
38ddd519cf chore(deps-dev): bump mypy from 1.10.0 to 1.10.1
Bumps [mypy](https://github.com/python/mypy) from 1.10.0 to 1.10.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.10.0...v1.10.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 14:01:48 -07:00
Mike
c35d1355b0 chore: prepare release v1.6.1 2024-06-23 14:06:01 -07:00
Mike
97eb3f7fc2 chore: update downstream dependencies 2024-06-23 09:37:32 -07:00
dependabot[bot]
a4f5a8ae3d chore(deps-dev): bump urllib3 from 1.26.18 to 1.26.19
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.18 to 1.26.19.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/1.26.19/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.18...1.26.19)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-23 09:00:22 -07:00
dependabot[bot]
322753b821 chore(deps-dev): bump hypothesis from 6.102.6 to 6.103.2
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.102.6 to 6.103.2.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.102.6...hypothesis-python-6.103.2)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-23 09:00:15 -07:00
dependabot[bot]
c62c927548 chore(deps-dev): bump safety from 3.2.0 to 3.2.3
Bumps [safety](https://github.com/pyupio/safety) from 3.2.0 to 3.2.3.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.2.0...3.2.3)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-23 09:00:08 -07:00
dependabot[bot]
c0f485e766 chore(deps-dev): bump authlib from 1.3.0 to 1.3.1
Bumps [authlib](https://github.com/lepture/authlib) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/lepture/authlib/releases)
- [Changelog](https://github.com/lepture/authlib/blob/master/docs/changelog.rst)
- [Commits](https://github.com/lepture/authlib/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: authlib
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-23 09:00:01 -07:00
dependabot[bot]
462e949a08 chore(deps-dev): bump pytest from 8.2.1 to 8.2.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.1...8.2.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-23 08:59:48 -07:00
pre-commit-ci[bot]
6585010038 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-06-23 08:59:33 -07:00
Mike
8c87f2f53b fix: ensure default manager is correctly replaced and ordered 2024-06-23 08:59:33 -07:00
dependabot[bot]
1b0ea0b9c4
Merge pull request #572 from jazzband/dependabot/pip/jinja2-3.1.4 2024-05-26 15:58:04 +00:00
dependabot[bot]
1b73435ff3
chore(deps-dev): bump jinja2 from 3.1.3 to 3.1.4
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-26 15:54:39 +00:00
dependabot[bot]
3c74c2c008 chore(deps-dev): bump hypothesis from 6.102.1 to 6.102.6
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.102.1 to 6.102.6.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.102.1...hypothesis-python-6.102.6)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-26 08:53:16 -07:00
dependabot[bot]
04b1926d3a ---
updated-dependencies:
- dependency-name: requests
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-26 08:53:09 -07:00
dependabot[bot]
6d3892459f chore(deps-dev): bump pytest from 8.2.0 to 8.2.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.0 to 8.2.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.0...8.2.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-26 08:53:01 -07:00
dependabot[bot]
671fd814b6 chore(deps-dev): bump black from 24.4.0 to 24.4.2
Bumps [black](https://github.com/psf/black) from 24.4.0 to 24.4.2.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.4.0...24.4.2)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-26 08:52:52 -07:00
pre-commit-ci[bot]
27dfd357de [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 24.3.0 → 24.4.2](https://github.com/psf/black/compare/24.3.0...24.4.2)
2024-05-14 12:52:08 -07:00
dependabot[bot]
9746ef8218 chore(deps-dev): bump hypothesis from 6.99.5 to 6.102.1
Bumps [hypothesis](https://github.com/HypothesisWorks/hypothesis) from 6.99.5 to 6.102.1.
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](https://github.com/HypothesisWorks/hypothesis/compare/hypothesis-python-6.99.5...hypothesis-python-6.102.1)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:48:17 -07:00
dependabot[bot]
5c804658b9 chore(deps-dev): bump tomlkit from 0.12.4 to 0.12.5
Bumps [tomlkit](https://github.com/sdispater/tomlkit) from 0.12.4 to 0.12.5.
- [Release notes](https://github.com/sdispater/tomlkit/releases)
- [Changelog](https://github.com/python-poetry/tomlkit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sdispater/tomlkit/compare/0.12.4...0.12.5)

---
updated-dependencies:
- dependency-name: tomlkit
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:47:57 -07:00
dependabot[bot]
18b821e4a5 chore(deps): bump django from 4.2.11 to 4.2.13
Bumps [django](https://github.com/django/django) from 4.2.11 to 4.2.13.
- [Commits](https://github.com/django/django/compare/4.2.11...4.2.13)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:47:40 -07:00
dependabot[bot]
8529a40d9d chore(deps-dev): bump safety from 3.1.0 to 3.2.0
Bumps [safety](https://github.com/pyupio/safety) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.1.0...3.2.0)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:47:33 -07:00
dependabot[bot]
827c54895f chore(deps-dev): bump pytest from 8.1.1 to 8.2.0
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.1.1 to 8.2.0.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.1.1...8.2.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:47:25 -07:00
dependabot[bot]
26dfc17c1b chore(deps-dev): bump mypy from 1.9.0 to 1.10.0
Bumps [mypy](https://github.com/python/mypy) from 1.9.0 to 1.10.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:47:15 -07:00
dependabot[bot]
b48882ee9d chore(deps): bump sqlparse from 0.4.4 to 0.5.0
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 12:46:49 -07:00
dependabot[bot]
b94cc44db5 chore(deps-dev): bump black from 24.3.0 to 24.4.0
Bumps [black](https://github.com/psf/black) from 24.3.0 to 24.4.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.3.0...24.4.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 09:56:24 -07:00
dependabot[bot]
4a01f13109 chore(deps-dev): bump idna from 3.4 to 3.7
Bumps [idna](https://github.com/kjd/idna) from 3.4 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.4...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 09:56:16 -07:00
dependabot[bot]
20c9194372 chore(deps-dev): bump sphinx-autodoc-typehints from 2.0.0 to 2.0.1
Bumps [sphinx-autodoc-typehints](https://github.com/tox-dev/sphinx-autodoc-typehints) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/tox-dev/sphinx-autodoc-typehints/releases)
- [Changelog](https://github.com/tox-dev/sphinx-autodoc-typehints/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tox-dev/sphinx-autodoc-typehints/compare/2.0.0...2.0.1)

---
updated-dependencies:
- dependency-name: sphinx-autodoc-typehints
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 09:56:07 -07:00
pre-commit-ci[bot]
e6cc7bc64e [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)
2024-04-15 09:55:56 -07:00
dependabot[bot]
6cc8e1206c chore(deps-dev): bump safety from 3.0.1 to 3.1.0
Bumps [safety](https://github.com/pyupio/safety) from 3.0.1 to 3.1.0.
- [Release notes](https://github.com/pyupio/safety/releases)
- [Changelog](https://github.com/pyupio/safety/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pyupio/safety/compare/3.0.1...3.1.0)

---
updated-dependencies:
- dependency-name: safety
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:02:13 -07:00
dependabot[bot]
9ee8af9a8a chore(deps-dev): bump pytest-cov from 4.1.0 to 5.0.0
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 4.1.0 to 5.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v5.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:01:59 -07:00
pre-commit-ci[bot]
19fb9f2017 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 24.2.0 → 24.3.0](https://github.com/psf/black/compare/24.2.0...24.3.0)
2024-04-01 09:01:49 -07:00
dependabot[bot]
bc64acf39c chore(deps-dev): bump black from 24.2.0 to 24.3.0
Bumps [black](https://github.com/psf/black) from 24.2.0 to 24.3.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.2.0...24.3.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:01:37 -07:00
56 changed files with 3370 additions and 2758 deletions

2
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,2 @@
# Apply ruff linter rules and standardize code style
c4d7cedeb8b7a8bded8db9a658ae635195071ce3

View file

@ -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 }}

View file

@ -1,24 +1,30 @@
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
name: test
"on": [push, pull_request, workflow_dispatch]
"on":
push:
branches:
- '**'
pull_request:
workflow_dispatch:
jobs:
build:
test-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["3.2", "4.2", "5.0"]
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
django-version: ['4.2', '5.1', '5.2']
exclude:
- django-version: "3.2"
python-version: "3.11"
- django-version: "3.2"
python-version: "3.12"
- django-version: "5.0"
python-version: "3.8"
- django-version: "5.0"
python-version: "3.9"
# Exclude Python 3.9 with Django 5.1 and 5.2
- python-version: '3.9'
django-version: '5.1'
- python-version: '3.9'
django-version: '5.2'
# Exclude Python 3.13 with Django 4.2
- python-version: '3.13'
django-version: '4.2'
steps:
- uses: actions/checkout@v4
@ -30,6 +36,7 @@ jobs:
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.8.4
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
@ -53,6 +60,6 @@ jobs:
poetry run pip check
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

View file

@ -1,7 +1,7 @@
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -9,12 +9,15 @@ repos:
- id: check-added-large-files
- id: mixed-line-ending
- repo: https://github.com/psf/black
rev: 24.2.0
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.12
hooks:
- id: black
language_version: python3
entry: black --target-version=py36
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/remastr/pre-commit-django-check-migrations
rev: v0.1.0

View file

@ -2,6 +2,41 @@
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
## 1.8.1 (2025-06-02)
## What's Changed
- Added support for Django 5.2
- Updated dependencies to their latest versions
## 1.8.0 (2025-02-24)
## What's Changed
- Add database constraints to Value model for data integrity by @Dresdn in https://github.com/jazzband/django-eav2/pull/706
- Fix for issue #648: Ensure choices are valid (value, label) tuples by @altimore in https://github.com/jazzband/django-eav2/pull/707
## 1.7.1 (2024-09-01)
## What's Changed
* Restore backward compatibility for Attribute creation with invalid slugs by @Dresdn in https://github.com/jazzband/django-eav2/pull/639
## 1.7.0 (2024-09-01)
### What's Changed
- Enhance slug validation for Python identifier compliance
- Migrate to ruff
- Drop support for Django 3.2
- Add support for Django 5.1
## 1.6.1 (2024-06-23)
### What's Changed
- Ensure eav.register() Maintains Manager Order by @Dresdn in https://github.com/jazzband/django-eav2/pull/595
- Update downstream dependencies by @Dresdn in https://github.com/jazzband/django-eav2/pull/597
## 1.6.0 (2024-03-14)
### What's Changed

View file

@ -2,30 +2,35 @@
#
# More information on the configuration options is available at:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
from __future__ import annotations
import os
import sys
from typing import Dict
from pathlib import Path
import django
from django.conf import settings
from sphinx.ext.autodoc import between
sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath('../../'))
# For discovery of Python modules
sys.path.insert(0, str(Path().cwd()))
# For finding the django_settings.py file
sys.path.insert(0, str(Path("../../").resolve()))
# Pass settings into configure.
settings.configure(
INSTALLED_APPS=[
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'eav',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"eav",
],
SECRET_KEY=os.environ.get('DJANGO_SECRET_KEY', 'this-is-not-s3cur3'),
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"),
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
)
@ -34,22 +39,22 @@ django.setup()
# -- Project information -----------------------------------------------------
project = 'Django EAV 2'
copyright = '2018, Iwo Herka and team at MAKIMO'
author = '-'
project = "Django EAV 2"
copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001
author = "-"
# The short X.Y version
version = ''
version = ""
# The full version, including alpha/beta/rc tags
release = '0.10.0'
release = "0.10.0"
def setup(app):
"""Use the configuration file itself as an extension."""
app.connect(
'autodoc-process-docstring',
"autodoc-process-docstring",
between(
'^.*IGNORE.*$',
"^.*IGNORE.*$",
exclude=True,
),
)
@ -59,57 +64,57 @@ def setup(app):
# -- General configuration ---------------------------------------------------
extensions = [
'sphinx.ext.napoleon',
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
'sphinx_rtd_theme',
"sphinx.ext.napoleon",
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx_rtd_theme",
]
templates_path = ['_templates']
templates_path = ["_templates"]
source_suffix = '.rst'
source_suffix = ".rst"
master_doc = 'index'
master_doc = "index"
language = 'en'
language = "en"
exclude_patterns = ['build']
exclude_patterns = ["build"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
pygments_style = "sphinx"
# -- Options for HTML output -------------------------------------------------
html_theme = 'sphinx_rtd_theme'
html_theme = "sphinx_rtd_theme"
html_static_path = ['_static']
html_static_path = ["_static"]
html_sidebars = {
'index': ['sidebarintro.html', 'localtoc.html'],
'**': [
'sidebarintro.html',
'localtoc.html',
'relations.html',
'searchbox.html',
"index": ["sidebarintro.html", "localtoc.html"],
"**": [
"sidebarintro.html",
"localtoc.html",
"relations.html",
"searchbox.html",
],
}
htmlhelp_basename = 'DjangoEAV2doc'
htmlhelp_basename = "DjangoEAV2doc"
# -- Options for LaTeX output ------------------------------------------------
latex_elements: Dict[str, str] = {}
latex_elements: dict[str, str] = {}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', '-', 'manual'),
(master_doc, "DjangoEAV2.tex", "Django EAV 2 Documentation", "-", "manual"),
]
@ -120,8 +125,8 @@ latex_documents = [
man_pages = [
(
master_doc,
'djangoeav2',
'Django EAV 2 Documentation',
"djangoeav2",
"Django EAV 2 Documentation",
[author],
1,
),
@ -136,12 +141,12 @@ man_pages = [
texinfo_documents = [
(
master_doc,
'DjangoEAV2',
'Django EAV 2 Documentation',
"DjangoEAV2",
"Django EAV 2 Documentation",
author,
'DjangoEAV2',
'One line description of project.',
'Miscellaneous',
"DjangoEAV2",
"One line description of project.",
"Miscellaneous",
),
]
@ -150,7 +155,7 @@ texinfo_documents = [
# -- Options for intersphinx extension ---------------------------------------
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
# -- Autodoc configuration ---------------------------------------------------

View file

@ -1,6 +1,8 @@
"""This module contains classes used for admin integration."""
from typing import Any, Dict, List, Optional, Union
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
from django.contrib import admin
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
@ -9,8 +11,13 @@ from django.utils.safestring import mark_safe
from eav.models import Attribute, EnumGroup, EnumValue, Value
if TYPE_CHECKING:
from collections.abc import Sequence
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
some_attribute = ClassVar[Dict[str, str]]
class BaseEntityAdmin(ModelAdmin):
"""Custom admin model to support dynamic EAV fieldsets.
@ -26,7 +33,7 @@ class BaseEntityAdmin(ModelAdmin):
"""
eav_fieldset_title: str = "EAV Attributes"
eav_fieldset_description: Optional[str] = None
eav_fieldset_description: str | None = None
def render_change_form(self, request, context, *args, **kwargs):
"""Dynamically modifies the admin form to include EAV fields.
@ -45,7 +52,7 @@ class BaseEntityAdmin(ModelAdmin):
Returns:
HttpResponse object representing the rendered change form.
"""
form = context['adminform'].form
form = context["adminform"].form
# Identify EAV fields based on the form instance's configuration.
eav_fields = self._get_eav_fields(form.instance)
@ -55,7 +62,7 @@ class BaseEntityAdmin(ModelAdmin):
return super().render_change_form(request, context, *args, **kwargs)
# Get the non-EAV fieldsets and then append our own
fieldsets = list(self.get_fieldsets(request, kwargs['obj']))
fieldsets = list(self.get_fieldsets(request, kwargs["obj"]))
fieldsets.append(self._get_eav_fieldset(eav_fields))
# Reconstruct the admin form with updated fieldsets.
@ -65,18 +72,18 @@ class BaseEntityAdmin(ModelAdmin):
# Clear prepopulated fields on a view-only form to avoid a crash.
(
self.prepopulated_fields
if self.has_change_permission(request, kwargs['obj'])
if self.has_change_permission(request, kwargs["obj"])
else {}
),
readonly_fields=self.readonly_fields,
model_admin=self,
)
media = mark_safe(context['media'] + adminform.media)
media = mark_safe(context["media"] + adminform.media) # noqa: S308
context.update(adminform=adminform, media=media)
return super().render_change_form(request, context, *args, **kwargs)
def _get_eav_fields(self, instance) -> List[str]:
def _get_eav_fields(self, instance) -> list[str]:
"""Retrieves a list of EAV field slugs for the given instance.
Args:
@ -85,24 +92,24 @@ class BaseEntityAdmin(ModelAdmin):
Returns:
A list of strings representing the slugs of EAV fields.
"""
entity = getattr(instance, instance._eav_config_cls.eav_attr)
return list(entity.get_all_attributes().values_list('slug', flat=True))
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
return list(entity.get_all_attributes().values_list("slug", flat=True))
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
Generates a list representing a fieldset specifically for Entity-Attribute-Value (EAV) fields,
intended to be appended to the admin form's fieldsets configuration. This facilitates the
dynamic inclusion of EAV fields within the Django admin interface by creating a designated
section for these attributes.
Generates a list representing a fieldset specifically for Entity-Attribute-Value
(EAV) fields, intended to be appended to the admin form's fieldsets
configuration. This facilitates the dynamic inclusion of EAV fields within the
Django admin interface by creating a designated section for these attributes.
Args:
eav_fields (List[str]): A list of slugs representing the EAV fields to be included
in the EAV Attributes fieldset.
eav_fields (List[str]): A list of slugs representing the EAV fields to be
included in the EAV Attributes fieldset.
"""
return [
self.eav_fieldset_title,
{'fields': eav_fields, 'description': self.eav_fieldset_description},
{"fields": eav_fields, "description": self.eav_fieldset_description},
]
@ -114,9 +121,9 @@ class BaseEntityInlineFormSet(BaseInlineFormSet):
def add_fields(self, form, index):
if self.instance:
setattr(form.instance, self.fk.name, self.instance)
form._build_dynamic_fields()
form._build_dynamic_fields() # noqa: SLF001
super(BaseEntityInlineFormSet, self).add_fields(form, index)
super().add_fields(form, index)
class BaseEntityInline(InlineModelAdmin):
@ -147,12 +154,12 @@ class BaseEntityInline(InlineModelAdmin):
instance = self.model(**kw)
form = formset.form(request.POST, instance=instance)
return [(None, {'fields': form.fields.keys()})]
return [(None, {"fields": form.fields.keys()})]
class AttributeAdmin(ModelAdmin):
list_display = ('name', 'slug', 'datatype', 'description')
prepopulated_fields = {'slug': ('name',)}
list_display = ("name", "slug", "datatype", "description")
prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)}
admin.site.register(Attribute, AttributeAdmin)

View file

@ -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

View file

@ -1,2 +1,2 @@
class IllegalAssignmentException(Exception):
class IllegalAssignmentException(Exception): # noqa: N818
pass

View file

@ -16,7 +16,7 @@ class EavDatatypeField(models.CharField):
:class:`~eav.models.Attribute` that is already used by
:class:`~eav.models.Value` objects.
"""
super(EavDatatypeField, self).validate(value, instance)
super().validate(value, instance)
if not instance.pk:
return
@ -31,8 +31,9 @@ class EavDatatypeField(models.CharField):
if instance.value_set.count():
raise ValidationError(
_(
'You cannot change the datatype of an attribute that is already in use.'
)
"You cannot change the datatype of an "
+ "attribute that is already in use.",
),
)
@ -42,21 +43,21 @@ class CSVField(models.TextField): # (models.Field):
def __init__(self, separator=";", *args, **kwargs):
self.separator = separator
kwargs.setdefault('default', "")
kwargs.setdefault("default", "")
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.separator != self.default_separator:
kwargs['separator'] = self.separator
kwargs["separator"] = self.separator
return name, path, args, kwargs
def formfield(self, **kwargs):
defaults = {'form_class': CSVFormField}
defaults = {"form_class": CSVFormField}
defaults.update(kwargs)
return super().formfield(**defaults)
def from_db_value(self, value, expression, connection, context=None):
def from_db_value(self, value, expression, connection):
if value is None:
return []
return value.split(self.separator)
@ -73,8 +74,9 @@ class CSVField(models.TextField): # (models.Field):
return ""
if isinstance(value, str):
return value
elif isinstance(value, list):
if isinstance(value, list):
return self.separator.join(value)
return value
def value_to_string(self, obj):
value = self.value_from_object(obj)

View file

@ -1,6 +1,9 @@
"""This module contains forms used for admin integration."""
from __future__ import annotations
from copy import deepcopy
from typing import ClassVar
from django.contrib.admin.widgets import AdminSplitDateTime
from django.core.exceptions import ValidationError
@ -21,14 +24,14 @@ from eav.widgets import CSVWidget
class CSVFormField(Field):
message = _('Enter comma-separated-values. eg: one;two;three.')
code = 'invalid'
message = _("Enter comma-separated-values. eg: one;two;three.")
code = "invalid"
widget = CSVWidget
default_separator = ";"
def __init__(self, *args, **kwargs):
kwargs.pop('max_length', None)
self.separator = kwargs.pop('separator', self.default_separator)
kwargs.pop("max_length", None)
self.separator = kwargs.pop("separator", self.default_separator)
super().__init__(*args, **kwargs)
def to_python(self, value):
@ -38,9 +41,8 @@ class CSVFormField(Field):
def validate(self, field_value):
super().validate(field_value)
try:
isinstance(field_value, list)
except ValidationError:
if not isinstance(field_value, list):
raise ValidationError(self.message, code=self.code)
@ -70,20 +72,20 @@ class BaseDynamicEntityForm(ModelForm):
===== =============
"""
FIELD_CLASSES = {
'text': CharField,
'float': FloatField,
'int': IntegerField,
'date': SplitDateTimeField,
'bool': BooleanField,
'enum': ChoiceField,
'json': JSONField,
'csv': CSVFormField,
FIELD_CLASSES: ClassVar[dict[str, Field]] = {
"text": CharField,
"float": FloatField,
"int": IntegerField,
"date": SplitDateTimeField,
"bool": BooleanField,
"enum": ChoiceField,
"json": JSONField,
"csv": CSVFormField,
}
def __init__(self, data=None, *args, **kwargs):
super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs)
config_cls = self.instance._eav_config_cls
super().__init__(data, *args, **kwargs)
config_cls = self.instance._eav_config_cls # noqa: SLF001
self.entity = getattr(self.instance, config_cls.eav_attr)
self._build_dynamic_fields()
@ -95,35 +97,35 @@ class BaseDynamicEntityForm(ModelForm):
value = getattr(self.entity, attribute.slug)
defaults = {
'label': attribute.name.capitalize(),
'required': attribute.required,
'help_text': attribute.help_text,
'validators': attribute.get_validators(),
"label": attribute.name.capitalize(),
"required": attribute.required,
"help_text": attribute.help_text,
"validators": attribute.get_validators(),
}
datatype = attribute.datatype
if datatype == attribute.TYPE_ENUM:
values = attribute.get_choices().values_list('id', 'value')
choices = [('', '-----')] + list(values)
defaults.update({'choices': choices})
values = attribute.get_choices().values_list("id", "value")
choices = [("", ""), ("-----", "-----"), *list(values)]
defaults.update({"choices": choices})
if value:
defaults.update({'initial': value.pk})
defaults.update({"initial": value.pk})
elif datatype == attribute.TYPE_DATE:
defaults.update({'widget': AdminSplitDateTime})
defaults.update({"widget": AdminSplitDateTime})
elif datatype == attribute.TYPE_OBJECT:
continue
MappedField = self.FIELD_CLASSES[datatype]
MappedField = self.FIELD_CLASSES[datatype] # noqa: N806
self.fields[attribute.slug] = MappedField(**defaults)
# Fill initial data (if attribute was already defined).
if value and not datatype == attribute.TYPE_ENUM:
if value and datatype != attribute.TYPE_ENUM:
self.initial[attribute.slug] = value
def save(self, commit=True):
def save(self, *, commit=True):
"""
Saves this ``form``'s cleaned_data into model instance
``self.instance`` and related EAV attributes. Returns ``instance``.
@ -131,23 +133,20 @@ class BaseDynamicEntityForm(ModelForm):
if self.errors:
raise ValueError(
_(
'The %s could not be saved because the data'
'didn\'t validate.' % self.instance._meta.object_name
"The %s could not be saved because the data didn't validate.",
)
% self.instance._meta.object_name, # noqa: SLF001
)
# Create entity instance, don't save yet.
instance = super(BaseDynamicEntityForm, self).save(commit=False)
instance = super().save(commit=False)
# Assign attributes.
for attribute in self.entity.get_all_attributes():
value = self.cleaned_data.get(attribute.slug)
if attribute.datatype == attribute.TYPE_ENUM:
if value:
value = attribute.enum_group.values.get(pk=value)
else:
value = None
value = attribute.enum_group.values.get(pk=value) if value else None
setattr(self.entity, attribute.slug, value)

View file

@ -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"

View file

@ -1,6 +1,5 @@
import uuid
from functools import partial
from typing import Type
from django.conf import settings
from django.db import models
@ -24,7 +23,7 @@ _FIELD_MAPPING = {
}
def get_pk_format() -> Type[models.Field]:
def get_pk_format() -> models.Field:
"""
Get the primary key field format based on the Django settings.

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import secrets
import string
from typing import Final
@ -7,16 +9,53 @@ from django.utils.text import slugify
SLUGFIELD_MAX_LENGTH: Final = 50
def generate_slug(name: str) -> str:
"""Generates a valid slug based on ``name``."""
slug = slugify(name, allow_unicode=False)
def non_identifier_chars() -> dict[str, str]:
"""Generate a mapping of non-identifier characters to their Unicode representations.
Returns:
dict[str, str]: A dictionary where keys are special characters and values
are their Unicode representations.
"""
# Start with all printable characters
all_chars = string.printable
# Filter out characters that are valid in Python identifiers
special_chars = [
char for char in all_chars if not char.isalnum() and char not in ["_", " "]
]
return {char: f"u{ord(char):04x}" for char in special_chars}
def generate_slug(value: str) -> str:
"""Generate a valid slug based on the given value.
This function converts the input value into a Python-identifier-friendly slug.
It handles special characters, ensures a valid Python identifier, and truncates
the result to fit within the maximum allowed length.
Args:
value (str): The input string to generate a slug from.
Returns:
str: A valid Python identifier slug, with a maximum
length of SLUGFIELD_MAX_LENGTH.
"""
for char, replacement in non_identifier_chars().items():
value = value.replace(char, replacement)
# Use slugify to create a URL-friendly base slug.
slug = slugify(value, allow_unicode=False).replace("-", "_")
# If slugify returns an empty string, generate a fallback
# slug to ensure it's never empty.
if not slug:
# Fallback to ensure a slug is always generated by using a random one
chars = string.ascii_lowercase + string.digits
randstr = ''.join(secrets.choice(chars) for _ in range(8))
slug = 'rand-{0}'.format(randstr)
randstr = "".join(secrets.choice(chars) for _ in range(8))
slug = f"rand_{randstr}"
slug = slug.encode('utf-8', 'surrogateescape').decode()
# Ensure the slug doesn't start with a digit to make it a valid Python identifier.
if slug[0].isdigit():
slug = "_" + slug
return slug[:SLUGFIELD_MAX_LENGTH]

View file

@ -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 = {}

View file

@ -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",
),
),
]

View file

@ -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"),
),
]

View file

@ -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",
),
),
]

View file

@ -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),
),
]

View file

@ -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",
),
),
]

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -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",
),
),
]

View file

@ -8,171 +8,171 @@ class Migration(migrations.Migration):
"""Define verbose naming for models and fields."""
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('eav', '0008_use_native_slugfield'),
("contenttypes", "0002_remove_content_type_name"),
("eav", "0008_use_native_slugfield"),
]
operations = [
migrations.AlterModelOptions(
name='attribute',
name="attribute",
options={
'ordering': ['name'],
'verbose_name': 'Attribute',
'verbose_name_plural': 'Attributes',
"ordering": ["name"],
"verbose_name": "Attribute",
"verbose_name_plural": "Attributes",
},
),
migrations.AlterModelOptions(
name='enumgroup',
name="enumgroup",
options={
'verbose_name': 'EnumGroup',
'verbose_name_plural': 'EnumGroups',
"verbose_name": "EnumGroup",
"verbose_name_plural": "EnumGroups",
},
),
migrations.AlterModelOptions(
name='enumvalue',
name="enumvalue",
options={
'verbose_name': 'EnumValue',
'verbose_name_plural': 'EnumValues',
"verbose_name": "EnumValue",
"verbose_name_plural": "EnumValues",
},
),
migrations.AlterModelOptions(
name='value',
options={'verbose_name': 'Value', 'verbose_name_plural': 'Values'},
name="value",
options={"verbose_name": "Value", "verbose_name_plural": "Values"},
),
migrations.AlterField(
model_name='attribute',
name='entity_ct',
model_name="attribute",
name="entity_ct",
field=models.ManyToManyField(
blank=True,
to='contenttypes.contenttype',
verbose_name='Entity content type',
to="contenttypes.contenttype",
verbose_name="Entity content type",
),
),
migrations.AlterField(
model_name='value',
name='entity_ct',
model_name="value",
name="entity_ct",
field=models.ForeignKey(
on_delete=models.deletion.PROTECT,
related_name='value_entities',
to='contenttypes.contenttype',
verbose_name='Entity ct',
related_name="value_entities",
to="contenttypes.contenttype",
verbose_name="Entity ct",
),
),
migrations.AlterField(
model_name='value',
name='entity_id',
model_name="value",
name="entity_id",
field=models.IntegerField(
blank=True,
null=True,
verbose_name='Entity id',
verbose_name="Entity id",
),
),
migrations.AlterField(
model_name='value',
name='entity_uuid',
model_name="value",
name="entity_uuid",
field=models.UUIDField(
blank=True,
null=True,
verbose_name='Entity uuid',
verbose_name="Entity uuid",
),
),
migrations.AlterField(
model_name='value',
name='generic_value_ct',
model_name="value",
name="generic_value_ct",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.PROTECT,
related_name='value_values',
to='contenttypes.contenttype',
verbose_name='Generic value content type',
related_name="value_values",
to="contenttypes.contenttype",
verbose_name="Generic value content type",
),
),
migrations.AlterField(
model_name='value',
name='generic_value_id',
model_name="value",
name="generic_value_id",
field=models.IntegerField(
blank=True,
null=True,
verbose_name='Generic value id',
verbose_name="Generic value id",
),
),
migrations.AlterField(
model_name='value',
name='value_bool',
model_name="value",
name="value_bool",
field=models.BooleanField(
blank=True,
null=True,
verbose_name='Value bool',
verbose_name="Value bool",
),
),
migrations.AlterField(
model_name='value',
name='value_csv',
model_name="value",
name="value_csv",
field=CSVField(
blank=True,
default='',
default="",
null=True,
verbose_name='Value CSV',
verbose_name="Value CSV",
),
),
migrations.AlterField(
model_name='value',
name='value_date',
model_name="value",
name="value_date",
field=models.DateTimeField(
blank=True,
null=True,
verbose_name='Value date',
verbose_name="Value date",
),
),
migrations.AlterField(
model_name='value',
name='value_enum',
model_name="value",
name="value_enum",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.PROTECT,
related_name='eav_values',
to='eav.enumvalue',
verbose_name='Value enum',
related_name="eav_values",
to="eav.enumvalue",
verbose_name="Value enum",
),
),
migrations.AlterField(
model_name='value',
name='value_float',
model_name="value",
name="value_float",
field=models.FloatField(
blank=True,
null=True,
verbose_name='Value float',
verbose_name="Value float",
),
),
migrations.AlterField(
model_name='value',
name='value_int',
model_name="value",
name="value_int",
field=models.BigIntegerField(
blank=True,
null=True,
verbose_name='Value int',
verbose_name="Value int",
),
),
migrations.AlterField(
model_name='value',
name='value_json',
model_name="value",
name="value_json",
field=models.JSONField(
blank=True,
default=dict,
encoder=DjangoJSONEncoder,
null=True,
verbose_name='Value JSON',
verbose_name="Value JSON",
),
),
migrations.AlterField(
model_name='value',
name='value_text',
model_name="value",
name="value_text",
field=models.TextField(
blank=True,
null=True,
verbose_name='Value text',
verbose_name="Value text",
),
),
]

View file

@ -5,13 +5,13 @@ class Migration(migrations.Migration):
"""Migration to use BigAutoField as default for all models."""
dependencies = [
('eav', '0009_enchance_naming'),
("eav", "0009_enchance_naming"),
]
operations = [
migrations.AlterField(
model_name='attribute',
name='id',
model_name="attribute",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
@ -19,8 +19,8 @@ class Migration(migrations.Migration):
),
),
migrations.AlterField(
model_name='enumgroup',
name='id',
model_name="enumgroup",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
@ -28,8 +28,8 @@ class Migration(migrations.Migration):
),
),
migrations.AlterField(
model_name='enumvalue',
name='id',
model_name="enumvalue",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
@ -37,8 +37,8 @@ class Migration(migrations.Migration):
),
),
migrations.AlterField(
model_name='value',
name='id',
model_name="value",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,

View 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"),
),
]

View 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",
),
),
]

View file

@ -17,9 +17,9 @@ from .value import Value
__all__ = [
"Attribute",
"EAVModelMeta",
"Entity",
"EnumGroup",
"EnumValue",
"Value",
"Entity",
"EAVModelMeta",
]

View file

@ -1,6 +1,9 @@
# ruff: noqa: UP007
from typing import TYPE_CHECKING, Optional, Tuple # noqa: UP035
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@ -79,40 +82,36 @@ class Attribute(models.Model):
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu.values.add(yes, no, unknown)
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
Attribute.objects.create(name='has fever?',
datatype=Attribute.TYPE_ENUM,
enum_group=ynu
)
# = <Attribute: has fever? (Multiple Choice)>
.. warning:: Once an Attribute has been used by an entity, you can not
change it's datatype.
"""
objects = AttributeManager()
class Meta:
ordering = ['name']
verbose_name = _('Attribute')
verbose_name_plural = _('Attributes')
TYPE_TEXT = 'text'
TYPE_FLOAT = 'float'
TYPE_INT = 'int'
TYPE_DATE = 'date'
TYPE_BOOLEAN = 'bool'
TYPE_OBJECT = 'object'
TYPE_ENUM = 'enum'
TYPE_JSON = 'json'
TYPE_CSV = 'csv'
TYPE_TEXT = "text"
TYPE_FLOAT = "float"
TYPE_INT = "int"
TYPE_DATE = "date"
TYPE_BOOLEAN = "bool"
TYPE_OBJECT = "object"
TYPE_ENUM = "enum"
TYPE_JSON = "json"
TYPE_CSV = "csv"
DATATYPE_CHOICES = (
(TYPE_TEXT, _('Text')),
(TYPE_DATE, _('Date')),
(TYPE_FLOAT, _('Float')),
(TYPE_INT, _('Integer')),
(TYPE_BOOLEAN, _('True / False')),
(TYPE_OBJECT, _('Django Object')),
(TYPE_ENUM, _('Multiple Choice')),
(TYPE_JSON, _('JSON Object')),
(TYPE_CSV, _('Comma-Separated-Value')),
(TYPE_TEXT, _("Text")),
(TYPE_DATE, _("Date")),
(TYPE_FLOAT, _("Float")),
(TYPE_INT, _("Integer")),
(TYPE_BOOLEAN, _("True / False")),
(TYPE_OBJECT, _("Django Object")),
(TYPE_ENUM, _("Multiple Choice")),
(TYPE_JSON, _("JSON Object")),
(TYPE_CSV, _("Comma-Separated-Value")),
)
# Core attributes
@ -121,13 +120,13 @@ class Attribute(models.Model):
datatype = EavDatatypeField(
choices=DATATYPE_CHOICES,
max_length=6,
verbose_name=_('Data Type'),
verbose_name=_("Data Type"),
)
name = models.CharField(
max_length=CHARFIELD_LENGTH,
help_text=_('User-friendly attribute name'),
verbose_name=_('Name'),
help_text=_("User-friendly attribute name"),
verbose_name=_("Name"),
)
"""
@ -139,8 +138,8 @@ class Attribute(models.Model):
max_length=SLUGFIELD_MAX_LENGTH,
db_index=True,
unique=True,
help_text=_('Short unique attribute label'),
verbose_name=_('Slug'),
help_text=_("Short unique attribute label"),
verbose_name=_("Slug"),
)
"""
@ -151,13 +150,13 @@ class Attribute(models.Model):
"""
required = models.BooleanField(
default=False,
verbose_name=_('Required'),
verbose_name=_("Required"),
)
entity_ct = models.ManyToManyField(
ContentType,
blank=True,
verbose_name=_('Entity content type'),
verbose_name=_("Entity content type"),
)
"""
This field allows you to specify a relationship with any number of content types.
@ -166,49 +165,67 @@ class Attribute(models.Model):
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
"""
enum_group: "ForeignKey[Optional[EnumGroup]]" = ForeignKey(
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
"eav.EnumGroup",
on_delete=models.PROTECT,
blank=True,
null=True,
verbose_name=_('Choice Group'),
verbose_name=_("Choice Group"),
)
description = models.CharField(
max_length=256,
blank=True,
null=True,
help_text=_('Short description'),
verbose_name=_('Description'),
default="",
help_text=_("Short description"),
verbose_name=_("Description"),
)
# Useful meta-information
display_order = models.PositiveIntegerField(
default=1,
verbose_name=_('Display order'),
verbose_name=_("Display order"),
)
modified = models.DateTimeField(
auto_now=True,
verbose_name=_('Modified'),
verbose_name=_("Modified"),
)
created = models.DateTimeField(
default=timezone.now,
editable=False,
verbose_name=_('Created'),
verbose_name=_("Created"),
)
def __str__(self) -> str:
return f'{self.name} ({self.get_datatype_display()})'
objects = AttributeManager()
def natural_key(self) -> Tuple[str, str]: # noqa: UP006
class Meta:
ordering = ("name",)
verbose_name = _("Attribute")
verbose_name_plural = _("Attributes")
def __str__(self) -> str:
return f"{self.name} ({self.get_datatype_display()})"
def save(self, *args, **kwargs):
"""
Saves the Attribute and auto-generates a slug field
if one wasn't provided.
"""
if not self.slug:
self.slug = generate_slug(self.name)
self.full_clean()
super().save(*args, **kwargs)
def natural_key(self) -> tuple[str, str]:
"""
Retrieve the natural key for the Attribute instance.
The natural key for an Attribute is defined by its `name` and `slug`. This method
returns a tuple containing these two attributes of the instance.
The natural key for an Attribute is defined by its `name` and `slug`. This
method returns a tuple containing these two attributes of the instance.
Returns
-------
@ -233,19 +250,19 @@ class Attribute(models.Model):
method to look elsewhere for additional attribute specific
validators to return as well as the default, built-in one.
"""
DATATYPE_VALIDATORS = {
'text': validate_text,
'float': validate_float,
'int': validate_int,
'date': validate_date,
'bool': validate_bool,
'object': validate_object,
'enum': validate_enum,
'json': validate_json,
'csv': validate_csv,
datatype_validators = {
"text": validate_text,
"float": validate_float,
"int": validate_int,
"date": validate_date,
"bool": validate_bool,
"object": validate_object,
"enum": validate_enum,
"json": validate_json,
"csv": validate_csv,
}
return [DATATYPE_VALIDATORS[self.datatype]]
return [datatype_validators[self.datatype]]
def validate_value(self, value):
"""
@ -260,21 +277,10 @@ class Attribute(models.Model):
value = value.value
if not self.enum_group.values.filter(value=value).exists():
raise ValidationError(
_('%(val)s is not a valid choice for %(attr)s')
% {'val': value, 'attr': self},
_("%(val)s is not a valid choice for %(attr)s")
% {"val": value, "attr": self},
)
def save(self, *args, **kwargs):
"""
Saves the Attribute and auto-generates a slug field
if one wasn't provided.
"""
if not self.slug:
self.slug = generate_slug(self.name)
self.full_clean()
super().save(*args, **kwargs)
def clean(self):
"""
Validates the attribute. Will raise ``ValidationError`` if the
@ -283,12 +289,33 @@ class Attribute(models.Model):
"""
if self.datatype == self.TYPE_ENUM and not self.enum_group:
raise ValidationError(
_('You must set the choice group for multiple choice attributes'),
_("You must set the choice group for multiple choice attributes"),
)
if self.datatype != self.TYPE_ENUM and self.enum_group:
raise ValidationError(
_('You can only assign a choice group to multiple choice attributes'),
_("You can only assign a choice group to multiple choice attributes"),
)
def clean_fields(self, exclude=None):
"""Perform field-specific validation on the model's fields.
This method extends the default field cleaning process to include
custom validation for the slug field.
Args:
exclude (list): Fields to exclude from cleaning.
Raises:
ValidationError: If the slug is not a valid Python identifier.
"""
super().clean_fields(exclude=exclude)
if not self.slug.isidentifier():
warnings.warn(
f"Slug '{self.slug}' is not a valid Python identifier. "
+ "Consider updating it.",
stacklevel=3,
)
def get_choices(self):
@ -318,20 +345,20 @@ class Attribute(models.Model):
ct = ContentType.objects.get_for_model(entity)
entity_filter = {
'entity_ct': ct,
'attribute': self,
f'{get_entity_pk_type(entity)}': entity.pk,
"entity_ct": ct,
"attribute": self,
f"{get_entity_pk_type(entity)}": entity.pk,
}
try:
value_obj = self.value_set.get(**entity_filter)
except Value.DoesNotExist:
if value is None or value == '':
if value is None or value == "":
return
value_obj = Value.objects.create(**entity_filter)
if value is None or value == '':
if value is None or value == "":
value_obj.delete()
return

View file

@ -24,8 +24,8 @@ class Entity:
model instance we are attached to is saved. This allows us to call
:meth:`validate_attributes` before the entity is saved.
"""
instance = kwargs['instance']
entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr)
instance = kwargs["instance"]
entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001
entity.validate_attributes()
@staticmethod
@ -34,8 +34,8 @@ class Entity:
Post save handler attached to self.instance. Calls :meth:`save` when
the model instance we are attached to is saved.
"""
instance = kwargs['instance']
entity = getattr(instance, instance._eav_config_cls.eav_attr)
instance = kwargs["instance"]
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
entity.save()
def __init__(self, instance) -> None:
@ -58,14 +58,14 @@ class Entity:
class:`Value` object, otherwise it hasn't been set, so it returns
None.
"""
if not name.startswith('_'):
if not name.startswith("_"):
try:
attribute = self.get_attribute_by_slug(name)
except Attribute.DoesNotExist:
except Attribute.DoesNotExist as err:
raise AttributeError(
_('%(obj)s has no EAV attribute named %(attr)s')
% {'obj': self.instance, 'attr': name},
)
_("%(obj)s has no EAV attribute named %(attr)s")
% {"obj": self.instance, "attr": name},
) from err
try:
return self.get_value_by_attribute(attribute).value
@ -79,9 +79,9 @@ class Entity:
Return a query set of all :class:`Attribute` objects that can be set
for this entity.
"""
return self.instance._eav_config_cls.get_attributes(
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
instance=self.instance,
).order_by('display_order')
).order_by("display_order")
def _hasattr(self, attribute_slug):
"""
@ -137,28 +137,29 @@ class Entity:
if value is None:
if attribute.required:
raise ValidationError(
_(f'{attribute.slug} EAV field cannot be blank'),
_("%s EAV field cannot be blank") % attribute.slug,
)
else:
try:
attribute.validate_value(value)
except ValidationError as e:
except ValidationError as err:
raise ValidationError(
_('%(attr)s EAV field %(err)s')
% {'attr': attribute.slug, 'err': e},
)
_("%(attr)s EAV field %(err)s")
% {"attr": attribute.slug, "err": err},
) from err
illegal = values_dict or (
self.get_object_attributes() - self.get_all_attribute_slugs()
)
if illegal:
raise IllegalAssignmentException(
'Instance of the class {} cannot have values for attributes: {}.'.format(
self.instance.__class__,
', '.join(illegal),
),
message = (
"Instance of the class {} cannot have values for attributes: {}."
).format(
self.instance.__class__,
", ".join(illegal),
)
raise IllegalAssignmentException(message)
def get_values_dict(self):
return {v.attribute.slug: v.value for v in self.get_values()}
@ -166,15 +167,15 @@ class Entity:
def get_values(self):
"""Get all set :class:`Value` objects for self.instance."""
entity_filter = {
'entity_ct': self.ct,
f'{get_entity_pk_type(self.instance)}': self.instance.pk,
"entity_ct": self.ct,
f"{get_entity_pk_type(self.instance)}": self.instance.pk,
}
return Value.objects.filter(**entity_filter).select_related()
def get_all_attribute_slugs(self):
"""Returns a list of slugs for all attributes available to this entity."""
return set(self.get_all_attributes().values_list('slug', flat=True))
return set(self.get_all_attributes().values_list("slug", flat=True))
def get_attribute_by_slug(self, slug):
"""Returns a single :class:`Attribute` with *slug*."""
@ -189,7 +190,7 @@ class Entity:
Returns entity instance attributes, except for
``instance`` and ``ct`` which are used internally.
"""
return set(copy(self.__dict__).keys()) - {'instance', 'ct'}
return set(copy(self.__dict__).keys()) - {"instance", "ct"}
def __iter__(self):
"""

View file

@ -1,4 +1,6 @@
from typing import TYPE_CHECKING, Any, Tuple
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from django.db import models
from django.db.models import ManyToManyField
@ -21,33 +23,33 @@ class EnumGroup(models.Model):
See :class:`EnumValue` for an example.
"""
objects = EnumGroupManager()
class Meta:
verbose_name = _('EnumGroup')
verbose_name_plural = _('EnumGroups')
id = get_pk_format()
name = models.CharField(
unique=True,
max_length=CHARFIELD_LENGTH,
verbose_name=_('Name'),
verbose_name=_("Name"),
)
values: "ManyToManyField[EnumValue, Any]" = ManyToManyField(
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
"eav.EnumValue",
verbose_name=_('Enum group'),
verbose_name=_("Enum group"),
)
objects = EnumGroupManager()
class Meta:
verbose_name = _("EnumGroup")
verbose_name_plural = _("EnumGroups")
def __str__(self) -> str:
"""String representation of `EnumGroup` instance."""
return str(self.name)
def __repr__(self) -> str:
"""String representation of `EnumGroup` object."""
return f'<EnumGroup {self.name}>'
return f"<EnumGroup {self.name}>"
def natural_key(self) -> Tuple[str]:
def natural_key(self) -> tuple[str]:
"""
Retrieve the natural key for the EnumGroup instance.

View file

@ -1,4 +1,4 @@
from typing import Tuple
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -35,21 +35,21 @@ class EnumValue(models.Model):
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
"""
objects = EnumValueManager()
class Meta:
verbose_name = _('EnumValue')
verbose_name_plural = _('EnumValues')
id = get_pk_format()
value = models.CharField(
_('Value'),
_("Value"),
db_index=True,
unique=True,
max_length=SLUGFIELD_MAX_LENGTH,
)
objects = EnumValueManager()
class Meta:
verbose_name = _("EnumValue")
verbose_name_plural = _("EnumValues")
def __str__(self) -> str:
"""String representation of `EnumValue` instance."""
return str(
@ -58,9 +58,9 @@ class EnumValue(models.Model):
def __repr__(self) -> str:
"""String representation of `EnumValue` object."""
return f'<EnumValue {self.value}>'
return f"<EnumValue {self.value}>"
def natural_key(self) -> Tuple[str]:
def natural_key(self) -> tuple[str]:
"""
Retrieve the natural key for the EnumValue instance.

View file

@ -1,6 +1,7 @@
# ruff: noqa: UP007
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Tuple
from typing import TYPE_CHECKING, ClassVar, Optional
from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType
@ -43,20 +44,14 @@ class Value(models.Model):
# = <Value: crazy_dev_user - Fav Drink: "red bull">
"""
objects = ValueManager()
class Meta:
verbose_name = _('Value')
verbose_name_plural = _('Values')
id = get_pk_format()
# Direct foreign keys
attribute: "ForeignKey[Attribute]" = ForeignKey(
attribute: ForeignKey[Attribute] = ForeignKey(
"eav.Attribute",
db_index=True,
on_delete=models.PROTECT,
verbose_name=_('Attribute'),
verbose_name=_("Attribute"),
)
# Entity generic relationships. Rather than rely on database casting,
@ -65,73 +60,73 @@ class Value(models.Model):
entity_id = models.IntegerField(
blank=True,
null=True,
verbose_name=_('Entity id'),
verbose_name=_("Entity id"),
)
entity_uuid = models.UUIDField(
blank=True,
null=True,
verbose_name=_('Entity uuid'),
verbose_name=_("Entity uuid"),
)
entity_ct = ForeignKey(
ContentType,
on_delete=models.PROTECT,
related_name='value_entities',
verbose_name=_('Entity ct'),
related_name="value_entities",
verbose_name=_("Entity ct"),
)
entity_pk_int = generic.GenericForeignKey(
ct_field='entity_ct',
fk_field='entity_id',
ct_field="entity_ct",
fk_field="entity_id",
)
entity_pk_uuid = generic.GenericForeignKey(
ct_field='entity_ct',
fk_field='entity_uuid',
ct_field="entity_ct",
fk_field="entity_uuid",
)
# Model attributes
created = models.DateTimeField(
default=timezone.now,
verbose_name=_('Created'),
verbose_name=_("Created"),
)
modified = models.DateTimeField(
auto_now=True,
verbose_name=_('Modified'),
verbose_name=_("Modified"),
)
# Value attributes
value_bool = models.BooleanField(
blank=True,
null=True,
verbose_name=_('Value bool'),
verbose_name=_("Value bool"),
)
value_csv = CSVField(
blank=True,
null=True,
verbose_name=_('Value CSV'),
verbose_name=_("Value CSV"),
)
value_date = models.DateTimeField(
blank=True,
null=True,
verbose_name=_('Value date'),
verbose_name=_("Value date"),
)
value_float = models.FloatField(
blank=True,
null=True,
verbose_name=_('Value float'),
verbose_name=_("Value float"),
)
value_int = models.BigIntegerField(
blank=True,
null=True,
verbose_name=_('Value int'),
verbose_name=_("Value int"),
)
value_text = models.TextField(
blank=True,
null=True,
verbose_name=_('Value text'),
default="",
verbose_name=_("Value text"),
)
value_json = models.JSONField(
@ -139,23 +134,23 @@ class Value(models.Model):
encoder=DjangoJSONEncoder,
blank=True,
null=True,
verbose_name=_('Value JSON'),
verbose_name=_("Value JSON"),
)
value_enum: "ForeignKey[Optional[EnumValue]]" = ForeignKey(
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
"eav.EnumValue",
blank=True,
null=True,
on_delete=models.PROTECT,
related_name='eav_values',
verbose_name=_('Value enum'),
related_name="eav_values",
verbose_name=_("Value enum"),
)
# Value object relationship
generic_value_id = models.IntegerField(
blank=True,
null=True,
verbose_name=_('Generic value id'),
verbose_name=_("Generic value id"),
)
generic_value_ct = ForeignKey(
@ -163,29 +158,38 @@ class Value(models.Model):
blank=True,
null=True,
on_delete=models.PROTECT,
related_name='value_values',
verbose_name=_('Generic value content type'),
related_name="value_values",
verbose_name=_("Generic value content type"),
)
value_object = generic.GenericForeignKey(
ct_field='generic_value_ct',
fk_field='generic_value_id',
ct_field="generic_value_ct",
fk_field="generic_value_id",
)
def natural_key(self) -> Tuple[Tuple[str, str], int, str]:
"""
Retrieve the natural key for the Value instance.
objects = ValueManager()
The natural key for a Value is a combination of its `attribute` natural key,
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
three elements.
class Meta:
verbose_name = _("Value")
verbose_name_plural = _("Values")
Returns
-------
tuple: A tuple containing the natural key of the attribute, entity ID,
and entity UUID of the Value instance.
"""
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
constraints: ClassVar[list[models.Constraint]] = [
models.UniqueConstraint(
fields=["entity_ct", "attribute", "entity_uuid"],
name="unique_entity_uuid_per_attribute",
),
models.UniqueConstraint(
fields=["entity_ct", "attribute", "entity_id"],
name="unique_entity_id_per_attribute",
),
models.CheckConstraint(
check=(
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
),
name="ensure_entity_id_xor_entity_uuid",
),
]
def __str__(self) -> str:
"""String representation of a Value."""
@ -202,12 +206,27 @@ class Value(models.Model):
self.full_clean()
super().save(*args, **kwargs)
def natural_key(self) -> tuple[tuple[str, str], int, str]:
"""
Retrieve the natural key for the Value instance.
The natural key for a Value is a combination of its `attribute` natural key,
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
three elements.
Returns
-------
tuple: A tuple containing the natural key of the attribute, entity ID,
and entity UUID of the Value instance.
"""
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
def _get_value(self):
"""Return the python object this value is holding."""
return getattr(self, f'value_{self.attribute.datatype}')
return getattr(self, f"value_{self.attribute.datatype}")
def _set_value(self, new_value):
"""Set the object this value is holding."""
setattr(self, f'value_{self.attribute.datatype}', new_value)
setattr(self, f"value_{self.attribute.datatype}", new_value)
value = property(_get_value, _set_value)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
This module contains custom :class:`EavQuerySet` class used for overriding
relational operators and pure functions for rewriting Q-expressions.
@ -19,14 +18,14 @@ Q-expressions need to be rewritten for two reasons:
2. To ensure that Q-expression tree is compiled to valid SQL.
For details see: :func:`rewrite_q_expr`.
"""
from functools import wraps
from itertools import count
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Case, IntegerField, Q, When
from django.db.models.query import QuerySet
from django.db.utils import NotSupportedError
from django.db.models import Subquery
from eav.models import Attribute, EnumValue, Value
@ -43,9 +42,9 @@ def is_eav_and_leaf(expr, gr_name):
bool
"""
return (
getattr(expr, 'connector', None) == 'AND'
getattr(expr, "connector", None) == "AND"
and len(expr.children) == 1
and expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)]
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
)
@ -98,7 +97,7 @@ def rewrite_q_expr(model_cls, expr):
# We are only interested in Qs.
if isinstance(expr, Q):
config_cls = getattr(model_cls, '_eav_config_cls', None)
config_cls = getattr(model_cls, "_eav_config_cls", None)
gr_name = config_cls.generic_relation_attr
# Recursively check child nodes.
@ -112,18 +111,18 @@ def rewrite_q_expr(model_cls, expr):
if len(rewritable) > 1:
q = None
# Save nodes which shouldn't be merged (non-EAV).
other = [c for c in expr.children if not c in rewritable]
other = [c for c in expr.children if c not in rewritable]
for child in rewritable:
if not (child.children and len(child.children) == 1):
raise AssertionError('Child must have exactly one descendant')
raise AssertionError("Child must have exactly one descendant")
# Child to be merged is always a terminal Q node,
# i.e. it's an AND expression with attribute-value tuple child.
attrval = child.children[0]
if not isinstance(attrval, tuple):
raise AssertionError('Attribute-value must be a tuple')
raise TypeError("Attribute-value must be a tuple")
fname = '{}__in'.format(gr_name)
fname = f"{gr_name}__in"
# Child can be either a 'eav_values__in' or 'pk__in' query.
# If it's the former then transform it into the latter.
@ -131,7 +130,7 @@ def rewrite_q_expr(model_cls, expr):
# If so, reverse it back to QuerySet so that set operators
# can be applied.
if attrval[0] == fname or hasattr(attrval[1], '__contains__'):
if attrval[0] == fname or hasattr(attrval[1], "__contains__"):
# Create model queryset.
_q = model_cls.objects.filter(**{fname: attrval[1]})
else:
@ -140,17 +139,17 @@ def rewrite_q_expr(model_cls, expr):
# Explicitly check for None. 'or' doesn't work here
# as empty QuerySet, which is valid, is falsy.
q = q if q != None else _q
q = q if q is not None else _q
if expr.connector == 'AND':
if expr.connector == "AND":
q &= _q
else:
q |= _q
# If any two children were merged,
# update parent expression.
if q != None:
expr.children = other + [('pk__in', q)]
if q is not None:
expr.children = [*other, ("pk__in", q)]
return expr
@ -170,9 +169,9 @@ def eav_filter(func):
for arg in args:
if isinstance(arg, Q):
# Modify Q objects (warning: recursion ahead).
arg = expand_q_filters(arg, self.model)
arg = expand_q_filters(arg, self.model) # noqa: PLW2901
# Rewrite Q-expression to safeform.
arg = rewrite_q_expr(self.model, arg)
arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901
nargs.append(arg)
for key, value in kwargs.items():
@ -180,9 +179,10 @@ def eav_filter(func):
nkey, nval = expand_eav_filter(self.model, key, value)
if nkey in nkwargs:
# Add filter to check if matching entity_id is in the previous queryset with same nkey
# Add filter to check if matching entity_id is
# in the previous queryset with same nkey
nkwargs[nkey] = nval.filter(
entity_id__in=nkwargs[nkey].values_list('entity_id', flat=True)
entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True),
).distinct()
else:
nkwargs.update({nkey: nval})
@ -229,27 +229,27 @@ def expand_eav_filter(model_cls, key, value):
key = 'eav_values__in'
value = Values.objects.filter(value_int=5, attribute__slug='height')
"""
fields = key.split('__')
config_cls = getattr(model_cls, '_eav_config_cls', None)
fields = key.split("__")
config_cls = getattr(model_cls, "_eav_config_cls", None)
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
slug = fields[1]
gr_name = config_cls.generic_relation_attr
datatype = Attribute.objects.get(slug=slug).datatype
value_key = ''
value_key = ""
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
lookup = '__value__{}'.format(fields[2]) if len(fields) > 2 else '__value'
value_key = 'value_{}{}'.format(datatype, lookup)
lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004
value_key = f"value_{datatype}{lookup}"
elif datatype == Attribute.TYPE_OBJECT:
value_key = 'generic_value_id'
value_key = "generic_value_id"
else:
lookup = '__{}'.format(fields[2]) if len(fields) > 2 else ''
value_key = 'value_{}{}'.format(datatype, lookup)
kwargs = {value_key: value, 'attribute__slug': slug}
lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004
value_key = f"value_{datatype}{lookup}"
kwargs = {value_key: value, "attribute__slug": slug}
value = Value.objects.filter(**kwargs)
return '%s__in' % gr_name, value
return f"{gr_name}__in", value
# Not an eav field, so keep as is
return key, value
@ -266,7 +266,7 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` filter method.
"""
return super(EavQuerySet, self).filter(*args, **kwargs)
return super().filter(*args, **kwargs)
@eav_filter
def exclude(self, *args, **kwargs):
@ -274,7 +274,7 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` exclude method.
"""
return super(EavQuerySet, self).exclude(*args, **kwargs)
return super().exclude(*args, **kwargs)
@eav_filter
def get(self, *args, **kwargs):
@ -282,7 +282,7 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` get method.
"""
return super(EavQuerySet, self).get(*args, **kwargs)
return super().get(*args, **kwargs)
def order_by(self, *fields):
# Django only allows to order querysets by direct fields and
@ -292,20 +292,20 @@ class EavQuerySet(QuerySet):
# This will be slow, of course.
order_clauses = []
query_clause = self
config_cls = self.model._eav_config_cls
config_cls = self.model._eav_config_cls # noqa: SLF001
for term in [t.split('__') for t in fields]:
for term in [t.split("__") for t in fields]:
# Continue only for EAV attributes.
if len(term) == 2 and term[0] == config_cls.eav_attr:
if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
# Retrieve Attribute over which the ordering is performed.
try:
attr = Attribute.objects.get(slug=term[1])
except ObjectDoesNotExist:
except ObjectDoesNotExist as err:
raise ObjectDoesNotExist(
'Cannot find EAV attribute "{}"'.format(term[1])
)
f'Cannot find EAV attribute "{term[1]}"',
) from err
field_name = 'value_%s' % attr.datatype
field_name = f"value_{attr.datatype}"
pks_values = (
Value.objects.filter(
@ -318,12 +318,12 @@ class EavQuerySet(QuerySet):
.order_by(
# Order values by their value-field of
# appropriate attribute data-type.
field_name
field_name,
)
.values_list(
# Retrieve only primary-keys of the entities
# in the current queryset.
'entity_id',
"entity_id",
field_name,
)
)
@ -352,16 +352,16 @@ class EavQuerySet(QuerySet):
order_clause = Case(*when_clauses, output_field=IntegerField())
clause_name = '__'.join(term)
clause_name = "__".join(term)
# Use when-clause to construct
# custom order-by clause.
query_clause = query_clause.annotate(**{clause_name: order_clause})
order_clauses.append(clause_name)
elif len(term) >= 2 and term[0] == config_cls.eav_attr:
elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
raise NotSupportedError(
'EAV does not support ordering through ' 'foreign-key chains'
"EAV does not support ordering through foreign-key chains",
)
else:

View file

@ -3,13 +3,12 @@
from django.contrib.contenttypes import fields as generic
from django.db.models.signals import post_init, post_save, pre_save
from eav.logic.entity_pk import get_entity_pk_type
from eav.managers import EntityManager
from eav.models import Attribute, Entity, Value
from eav.logic.entity_pk import get_entity_pk_type
class EavConfig(object):
class EavConfig:
"""
The default ``EavConfig`` class used if it is not overridden on registration.
This is where all the default eav attribute names are defined.
@ -29,10 +28,10 @@ class EavConfig(object):
if not overridden, it is not possible to query Values by Entities.
"""
manager_attr = 'objects'
manager_attr = "objects"
manager_only = False
eav_attr = 'eav'
generic_relation_attr = 'eav_values'
eav_attr = "eav"
generic_relation_attr = "eav_values"
generic_relation_related_name = None
@classmethod
@ -44,7 +43,7 @@ class EavConfig(object):
return Attribute.objects.all()
class Registry(object):
class Registry:
"""
Handles registration through the
:meth:`register` and :meth:`unregister` methods.
@ -59,14 +58,14 @@ class Registry(object):
.. note::
Multiple registrations for the same entity are harmlessly ignored.
"""
if hasattr(model_cls, '_eav_config_cls'):
if hasattr(model_cls, "_eav_config_cls"):
return
if config_cls is EavConfig or config_cls is None:
config_cls = type("%sConfig" % model_cls.__name__, (EavConfig,), {})
config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {})
# set _eav_config_cls on the model so we can access it there
setattr(model_cls, '_eav_config_cls', config_cls)
model_cls._eav_config_cls = config_cls
reg = Registry(model_cls)
reg._register_self()
@ -79,19 +78,19 @@ class Registry(object):
.. note::
Unregistering a class not already registered is harmlessly ignored.
"""
if not getattr(model_cls, '_eav_config_cls', None):
if not getattr(model_cls, "_eav_config_cls", None):
return
reg = Registry(model_cls)
reg._unregister_self()
delattr(model_cls, '_eav_config_cls')
delattr(model_cls, "_eav_config_cls")
@staticmethod
def attach_eav_attr(sender, *args, **kwargs):
"""
Attach EAV Entity toolkit to an instance after init.
"""
instance = kwargs['instance']
instance = kwargs["instance"]
config_cls = instance.__class__._eav_config_cls
setattr(instance, config_cls.eav_attr, Entity(instance))
@ -102,25 +101,41 @@ class Registry(object):
self.model_cls = model_cls
self.config_cls = model_cls._eav_config_cls
def _attach_manager(self):
def _attach_manager(self) -> None:
"""
Attach the manager to *manager_attr* specified in *config_cls*
Attach the EntityManager to the model class.
This method replaces the existing manager specified in the `config_cls`
with a new instance of `EntityManager`. If the specified manager is the
default manager, the `EntityManager` is set as the new default manager.
Otherwise, it is appended to the list of managers.
If the model class already has a manager with the same name as the one
specified in `config_cls`, it is saved as `old_mgr` in the `config_cls`
for use during detachment.
"""
# Save the old manager if the attribute name conflicts with the new one.
if hasattr(self.model_cls, self.config_cls.manager_attr):
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
manager_attr = self.config_cls.manager_attr
model_meta = self.model_cls._meta
current_manager = getattr(self.model_cls, manager_attr, None)
# For some models, `local_managers` may be empty, eg.
# django.contrib.auth.models.User and AbstractUser
if mgr in self.model_cls._meta.local_managers:
self.config_cls.old_mgr = mgr
self.model_cls._meta.local_managers.remove(mgr)
if isinstance(current_manager, EntityManager):
# EntityManager is already attached, no need to proceed
return
self.model_cls._meta._expire_cache()
# Create a new EntityManager
new_manager = EntityManager()
# Attach the new manager to the model.
mgr = EntityManager()
mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr)
# Save and remove the old manager if it exists
if current_manager and current_manager in model_meta.local_managers:
self.config_cls.old_mgr = current_manager
model_meta.local_managers.remove(current_manager)
# Set the creation_counter to maintain the order
# This ensures that the new manager has the same priority as the old one
new_manager.creation_counter = current_manager.creation_counter
# Attach the new EntityManager instance to the model.
new_manager.contribute_to_class(self.model_cls, manager_attr)
def _detach_manager(self):
"""
@ -131,9 +146,10 @@ class Registry(object):
self.model_cls._meta._expire_cache()
delattr(self.model_cls, self.config_cls.manager_attr)
if hasattr(self.config_cls, 'old_mgr'):
if hasattr(self.config_cls, "old_mgr"):
self.config_cls.old_mgr.contribute_to_class(
self.model_cls, self.config_cls.manager_attr
self.model_cls,
self.config_cls.manager_attr,
)
def _attach_signals(self):
@ -165,7 +181,7 @@ class Registry(object):
generic_relation = generic.GenericRelation(
Value,
object_id_field=get_entity_pk_type(self.model_cls),
content_type_field='entity_ct',
content_type_field="entity_ct",
related_query_name=rel_name,
)
generic_relation.contribute_to_class(self.model_cls, gr_name)

View file

@ -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."))

View file

@ -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

View file

@ -13,19 +13,19 @@ def main() -> None:
2. Warns if Django is not installed
3. Executes any given command
"""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
try:
from django.core import management # noqa: WPS433
except ImportError:
from django.core import management
except ImportError as err:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
+ 'available on your PYTHONPATH environment variable? Did you '
+ 'forget to activate a virtual environment?',
)
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?",
) from err
management.execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

2989
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,12 @@
[build-system]
requires = ["poetry-core"]
requires = ["poetry-core>=1.9"]
build-backend = "poetry.core.masonry.api"
[tool.nitpick]
style = "https://raw.githubusercontent.com/wemake-services/wemake-python-styleguide/master/styles/nitpick-style-wemake.toml"
[tool.black]
target-version = ['py37', 'py38', 'py39', 'py310']
skip-string-normalization = true
include = '\.pyi?$'
[tool.poetry]
name = "django-eav2"
description = "Entity-Attribute-Value storage for Django"
version = "1.6.0"
version = "1.8.1"
license = "GNU Lesser General Public License (LGPL), Version 3"
packages = [
{ include = "eav" }
@ -47,17 +37,17 @@ classifiers = [
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Database",
"Topic :: Software Development :: Libraries :: Python Modules",
"Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
]
[tool.semantic_release]
@ -70,22 +60,17 @@ upload_to_release = false
build_command = "pip install poetry && poetry build"
[tool.poetry.dependencies]
python = "^3.8"
django = ">=3.2,<5.1"
pyyaml = { version = "^6.0.1", python = "^3.12" }
python = "^3.9"
django = ">=4.2,<5.3"
[tool.poetry.group.test.dependencies]
mypy = "^1.6"
wemake-python-styleguide = "^0.17"
flake8-pytest-style = "^1.7"
nitpick = ">=0.34,<0.36"
black = ">=22.12,<25.0"
ruff = ">=0.6.3,<0.13.0"
safety = ">=2.3,<4.0"
pytest = ">=7.4.3,<9.0.0"
pytest-cov = "^4.1"
pytest-cov = ">=4.1,<7.0"
pytest-randomly = "^3.15"
pytest-django = "^4.5.2"
hypothesis = "^6.87.1"
@ -97,7 +82,53 @@ optional = true
[tool.poetry.group.docs.dependencies]
sphinx = ">=5.0,<8.0"
sphinx-rtd-theme = ">=1.3,<3.0"
sphinx-rtd-theme = ">=1.3,<4.0"
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
m2r2 = "^0.3"
tomlkit = ">=0.11,<0.13"
tomlkit = ">=0.13.0,<0.14"
[tool.ruff]
line-length = 88
target-version = "py38"
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"ANN", # Type hints related, let mypy handle these.
"ARG", # Unused arguments
"D", # Docstrings related
"EM101", # "Exception must not use a string literal, assign to variable first"
"EM102", # "Exception must not use an f-string literal, assign to variable first"
"PD", # Pandas related
"Q000", # For now
"SIM105", # "Use contextlib.suppress({exception}) instead of try-except-pass"
"TRY003", # "Avoid specifying long messages outside the exception class"
]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false
[tool.ruff.lint.per-file-ignores]
# Allow private member access for Registry
"eav/registry.py" = ["SLF001"]
# Migrations are special
"**/migrations/*" = ["RUF012"]
# Sphinx specific
"docs/source/conf.py" = ["INP001"]
# pytest is even more special
"tests/*" = [
"INP001", # "Add an __init__.py"
"PLR2004", # "Magic value used in comparison"
"PT009", # "Use a regular assert instead of unittest-style"
"PT027", # "Use pytest.raises instead of unittest-style"
"S101", # "Use of assert detected"
"SLF001" # "Private member accessed"
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"

View file

@ -3,63 +3,6 @@
# https://docs.python.org/3/distutils/configfile.html
[flake8]
format = wemake
show-source = True
doctests = False
statistics = False
# darglint configuration:
# https://github.com/terrencepreilly/darglint
strictness = long
docstring-style = numpy
# Plugins:
max-complexity = 6
max-line-length = 80
exclude =
# Trash and cache:
.git
__pycache__
.venv
.eggs
*.egg
temp
ignore =
D100,
D104,
D401,
W504,
X100,
RST303,
RST304,
DAR103,
DAR203
per-file-ignores =
# Allow to have magic numbers inside migrations, wrong module names,
# and string over-use:
*/migrations/*.py: WPS102, WPS114, WPS226, WPS432
# Allow `__init__.py` with logic for configuration:
test_project/settings.py: S105, WPS226, WPS407
tests/test_*.py: N806, S101, S404, S603, S607, WPS118, WPS226, WPS432, WPS442
[isort]
# isort configuration:
# https://github.com/timothycrosley/isort/wiki/isort-Settings
include_trailing_comma = true
use_parentheses = true
# See https://github.com/timothycrosley/isort#multi-line-output-modes
multi_line_output = 3
line_length = 80
# Useful for our test app:
known_first_party = test_project
[tool:pytest]
# Django options:
# https://pytest-django.readthedocs.io/en/latest/

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class TestAppConfig(AppConfig):
name = 'test_project'
name = "test_project"

View file

@ -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,
},
),
]

View file

@ -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

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from pathlib import Path
from typing import List
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).parent.parent
@ -9,51 +10,51 @@ BASE_DIR = Path(__file__).parent.parent
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'secret!' # noqa: S105
SECRET_KEY = "secret!" # noqa: S105
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS: List[str] = []
ALLOWED_HOSTS: list[str] = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Test Project:
'test_project.apps.TestAppConfig',
"test_project.apps.TestAppConfig",
# Our app:
'eav',
"eav",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
@ -64,15 +65,15 @@ TEMPLATES = [
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
}
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
EAV2_PRIMARY_KEY_FIELD = 'django.db.models.AutoField'
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
# Password validation
@ -84,9 +85,9 @@ AUTH_PASSWORD_VALIDATORS = []
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC'
TIME_ZONE = "UTC"
USE_I18N = True
@ -98,4 +99,4 @@ USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = "/static/"

View file

@ -1,12 +1,13 @@
import uuid
import string
import uuid
import pytest
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError
from django.test import TestCase
from hypothesis import given, settings
from hypothesis.extra import django
from django.conf import settings as django_settings
from hypothesis import strategies as st
from hypothesis.extra import django
from hypothesis.strategies import just
import eav
@ -15,7 +16,6 @@ from eav.models import Attribute, Value
from eav.registry import EavConfig
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
@ -27,22 +27,22 @@ else:
class Attributes(TestCase):
def setUp(self):
class EncounterEavConfig(EavConfig):
manager_attr = 'eav_objects'
eav_attr = 'eav_field'
generic_relation_attr = 'encounter_eav_values'
generic_relation_related_name = 'encounters'
manager_attr = "eav_objects"
eav_attr = "eav_field"
generic_relation_attr = "encounter_eav_values"
generic_relation_related_name = "encounters"
@classmethod
def get_attributes(cls, instance=None):
return Attribute.objects.filter(slug__contains='a')
return Attribute.objects.filter(slug__contains="a")
eav.register(Encounter, EncounterEavConfig)
eav.register(Patient)
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
def tearDown(self):
eav.unregister(Encounter)
@ -53,14 +53,14 @@ class Attributes(TestCase):
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
def test_duplicate_attributs(self):
'''
"""
Ensure that no two Attributes with the same slug can exist.
'''
"""
with self.assertRaises(ValidationError):
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
def test_setting_attributes(self):
p = Patient.objects.create(name='Jon')
p = Patient.objects.create(name="Jon")
e = Encounter.objects.create(patient=p, num=1)
p.eav.age = 3
@ -73,7 +73,7 @@ class Attributes(TestCase):
t.eav.age = 6
t.eav.height = 10
t.save()
p = Patient.objects.get(name='Jon')
p = Patient.objects.get(name="Jon")
self.assertEqual(p.eav.age, 3)
self.assertEqual(p.eav.height, 2.3)
e = Encounter.objects.get(num=1)
@ -96,20 +96,21 @@ class Attributes(TestCase):
eav.unregister(Encounter)
eav.register(Encounter, EncounterEavConfig)
p = Patient.objects.create(name='Jon')
p = Patient.objects.create(name="Jon")
e = Encounter.objects.create(patient=p, num=1)
with self.assertRaises(IllegalAssignmentException):
e.eav.color = 'red'
e.eav.color = "red"
e.save()
def test_uuid_pk(self):
"""Tests for when model pk is UUID."""
d1 = Doctor.objects.create(name='Lu')
d1.eav.age = 10
expected_age = 10
d1 = Doctor.objects.create(name="Lu")
d1.eav.age = expected_age
d1.save()
assert d1.eav.age == 10
assert d1.eav.age == expected_age
# Validate repr of Value for an entity with a UUID PK
v1 = Value.objects.filter(entity_uuid=d1.pk).first()
@ -119,7 +120,7 @@ class Attributes(TestCase):
def test_big_integer(self):
"""Tests an integer larger than 32-bit a value."""
big_num = 3147483647
patient = Patient.objects.create(name='Jon')
patient = Patient.objects.create(name="Jon")
patient.eav.age = big_num
patient.save()
@ -136,6 +137,7 @@ class TestAttributeModel(django.TestCase):
id=auto_field_strategy,
datatype=just(Attribute.TYPE_TEXT),
enum_group=just(None),
slug=just(None), # Let Attribute.save() handle
),
)
@settings(deadline=None)
@ -162,3 +164,20 @@ class TestAttributeModel(django.TestCase):
)
assert isinstance(instance, Attribute)
@pytest.mark.django_db
def test_attribute_create_with_invalid_slug() -> None:
"""
Test that creating an Attribute with an invalid slug raises a UserWarning.
This test ensures that when an Attribute is created with a slug that is not
a valid Python identifier, a UserWarning is raised. The warning should
indicate that the slug is invalid and suggest updating it.
"""
with pytest.warns(UserWarning):
Attribute.objects.create(
name="Test Attribute",
slug="123-invalid",
datatype=Attribute.TYPE_TEXT,
)

View file

@ -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"],
)

View file

@ -1,5 +1,3 @@
import sys
import pytest
from django.contrib.admin.sites import AdminSite
from django.core.handlers.base import BaseHandler
@ -8,9 +6,9 @@ from django.test import TestCase
from django.test.client import RequestFactory
import eav
from eav.admin import *
from eav.admin import BaseEntityAdmin
from eav.forms import BaseDynamicEntityForm
from eav.models import Attribute
from eav.models import Attribute, EnumGroup, EnumValue
from test_project.models import ExampleModel, M2MModel, Patient
@ -20,15 +18,7 @@ class MockRequest(RequestFactory):
request = RequestFactory.request(self, **request)
handler = BaseHandler()
handler.load_middleware()
# BaseHandler_request_middleware is not set in Django2.0
# and removed in Django2.1
if sys.version_info[0] < 2:
for middleware_method in handler._request_middleware:
if middleware_method(request):
raise Exception(
"Couldn't create request mock object - "
"request middleware returned a response"
)
return request
@ -48,49 +38,51 @@ request.user = MockSuperUser()
class PatientForm(ModelForm):
class Meta:
model = Patient
fields = '__all__'
fields = ("name", "email", "example")
class PatientDynamicForm(BaseDynamicEntityForm):
class Meta:
model = Patient
fields = '__all__'
fields = ("name", "email", "example")
class M2MModelForm(ModelForm):
class Meta:
model = M2MModel
fields = '__all__'
fields = ("name", "models")
class Forms(TestCase):
def setUp(self):
eav.register(Patient)
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
self.female = EnumValue.objects.create(value='Female')
self.male = EnumValue.objects.create(value='Male')
gender_group = EnumGroup.objects.create(name='Gender')
self.female = EnumValue.objects.create(value="Female")
self.male = EnumValue.objects.create(value="Male")
gender_group = EnumGroup.objects.create(name="Gender")
gender_group.values.add(self.female, self.male)
Attribute.objects.create(
name='gender', datatype=Attribute.TYPE_ENUM, enum_group=gender_group
name="gender",
datatype=Attribute.TYPE_ENUM,
enum_group=gender_group,
)
self.instance = Patient.objects.create(name='Jim Morrison')
self.instance = Patient.objects.create(name="Jim Morrison")
def test_valid_submit(self):
self.instance.eav.color = 'Blue'
self.instance.eav.color = "Blue"
form = PatientForm(self.instance.__dict__, instance=self.instance)
jim = form.save()
self.assertEqual(jim.eav.color, 'Blue')
self.assertEqual(jim.eav.color, "Blue")
def test_invalid_submit(self):
form = PatientForm(dict(color='Blue'), instance=self.instance)
form = PatientForm({"color": "Blue"}, instance=self.instance)
with self.assertRaises(ValueError):
jim = form.save()
form.save()
def test_valid_enums(self):
self.instance.eav.gender = self.female
@ -100,41 +92,41 @@ class Forms(TestCase):
self.assertEqual(rose.eav.gender, self.female)
def test_m2m(self):
m2mmodel = M2MModel.objects.create(name='name')
model = ExampleModel.objects.create(name='name')
form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel)
m2mmodel = M2MModel.objects.create(name="name")
model = ExampleModel.objects.create(name="name")
form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel)
form.save()
self.assertEqual(len(m2mmodel.models.all()), 1)
@pytest.fixture()
@pytest.fixture
def patient() -> Patient:
"""Return an eav enabled Patient instance."""
eav.register(Patient)
return Patient.objects.create(name='Jim Morrison')
return Patient.objects.create(name="Jim Morrison")
@pytest.fixture()
@pytest.fixture
def create_attributes() -> None:
"""Create some Attributes to use for testing."""
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
@pytest.mark.django_db()
@pytest.mark.django_db
@pytest.mark.parametrize(
'csv_data, separator',
("csv_data", "separator"),
[
('', ';'),
('justone', ','),
('one;two;three', ';'),
('alpha,beta,gamma', ','),
(None, ','),
("", ";"),
("justone", ","),
("one;two;three", ";"),
("alpha,beta,gamma", ","),
(None, ","),
],
)
def test_csvdynamicform(patient, csv_data, separator) -> None:
"""Ensure that a TYPE_CSV field works correctly with forms."""
Attribute.objects.create(name='csv', datatype=Attribute.TYPE_CSV)
Attribute.objects.create(name="csv", datatype=Attribute.TYPE_CSV)
patient.eav.csv = csv_data
patient.save()
patient.refresh_from_db()
@ -143,7 +135,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
patient.__dict__,
instance=patient,
)
form.fields['csv'].separator = separator
form.fields["csv"].separator = separator
assert form.is_valid()
jim = form.save()
@ -151,7 +143,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None:
assert jim.eav.csv == expected_result
@pytest.mark.django_db()
@pytest.mark.django_db
def test_csvdynamicform_empty(patient) -> None:
"""Test to ensure an instance with no eav values is correct."""
form = PatientDynamicForm(
@ -162,29 +154,31 @@ def test_csvdynamicform_empty(patient) -> None:
assert form.save()
@pytest.mark.django_db()
@pytest.mark.usefixtures('create_attributes')
@pytest.mark.parametrize('define_fieldsets', (True, False))
@pytest.mark.django_db
@pytest.mark.usefixtures("create_attributes")
@pytest.mark.parametrize("define_fieldsets", [True, False])
def test_entity_admin_form(patient, define_fieldsets):
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
admin = BaseEntityAdmin(Patient, AdminSite())
admin.readonly_fields = ('email',)
admin.readonly_fields = ("email",)
admin.form = BaseDynamicEntityForm
expected_fieldsets = 2
if define_fieldsets:
# Use all fields in Patient model
admin.fieldsets = (
(None, {'fields': ['name', 'example']}),
('Contact Info', {'fields': ['email']}),
(None, {"fields": ["name", "example"]}),
("Contact Info", {"fields": ["email"]}),
)
expected_fieldsets = 3
view = admin.change_view(request, str(patient.pk))
adminform = view.context_data['adminform']
adminform = view.context_data["adminform"]
# Count the total fields in fieldsets
total_fields = sum(
len(fields_info['fields']) for _, fields_info in adminform.fieldsets
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
)
# 3 for 'name', 'email', 'example'
@ -193,27 +187,65 @@ def test_entity_admin_form(patient, define_fieldsets):
assert total_fields == expected_fields_count
# Ensure our fieldset count is correct
if define_fieldsets:
assert len(adminform.fieldsets) == 3
else:
assert len(adminform.fieldsets) == 2
assert len(adminform.fieldsets) == expected_fieldsets
@pytest.mark.django_db()
@pytest.mark.django_db
def test_entity_admin_form_no_attributes(patient):
"""Test the BaseEntityAdmin form with no Attributes created."""
admin = BaseEntityAdmin(Patient, AdminSite())
admin.readonly_fields = ('email',)
admin.readonly_fields = ("email",)
admin.form = BaseDynamicEntityForm
# Only fields defined in Patient model
expected_fields = 3
view = admin.change_view(request, str(patient.pk))
adminform = view.context_data['adminform']
adminform = view.context_data["adminform"]
# Count the total fields in fieldsets
total_fields = sum(
len(fields_info['fields']) for _, fields_info in adminform.fieldsets
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
)
# 3 for 'name', 'email', 'example'
assert total_fields == 3
assert total_fields == expected_fields
@pytest.mark.django_db
def test_dynamic_form_renders_enum_choices():
"""
Test that enum choices render correctly in BaseDynamicEntityForm.
This test verifies the fix for issue #648 where enum choices weren't
rendering correctly in Django 4.2.17 due to QuerySet unpacking issues.
"""
# Setup
eav.register(Patient)
# Create enum values and group
female = EnumValue.objects.create(value="Female")
male = EnumValue.objects.create(value="Male")
gender_group = EnumGroup.objects.create(name="Gender")
gender_group.values.add(female, male)
Attribute.objects.create(
name="gender",
datatype=Attribute.TYPE_ENUM,
enum_group=gender_group,
)
# Create a patient
patient = Patient.objects.create(name="Test Patient")
# Initialize the dynamic form
form = PatientDynamicForm(instance=patient)
# Test rendering - should not raise any exceptions
rendered_form = form.as_p()
# Verify the form rendered and contains the enum choices
assert 'name="gender"' in rendered_form
assert f'value="{female.pk}">{female.value}' in rendered_form
assert f'value="{male.pk}">{male.value}' in rendered_form

View file

@ -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"
)

View file

@ -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)

View file

@ -1,30 +1,31 @@
from django.test import TestCase
import eav
from eav.models import Attribute, EnumGroup, EnumValue, Value
from test_project.models import Patient
import eav
class ModelTest(TestCase):
def setUp(self):
eav.register(Patient)
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
EnumGroup.objects.create(name='Yes / No')
EnumValue.objects.create(value='yes')
EnumValue.objects.create(value='no')
EnumValue.objects.create(value='unknown')
EnumGroup.objects.create(name="Yes / No")
EnumValue.objects.create(value="yes")
EnumValue.objects.create(value="no")
EnumValue.objects.create(value="unknown")
def test_attr_natural_keys(self):
attr = Attribute.objects.get(name='age')
attr = Attribute.objects.get(name="age")
attr_natural_key = attr.natural_key()
attr_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key)
self.assertEqual(attr_retrieved_model, attr)
def test_value_natural_keys(self):
p = Patient.objects.create(name='Jon')
p = Patient.objects.create(name="Jon")
p.eav.age = 5
p.save()
@ -38,7 +39,7 @@ class ModelTest(TestCase):
enum_group = EnumGroup.objects.first()
enum_group_natural_key = enum_group.natural_key()
enum_group_retrieved_model = EnumGroup.objects.get_by_natural_key(
*enum_group_natural_key
*enum_group_natural_key,
)
self.assertEqual(enum_group_retrieved_model, enum_group)
@ -46,6 +47,6 @@ class ModelTest(TestCase):
enum_value = EnumValue.objects.first()
enum_value_natural_key = enum_value.natural_key()
enum_value_retrieved_model = EnumValue.objects.get_by_natural_key(
*enum_value_natural_key
*enum_value_natural_key,
)
self.assertEqual(enum_value_retrieved_model, enum_value)

View file

@ -1,9 +1,8 @@
import uuid
import pytest
from django.db import models
from eav.logic.object_pk import get_pk_format
from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format
def test_get_uuid_primary_key(settings) -> None:
@ -21,7 +20,7 @@ def test_get_char_primary_key(settings) -> None:
assert isinstance(primary_field, models.CharField)
assert primary_field.primary_key
assert not primary_field.editable
assert primary_field.max_length == 40
assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN
def test_get_default_primary_key(settings) -> None:

View file

@ -1,3 +1,6 @@
from __future__ import annotations
import pytest
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Q
from django.db.utils import NotSupportedError
@ -6,7 +9,7 @@ from django.test import TestCase
import eav
from eav.models import Attribute, EnumGroup, EnumValue, Value
from eav.registry import EavConfig
from test_project.models import Encounter, Patient, ExampleModel
from test_project.models import Encounter, ExampleModel, Patient
class Queries(TestCase):
@ -14,32 +17,34 @@ class Queries(TestCase):
eav.register(Encounter)
eav.register(Patient)
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name='extras', datatype=Attribute.TYPE_JSON)
Attribute.objects.create(name='illness', datatype=Attribute.TYPE_CSV)
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="city", datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="country", datatype=Attribute.TYPE_TEXT)
Attribute.objects.create(name="extras", datatype=Attribute.TYPE_JSON)
Attribute.objects.create(name="illness", datatype=Attribute.TYPE_CSV)
self.yes = EnumValue.objects.create(value='yes')
self.no = EnumValue.objects.create(value='no')
self.unknown = EnumValue.objects.create(value='unknown')
self.yes = EnumValue.objects.create(value="yes")
self.no = EnumValue.objects.create(value="no")
self.unknown = EnumValue.objects.create(value="unknown")
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
ynu.values.add(self.yes)
ynu.values.add(self.no)
ynu.values.add(self.unknown)
Attribute.objects.create(
name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu
name="fever",
datatype=Attribute.TYPE_ENUM,
enum_group=ynu,
)
def tearDown(self):
eav.unregister(Encounter)
eav.unregister(Patient)
def init_data(self):
def init_data(self) -> None:
yes = self.yes
no = self.no
@ -47,24 +52,24 @@ class Queries(TestCase):
# Name, age, fever,
# city, country, extras
# possible illness
['Anne', 3, no, 'New York', 'USA', {"chills": "yes"}, "cold"],
['Bob', 15, no, 'Bamako', 'Mali', {}, ""],
["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"],
["Bob", 15, no, "Bamako", "Mali", {}, ""],
[
'Cyrill',
"Cyrill",
15,
yes,
'Kisumu',
'Kenya',
"Kisumu",
"Kenya",
{"chills": "yes", "headache": "no"},
"flu",
],
['Daniel', 3, no, 'Nice', 'France', {"headache": "yes"}, "cold"],
["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"],
[
'Eugene',
"Eugene",
2,
yes,
'France',
'Nice',
"France",
"Nice",
{"chills": "no", "headache": "yes"},
"flu;cold",
],
@ -82,26 +87,26 @@ class Queries(TestCase):
)
def test_get_or_create_with_eav(self):
Patient.objects.get_or_create(name='Bob', eav__age=5)
Patient.objects.get_or_create(name="Bob", eav__age=5)
self.assertEqual(Patient.objects.count(), 1)
self.assertEqual(Value.objects.count(), 1)
Patient.objects.get_or_create(name='Bob', eav__age=5)
Patient.objects.get_or_create(name="Bob", eav__age=5)
self.assertEqual(Patient.objects.count(), 1)
self.assertEqual(Value.objects.count(), 1)
Patient.objects.get_or_create(name='Bob', eav__age=6)
Patient.objects.get_or_create(name="Bob", eav__age=6)
self.assertEqual(Patient.objects.count(), 2)
self.assertEqual(Value.objects.count(), 2)
def test_get_or_create_with_defaults(self):
"""Tests EntityManager.get_or_create() with defaults keyword."""
city_name = 'Tokyo'
email = 'mari@test.com'
city_name = "Tokyo"
email = "mari@test.com"
p1, _ = Patient.objects.get_or_create(
name='Mari',
name="Mari",
eav__age=27,
defaults={
'email': email,
'eav__city': city_name,
"email": email,
"eav__city": city_name,
},
)
assert Patient.objects.count() == 1
@ -109,175 +114,258 @@ class Queries(TestCase):
assert p1.eav.city == city_name
def test_get_with_eav(self):
p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6)
p1, _ = Patient.objects.get_or_create(name="Bob", eav__age=6)
self.assertEqual(Patient.objects.get(eav__age=6), p1)
Patient.objects.create(name='Fred', eav__age=6)
Patient.objects.create(name="Fred", eav__age=6)
self.assertRaises(
MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)
MultipleObjectsReturned,
lambda: Patient.objects.get(eav__age=6),
)
def test_filtering_on_normal_and_eav_fields(self):
def test_no_results_for_contradictory_conditions(self) -> None:
"""Test that contradictory conditions return no results."""
self.init_data()
# Check number of objects in DB.
self.assertEqual(Patient.objects.count(), 5)
self.assertEqual(Value.objects.count(), 29)
# Nobody
q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 0)
# Anne, Daniel
# Should return no patients due to contradictory conditions
assert p.count() == 0
def test_filtering_on_numeric_eav_fields(self) -> None:
"""Test filtering on numeric EAV fields."""
self.init_data()
q1 = Q(eav__age__gte=3) # Everyone except Eugene
q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene
p = Patient.objects.filter(q2 & q1)
self.assertEqual(p.count(), 2)
# Anne
q1 = Q(eav__city__contains='Y') & Q(eav__fever='no')
# Should return Anne and Daniel
assert p.count() == 2
def test_filtering_on_text_and_boolean_eav_fields(self) -> None:
"""Test filtering on text and boolean EAV fields."""
self.init_data()
q1 = Q(eav__city__contains="Y") & Q(eav__fever="no")
q2 = Q(eav__age=3)
p = Patient.objects.filter(q1 & q2)
self.assertEqual(p.count(), 1)
# Anne
q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no)
# Should return only Anne
assert p.count() == 1
def test_filtering_with_enum_eav_fields(self) -> None:
"""Test filtering with enum EAV fields."""
self.init_data()
q1 = Q(eav__city__contains="Y") & Q(eav__fever=self.no)
q2 = Q(eav__age=3)
p = Patient.objects.filter(q1 & q2)
self.assertEqual(p.count(), 1)
# Anne, Daniel
q1 = Q(eav__city__contains='Y', eav__fever=self.no)
q2 = Q(eav__city='Nice')
# Should return only Anne
assert p.count() == 1
def test_complex_query_with_or_conditions(self) -> None:
"""Test complex query with OR conditions."""
self.init_data()
q1 = Q(eav__city__contains="Y", eav__fever=self.no)
q2 = Q(eav__city="Nice")
q3 = Q(eav__age=3)
p = Patient.objects.filter((q1 | q2) & q3)
self.assertEqual(p.count(), 2)
# Everyone
# Should return Anne and Daniel
assert p.count() == 2
def test_filtering_with_multiple_enum_values(self) -> None:
"""Test filtering with multiple enum values."""
self.init_data()
q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 5)
# Anne, Bob, Daniel
# Should return all patients
assert p.count() == 5
def test_complex_query_with_multiple_conditions(self) -> None:
"""Test complex query with multiple conditions."""
self.init_data()
q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel
q2 = Q(eav__fever=self.yes) # Cyrill, Eugene
q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene
q3 = Q(eav__country__contains="e") # Cyrill, Daniel, Eugene
q4 = q2 & q3 # Cyrill, Daniel, Eugene
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
p = Patient.objects.filter(q5)
self.assertEqual(p.count(), 3)
# Everyone except Anne
q1 = Q(eav__city__contains='Y')
# Should return Anne, Bob, and Daniel
assert p.count() == 3
def test_excluding_with_eav_fields(self) -> None:
"""Test excluding with EAV fields."""
self.init_data()
q1 = Q(eav__city__contains="Y")
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 4)
# Anne, Bob, Daniel
q1 = Q(eav__city__contains='Y')
# Should return all patients except Anne
assert p.count() == 4
def test_filtering_with_or_conditions(self) -> None:
"""Test filtering with OR conditions."""
self.init_data()
q1 = Q(eav__city__contains="Y")
q2 = Q(eav__fever=self.no)
q3 = q1 | q2
p = Patient.objects.filter(q3)
self.assertEqual(p.count(), 3)
# Anne, Daniel
# Should return Anne, Bob, and Daniel
assert p.count() == 3
def test_filtering_on_single_eav_field(self) -> None:
"""Test filtering on a single EAV field."""
self.init_data()
q1 = Q(eav__age=3)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 2)
# Eugene
q1 = Q(name__contains='E', eav__fever=self.yes)
# Should return Anne and Daniel
assert p.count() == 2
def test_combining_normal_and_eav_fields(self) -> None:
"""Test combining normal and EAV fields in a query."""
self.init_data()
q1 = Q(name__contains="E", eav__fever=self.yes)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 1)
# Extras: Chills
# Without
# Should return only Eugene
assert p.count() == 1
def test_filtering_on_json_eav_field(self) -> None:
"""Test filtering on JSON EAV field."""
self.init_data()
q1 = Q(eav__extras__has_key="chills")
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 2)
# With
# Should return patients without 'chills' in extras
assert p.count() == 2
q1 = Q(eav__extras__has_key="chills")
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 3)
# No chills
# Should return patients with 'chills' in extras
assert p.count() == 3
q1 = Q(eav__extras__chills="no")
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 1)
# Has chills
# Should return patients with 'chills' set to 'no'
assert p.count() == 1
q1 = Q(eav__extras__chills="yes")
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 2)
# Extras: Empty
# Yes
# Should return patients with 'chills' set to 'yes'
assert p.count() == 2
def test_filtering_on_empty_json_eav_field(self) -> None:
"""Test filtering on empty JSON EAV field."""
self.init_data()
q1 = Q(eav__extras={})
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 1)
# No
# Should return patients with empty extras
assert p.count() == 1
q1 = Q(eav__extras={})
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 4)
# Illness:
# Cold
# Should return patients with non-empty extras
assert p.count() == 4
def test_filtering_on_text_eav_field_with_icontains(self) -> None:
"""Test filtering on text EAV field with icontains."""
self.init_data()
q1 = Q(eav__illness__icontains="cold")
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 2)
# Flu
# Should return patients without 'cold' in illness
assert p.count() == 2
q1 = Q(eav__illness__icontains="flu")
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 3)
# Empty
# Should return patients without 'flu' in illness
assert p.count() == 3
def test_filtering_on_null_eav_field(self) -> None:
"""Test filtering on null EAV field."""
self.init_data()
q1 = Q(eav__illness__isnull=False)
p = Patient.objects.filter(~q1)
self.assertEqual(p.count(), 1)
def _order(self, ordering):
# Should return patients with null illness
assert p.count() == 1
def _order(self, ordering) -> list[str]:
query = Patient.objects.all().order_by(*ordering)
return list(query.values_list('name', flat=True))
return list(query.values_list("name", flat=True))
def assert_order_by_results(self, eav_attr='eav'):
self.assertEqual(
['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'],
self._order(['%s__city' % eav_attr]),
)
def assert_order_by_results(self, eav_attr="eav") -> None:
"""Test the ordering functionality of EAV attributes."""
# Ordering by a single EAV attribute
assert self._order([f"{eav_attr}__city"]) == [
"Bob",
"Eugene",
"Cyrill",
"Anne",
"Daniel",
]
self.assertEqual(
['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'],
self._order(['%s__age' % eav_attr, '%s__city' % eav_attr]),
)
# Ordering by multiple EAV attributes
assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [
"Eugene",
"Anne",
"Daniel",
"Bob",
"Cyrill",
]
self.assertEqual(
['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'],
self._order(['%s__fever' % eav_attr, '%s__age' % eav_attr]),
)
# Ordering by EAV attributes with different data types
assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [
"Eugene",
"Cyrill",
"Anne",
"Daniel",
"Bob",
]
self.assertEqual(
['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'],
self._order(['%s__fever' % eav_attr, '-name']),
)
# Combining EAV and regular model field ordering
assert self._order([f"{eav_attr}__fever", "-name"]) == [
"Eugene",
"Cyrill",
"Daniel",
"Bob",
"Anne",
]
self.assertEqual(
['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'],
self._order(['-name', '%s__age' % eav_attr]),
)
# Mixing regular and EAV field ordering
assert self._order(["-name", f"{eav_attr}__age"]) == [
"Eugene",
"Daniel",
"Cyrill",
"Bob",
"Anne",
]
self.assertEqual(
['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'],
self._order(['example__name']),
)
# Ordering by a related model field
assert self._order(["example__name"]) == [
"Anne",
"Bob",
"Cyrill",
"Daniel",
"Eugene",
]
with self.assertRaises(NotSupportedError):
Patient.objects.all().order_by('%s__first__second' % eav_attr)
# Error handling for unsupported nested EAV attributes
with pytest.raises(NotSupportedError):
Patient.objects.all().order_by(f"{eav_attr}__first__second")
with self.assertRaises(ObjectDoesNotExist):
Patient.objects.all().order_by('%s__nonsense' % eav_attr)
# Error handling for non-existent EAV attributes
with pytest.raises(ObjectDoesNotExist):
Patient.objects.all().order_by(f"{eav_attr}__nonsense")
def test_order_by(self):
self.init_data()
@ -291,11 +379,11 @@ class Queries(TestCase):
self.init_data()
eav.unregister(Patient)
eav.register(Patient, config_cls=CustomConfig)
self.assert_order_by_results(eav_attr='data')
self.assert_order_by_results(eav_attr="data")
def test_fk_filter(self):
e = ExampleModel.objects.create(name='test1')
p = Patient.objects.get_or_create(name='Beth', example=e)[0]
e = ExampleModel.objects.create(name="test1")
p = Patient.objects.get_or_create(name="Beth", example=e)[0]
c = ExampleModel.objects.filter(patient=p)
self.assertEqual(c.count(), 1)
@ -310,12 +398,12 @@ class Queries(TestCase):
# Use the filter method with 3 EAV attribute conditions
patients = Patient.objects.filter(
name='Anne',
name="Anne",
eav__age=3,
eav__illness='cold',
eav__fever='no',
eav__illness="cold",
eav__fever="no",
)
# Assert that the expected patient is returned
self.assertEqual(len(patients), 1)
self.assertEqual(patients[0].name, 'Anne')
self.assertEqual(patients[0].name, "Anne")

View file

@ -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

View file

@ -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
View 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