mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-04-01 22:20:32 +00:00
Compare commits
No commits in common. "master" and "1.7.0" have entirely different histories.
17 changed files with 866 additions and 1887 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload packages to Jazzband
|
- name: Upload packages to Jazzband
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
with:
|
with:
|
||||||
user: jazzband
|
user: jazzband
|
||||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
|
|
|
||||||
29
.github/workflows/test.yml
vendored
29
.github/workflows/test.yml
vendored
|
|
@ -1,30 +1,26 @@
|
||||||
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
||||||
name: test
|
name: test
|
||||||
|
|
||||||
"on":
|
"on": [push, pull_request, workflow_dispatch]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-matrix:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
django-version: ['4.2', '5.1', '5.2']
|
django-version: ["4.2", "5.0", "5.1"]
|
||||||
exclude:
|
exclude:
|
||||||
# Exclude Python 3.9 with Django 5.1 and 5.2
|
# Exclude Python 3.8 and 3.9 with Django 5.0
|
||||||
|
- python-version: '3.8'
|
||||||
|
django-version: '5.0'
|
||||||
- python-version: '3.9'
|
- python-version: '3.9'
|
||||||
|
django-version: '5.0'
|
||||||
|
# Exclude Python 3.8 and 3.9 with Django 5.1
|
||||||
|
- python-version: '3.8'
|
||||||
django-version: '5.1'
|
django-version: '5.1'
|
||||||
- python-version: '3.9'
|
- python-version: '3.9'
|
||||||
django-version: '5.2'
|
django-version: '5.1'
|
||||||
# Exclude Python 3.13 with Django 4.2
|
|
||||||
- python-version: '3.13'
|
|
||||||
django-version: '4.2'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -36,7 +32,6 @@ jobs:
|
||||||
- name: Install Poetry
|
- name: Install Poetry
|
||||||
uses: snok/install-poetry@v1
|
uses: snok/install-poetry@v1
|
||||||
with:
|
with:
|
||||||
version: 1.8.4
|
|
||||||
virtualenvs-create: true
|
virtualenvs-create: true
|
||||||
virtualenvs-in-project: true
|
virtualenvs-in-project: true
|
||||||
installer-parallel: true
|
installer-parallel: true
|
||||||
|
|
@ -60,6 +55,6 @@ jobs:
|
||||||
poetry run pip check
|
poetry run pip check
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# See https://pre-commit.com for more information
|
# See https://pre-commit.com for more information
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
|
@ -11,7 +11,7 @@ repos:
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.12
|
rev: v0.6.3
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter.
|
# Run the linter.
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
|
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -2,33 +2,12 @@
|
||||||
|
|
||||||
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||||
|
|
||||||
## 1.8.1 (2025-06-02)
|
## v1.7.0 (2024-09-01)
|
||||||
|
|
||||||
## 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
|
### What's Changed
|
||||||
|
|
||||||
- Enhance slug validation for Python identifier compliance
|
- Enhance slug validation for Python identifier compliance
|
||||||
- Migrate to ruff
|
- Migrate to ruff
|
||||||
- Drop support for Django 3.2
|
|
||||||
- Add support for Django 5.1
|
|
||||||
|
|
||||||
## 1.6.1 (2024-06-23)
|
## 1.6.1 (2024-06-23)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
|
from typing import Any, ClassVar, Dict, List, Sequence, Union
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
||||||
|
|
@ -11,9 +11,6 @@ from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
|
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
|
||||||
|
|
||||||
some_attribute = ClassVar[Dict[str, str]]
|
some_attribute = ClassVar[Dict[str, str]]
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ class BaseDynamicEntityForm(ModelForm):
|
||||||
|
|
||||||
if datatype == attribute.TYPE_ENUM:
|
if datatype == attribute.TYPE_ENUM:
|
||||||
values = attribute.get_choices().values_list("id", "value")
|
values = attribute.get_choices().values_list("id", "value")
|
||||||
choices = [("", ""), ("-----", "-----"), *list(values)]
|
choices = ["", "-----", *list(values)]
|
||||||
defaults.update({"choices": choices})
|
defaults.update({"choices": choices})
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
"""
|
|
||||||
Add uniqueness and integrity constraints to the Value model.
|
|
||||||
|
|
||||||
This migration adds database-level constraints to ensure:
|
|
||||||
1. Each entity (identified by UUID) can have only one value per attribute
|
|
||||||
2. Each entity (identified by integer ID) can have only one value per attribute
|
|
||||||
3. Each value must use either entity_id OR entity_uuid, never both or neither
|
|
||||||
|
|
||||||
These constraints ensure data integrity by preventing duplicate attribute values
|
|
||||||
for the same entity and enforcing the XOR relationship between the two types of
|
|
||||||
entity identification (integer ID vs UUID).
|
|
||||||
"""
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("eav", "0011_update_defaults_and_meta"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("entity_ct", "attribute", "entity_uuid"),
|
|
||||||
name="unique_entity_uuid_per_attribute",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("entity_ct", "attribute", "entity_id"),
|
|
||||||
name="unique_entity_id_per_attribute",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="value",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
check=models.Q(
|
|
||||||
models.Q(
|
|
||||||
("entity_id__isnull", False),
|
|
||||||
("entity_uuid__isnull", True),
|
|
||||||
),
|
|
||||||
models.Q(
|
|
||||||
("entity_id__isnull", True),
|
|
||||||
("entity_uuid__isnull", False),
|
|
||||||
),
|
|
||||||
_connector="OR",
|
|
||||||
),
|
|
||||||
name="ensure_entity_id_xor_entity_uuid",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -17,9 +17,9 @@ from .value import Value
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Attribute",
|
"Attribute",
|
||||||
"EAVModelMeta",
|
|
||||||
"Entity",
|
|
||||||
"EnumGroup",
|
"EnumGroup",
|
||||||
"EnumValue",
|
"EnumValue",
|
||||||
"Value",
|
"Value",
|
||||||
|
"Entity",
|
||||||
|
"EAVModelMeta",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import warnings
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
@ -312,10 +311,13 @@ class Attribute(models.Model):
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
if not self.slug.isidentifier():
|
if not self.slug.isidentifier():
|
||||||
warnings.warn(
|
raise ValidationError(
|
||||||
f"Slug '{self.slug}' is not a valid Python identifier. "
|
{
|
||||||
+ "Consider updating it.",
|
"slug": _(
|
||||||
stacklevel=3,
|
"Slug must be a valid Python identifier (no spaces, "
|
||||||
|
+ "special characters, or leading digits).",
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_choices(self):
|
def get_choices(self):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# ruff: noqa: UP007
|
# ruff: noqa: UP007
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from django.contrib.contenttypes import fields as generic
|
from django.contrib.contenttypes import fields as generic
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
@ -173,39 +173,21 @@ class Value(models.Model):
|
||||||
verbose_name = _("Value")
|
verbose_name = _("Value")
|
||||||
verbose_name_plural = _("Values")
|
verbose_name_plural = _("Values")
|
||||||
|
|
||||||
constraints: ClassVar[list[models.Constraint]] = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["entity_ct", "attribute", "entity_uuid"],
|
|
||||||
name="unique_entity_uuid_per_attribute",
|
|
||||||
),
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["entity_ct", "attribute", "entity_id"],
|
|
||||||
name="unique_entity_id_per_attribute",
|
|
||||||
),
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=(
|
|
||||||
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
|
|
||||||
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
|
|
||||||
),
|
|
||||||
name="ensure_entity_id_xor_entity_uuid",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""String representation of a Value."""
|
"""String representation of a Value."""
|
||||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
"""Representation of Value object."""
|
|
||||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
|
||||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Validate and save this value."""
|
"""Validate and save this value."""
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Representation of Value object."""
|
||||||
|
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||||
|
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||||
|
|
||||||
def natural_key(self) -> tuple[tuple[str, str], int, str]:
|
def natural_key(self) -> tuple[tuple[str, str], int, str]:
|
||||||
"""
|
"""
|
||||||
Retrieve the natural key for the Value instance.
|
Retrieve the natural key for the Value instance.
|
||||||
|
|
|
||||||
2193
poetry.lock
generated
2193
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.9"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "django-eav2"
|
name = "django-eav2"
|
||||||
description = "Entity-Attribute-Value storage for Django"
|
description = "Entity-Attribute-Value storage for Django"
|
||||||
version = "1.8.1"
|
version = "1.7.0"
|
||||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||||
packages = [
|
packages = [
|
||||||
{ include = "eav" }
|
{ include = "eav" }
|
||||||
|
|
@ -37,17 +37,17 @@ classifiers = [
|
||||||
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Topic :: Database",
|
"Topic :: Database",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
|
"Framework :: Django :: 5.0",
|
||||||
"Framework :: Django :: 5.1",
|
"Framework :: Django :: 5.1",
|
||||||
"Framework :: Django :: 5.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.semantic_release]
|
[tool.semantic_release]
|
||||||
|
|
@ -60,17 +60,17 @@ upload_to_release = false
|
||||||
build_command = "pip install poetry && poetry build"
|
build_command = "pip install poetry && poetry build"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.8"
|
||||||
django = ">=4.2,<5.3"
|
django = ">=4.2,<5.2"
|
||||||
|
|
||||||
[tool.poetry.group.test.dependencies]
|
[tool.poetry.group.test.dependencies]
|
||||||
mypy = "^1.6"
|
mypy = "^1.6"
|
||||||
ruff = ">=0.6.3,<0.13.0"
|
ruff = "^0.6.3"
|
||||||
|
|
||||||
safety = ">=2.3,<4.0"
|
safety = ">=2.3,<4.0"
|
||||||
|
|
||||||
pytest = ">=7.4.3,<9.0.0"
|
pytest = ">=7.4.3,<9.0.0"
|
||||||
pytest-cov = ">=4.1,<7.0"
|
pytest-cov = ">=4.1,<6.0"
|
||||||
pytest-randomly = "^3.15"
|
pytest-randomly = "^3.15"
|
||||||
pytest-django = "^4.5.2"
|
pytest-django = "^4.5.2"
|
||||||
hypothesis = "^6.87.1"
|
hypothesis = "^6.87.1"
|
||||||
|
|
@ -82,7 +82,7 @@ optional = true
|
||||||
|
|
||||||
[tool.poetry.group.docs.dependencies]
|
[tool.poetry.group.docs.dependencies]
|
||||||
sphinx = ">=5.0,<8.0"
|
sphinx = ">=5.0,<8.0"
|
||||||
sphinx-rtd-theme = ">=1.3,<4.0"
|
sphinx-rtd-theme = ">=1.3,<3.0"
|
||||||
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||||
m2r2 = "^0.3"
|
m2r2 = "^0.3"
|
||||||
tomlkit = ">=0.13.0,<0.14"
|
tomlkit = ">=0.13.0,<0.14"
|
||||||
|
|
@ -90,7 +90,6 @@ tomlkit = ">=0.13.0,<0.14"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
target-version = "py38"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["ALL"]
|
select = ["ALL"]
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class Encounter(TestBase):
|
||||||
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.patient}: encounter num {self.num}"
|
return "%s: encounter num %d" % (self.patient, self.num)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
|
||||||
|
|
@ -167,15 +167,8 @@ class TestAttributeModel(django.TestCase):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_attribute_create_with_invalid_slug() -> None:
|
def test_attribute_create_with_invalid_slug():
|
||||||
"""
|
with pytest.raises(ValidationError):
|
||||||
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(
|
Attribute.objects.create(
|
||||||
name="Test Attribute",
|
name="Test Attribute",
|
||||||
slug="123-invalid",
|
slug="123-invalid",
|
||||||
|
|
|
||||||
|
|
@ -211,41 +211,3 @@ def test_entity_admin_form_no_attributes(patient):
|
||||||
|
|
||||||
# 3 for 'name', 'email', 'example'
|
# 3 for 'name', 'email', 'example'
|
||||||
assert total_fields == expected_fields
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ def test_generate_slug_uniqueness() -> None:
|
||||||
generated_slugs: dict[str, str] = {}
|
generated_slugs: dict[str, str] = {}
|
||||||
for input_str in inputs:
|
for input_str in inputs:
|
||||||
slug = generate_slug(input_str)
|
slug = generate_slug(input_str)
|
||||||
assert slug not in generated_slugs.values(), (
|
assert (
|
||||||
f"Duplicate slug '{slug}' generated for input '{input_str}'"
|
slug not in generated_slugs.values()
|
||||||
)
|
), f"Duplicate slug '{slug}' generated for input '{input_str}'"
|
||||||
generated_slugs[input_str] = slug
|
generated_slugs[input_str] = slug
|
||||||
|
|
||||||
assert len(generated_slugs) == len(
|
assert len(generated_slugs) == len(
|
||||||
|
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
import pytest
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import IntegrityError
|
|
||||||
|
|
||||||
from eav.models import Attribute, Value
|
|
||||||
from test_project.models import Doctor, Patient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patient_ct() -> ContentType:
|
|
||||||
"""Return the content type for the Patient model."""
|
|
||||||
return ContentType.objects.get_for_model(Patient)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def doctor_ct() -> ContentType:
|
|
||||||
"""Return the content type for the Doctor model."""
|
|
||||||
# We use Doctor model for UUID tests since it already uses UUID as primary key
|
|
||||||
return ContentType.objects.get_for_model(Doctor)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def attribute() -> Attribute:
|
|
||||||
"""Create and return a test attribute."""
|
|
||||||
return Attribute.objects.create(
|
|
||||||
name="test_attribute",
|
|
||||||
datatype="text",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patient() -> Patient:
|
|
||||||
"""Create and return a patient with integer PK."""
|
|
||||||
# Patient model uses auto-incrementing integer primary keys
|
|
||||||
return Patient.objects.create(name="Patient with Int PK")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def doctor() -> Doctor:
|
|
||||||
"""Create and return a doctor with UUID PK."""
|
|
||||||
# Doctor model uses UUID primary keys, ideal for testing entity_uuid constraints
|
|
||||||
return Doctor.objects.create(name="Doctor with UUID PK")
|
|
||||||
|
|
||||||
|
|
||||||
class TestValueModelValidation:
|
|
||||||
"""Test Value model Python-level validation (via full_clean in save)."""
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_id_validation(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation prevents duplicate entity_id values.
|
|
||||||
|
|
||||||
The model's save() method calls full_clean() which should detect the
|
|
||||||
duplicate before it hits the database constraint.
|
|
||||||
"""
|
|
||||||
# Create first value - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create a second value with the same entity_ct, attribute, and entity_id
|
|
||||||
# This should fail with ValidationError from full_clean()
|
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the error message indicates uniqueness violation
|
|
||||||
assert "already exists" in str(excinfo.value)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_uuid_validation(
|
|
||||||
self,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation prevents duplicate entity_uuid values.
|
|
||||||
|
|
||||||
The model's full_clean() should detect the duplicate before it hits
|
|
||||||
the database constraint.
|
|
||||||
"""
|
|
||||||
# Create first value with UUID - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create a second value with the same entity_ct,
|
|
||||||
# attribute, and entity_uuid
|
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the error message indicates uniqueness violation
|
|
||||||
assert "already exists" in str(excinfo.value)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_entity_id_xor_entity_uuid_validation(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that model validation enforces XOR between entity_id and entity_uuid.
|
|
||||||
|
|
||||||
The model's full_clean() should detect if both or neither field is provided.
|
|
||||||
"""
|
|
||||||
# Try to create with both ID types
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Both IDs provided",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create with neither ID type
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=None,
|
|
||||||
entity_uuid=None,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="No IDs provided",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValueDatabaseConstraints:
|
|
||||||
"""
|
|
||||||
Test Value model database constraints when bypassing model validation.
|
|
||||||
|
|
||||||
These tests use bulk_create() which bypasses the save() method and its
|
|
||||||
full_clean() validation, allowing us to test the database constraints directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_id_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent duplicate entity_id values.
|
|
||||||
|
|
||||||
Even when bypassing model validation with bulk_create, the database
|
|
||||||
constraint should still prevent duplicates.
|
|
||||||
"""
|
|
||||||
# Create first value - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to bulk create a duplicate value, bypassing model validation
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second value",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_unique_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent duplicate entity_uuid values.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
still prevent duplicates.
|
|
||||||
"""
|
|
||||||
# Create first value with UUID - this should succeed
|
|
||||||
Value.objects.create(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="First UUID value",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to bulk create a duplicate value, bypassing model validation
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Second UUID value",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_entity_id_and_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent having both entity_id and entity_uuid.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
prevent having both fields set.
|
|
||||||
"""
|
|
||||||
# Try to bulk create with both ID types
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Both IDs provided",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_neither_entity_id_nor_entity_uuid_constraint(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that database constraints prevent having neither entity_id nor entity_uuid.
|
|
||||||
|
|
||||||
Even when bypassing model validation, the database constraint should
|
|
||||||
prevent having neither field set.
|
|
||||||
"""
|
|
||||||
# Try to bulk create with neither ID type
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=None,
|
|
||||||
entity_uuid=None,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="No IDs provided",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_happy_path_constraints(
|
|
||||||
self,
|
|
||||||
patient_ct: ContentType,
|
|
||||||
doctor_ct: ContentType,
|
|
||||||
attribute: Attribute,
|
|
||||||
patient: Patient,
|
|
||||||
doctor: Doctor,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Test that valid values pass both database constraints.
|
|
||||||
|
|
||||||
Values with either entity_id or entity_uuid (but not both) should be accepted.
|
|
||||||
"""
|
|
||||||
# Test with entity_id using bulk_create
|
|
||||||
values = Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=patient_ct,
|
|
||||||
entity_id=patient.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="Integer ID bulk created",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert len(values) == 1
|
|
||||||
|
|
||||||
# Test with entity_uuid using bulk_create
|
|
||||||
values = Value.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Value(
|
|
||||||
entity_ct=doctor_ct,
|
|
||||||
entity_uuid=doctor.id,
|
|
||||||
attribute=attribute,
|
|
||||||
value_text="UUID bulk created",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert len(values) == 1
|
|
||||||
Loading…
Reference in a new issue