mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-03-17 06:50:24 +00:00
Compare commits
68 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d941373d34 | ||
|
|
8b70e6afec | ||
|
|
5248498008 | ||
|
|
ce5f58dc50 | ||
|
|
f42a53bcaf | ||
|
|
32e20994f1 | ||
|
|
ec9a5b413d | ||
|
|
dcc643ff81 | ||
|
|
88d367f924 | ||
|
|
f7aeed0b14 | ||
|
|
a32e94adb6 | ||
|
|
794c71c7a6 | ||
|
|
8dd92753d6 | ||
|
|
539f0003a1 | ||
|
|
dc371db44f | ||
|
|
24c1de89fa | ||
|
|
3e5841af10 | ||
|
|
dc49b53c82 | ||
|
|
f193bd41cd | ||
|
|
f7f3d59b30 | ||
|
|
439fa5046f | ||
|
|
fe6db896bd | ||
|
|
682cf61840 | ||
|
|
7e572801b0 | ||
|
|
a38a6b9f5c | ||
|
|
2b9b9d7aa7 | ||
|
|
f6b3cf0865 | ||
|
|
041b19a1d2 | ||
|
|
e789a0dcd3 | ||
|
|
fafe528ea5 | ||
|
|
4deda2abc5 | ||
|
|
28c67b3d04 | ||
|
|
449ddc9248 | ||
|
|
a95f2a1c33 | ||
|
|
996512b04c | ||
|
|
73755c4fdf | ||
|
|
579a1e0fc7 | ||
|
|
b160b38309 | ||
|
|
436edd5492 | ||
|
|
9261d518da | ||
|
|
18148c2b97 | ||
|
|
eca5995616 | ||
|
|
1125887ba9 | ||
|
|
9c68743af8 | ||
|
|
6c44ba988a | ||
|
|
75708e3fbb | ||
|
|
34862ed30a | ||
|
|
abd93a44a1 | ||
|
|
835717bd27 | ||
|
|
3a7d8eec63 | ||
|
|
fef15f0ba6 | ||
|
|
ae73962bb2 | ||
|
|
70fceedda0 | ||
|
|
39c3540592 | ||
|
|
6c3c7f39e8 | ||
|
|
a47b1b05e0 | ||
|
|
b276cb3e35 | ||
|
|
5a1d7546f4 | ||
|
|
dc2cd2dff5 | ||
|
|
0e27224106 | ||
|
|
305740e2e6 | ||
|
|
d281ff97c2 | ||
|
|
e17778b522 | ||
|
|
0f218add0b | ||
|
|
f07e2d0506 | ||
|
|
0d82d5ab5a | ||
|
|
d0b531f7be | ||
|
|
8f18d5e7e2 |
17 changed files with 1884 additions and 863 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
|
|
|
|||
31
.github/workflows/test.yml
vendored
31
.github/workflows/test.yml
vendored
|
|
@ -1,26 +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: ["4.2", "5.0", "5.1"]
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
django-version: ['4.2', '5.1', '5.2']
|
||||
exclude:
|
||||
# Exclude Python 3.8 and 3.9 with Django 5.0
|
||||
- python-version: '3.8'
|
||||
django-version: '5.0'
|
||||
- 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'
|
||||
# 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
|
||||
|
||||
|
|
@ -32,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
|
||||
|
|
@ -55,6 +60,6 @@ jobs:
|
|||
poetry run pip check
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
|
@ -11,7 +11,7 @@ repos:
|
|||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.6.3
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -2,12 +2,33 @@
|
|||
|
||||
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||
|
||||
## v1.7.0 (2024-09-01)
|
||||
## 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar, Dict, List, Sequence, Union
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
||||
|
|
@ -11,6 +11,9 @@ 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]]
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class BaseDynamicEntityForm(ModelForm):
|
|||
|
||||
if datatype == attribute.TYPE_ENUM:
|
||||
values = attribute.get_choices().values_list("id", "value")
|
||||
choices = ["", "-----", *list(values)]
|
||||
choices = [("", ""), ("-----", "-----"), *list(values)]
|
||||
defaults.update({"choices": choices})
|
||||
|
||||
if value:
|
||||
|
|
|
|||
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Add uniqueness and integrity constraints to the Value model.
|
||||
|
||||
This migration adds database-level constraints to ensure:
|
||||
1. Each entity (identified by UUID) can have only one value per attribute
|
||||
2. Each entity (identified by integer ID) can have only one value per attribute
|
||||
3. Each value must use either entity_id OR entity_uuid, never both or neither
|
||||
|
||||
These constraints ensure data integrity by preventing duplicate attribute values
|
||||
for the same entity and enforcing the XOR relationship between the two types of
|
||||
entity identification (integer ID vs UUID).
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("eav", "0011_update_defaults_and_meta"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="value",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("entity_ct", "attribute", "entity_uuid"),
|
||||
name="unique_entity_uuid_per_attribute",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="value",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("entity_ct", "attribute", "entity_id"),
|
||||
name="unique_entity_id_per_attribute",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="value",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(
|
||||
("entity_id__isnull", False),
|
||||
("entity_uuid__isnull", True),
|
||||
),
|
||||
models.Q(
|
||||
("entity_id__isnull", True),
|
||||
("entity_uuid__isnull", False),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="ensure_entity_id_xor_entity_uuid",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -17,9 +17,9 @@ from .value import Value
|
|||
|
||||
__all__ = [
|
||||
"Attribute",
|
||||
"EAVModelMeta",
|
||||
"Entity",
|
||||
"EnumGroup",
|
||||
"EnumValue",
|
||||
"Value",
|
||||
"Entity",
|
||||
"EAVModelMeta",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
|
@ -311,13 +312,10 @@ class Attribute(models.Model):
|
|||
super().clean_fields(exclude=exclude)
|
||||
|
||||
if not self.slug.isidentifier():
|
||||
raise ValidationError(
|
||||
{
|
||||
"slug": _(
|
||||
"Slug must be a valid Python identifier (no spaces, "
|
||||
+ "special characters, or leading digits).",
|
||||
),
|
||||
},
|
||||
warnings.warn(
|
||||
f"Slug '{self.slug}' is not a valid Python identifier. "
|
||||
+ "Consider updating it.",
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
def get_choices(self):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# ruff: noqa: UP007
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||
|
||||
from django.contrib.contenttypes import fields as generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
|
@ -173,21 +173,39 @@ class Value(models.Model):
|
|||
verbose_name = _("Value")
|
||||
verbose_name_plural = _("Values")
|
||||
|
||||
constraints: ClassVar[list[models.Constraint]] = [
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_ct", "attribute", "entity_uuid"],
|
||||
name="unique_entity_uuid_per_attribute",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_ct", "attribute", "entity_id"],
|
||||
name="unique_entity_id_per_attribute",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
|
||||
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
|
||||
),
|
||||
name="ensure_entity_id_xor_entity_uuid",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of a Value."""
|
||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of Value object."""
|
||||
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Validate and save this value."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __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]:
|
||||
"""
|
||||
Retrieve the natural key for the Value instance.
|
||||
|
|
|
|||
2185
poetry.lock
generated
2185
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
requires = ["poetry-core>=1.9"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
|
||||
[tool.poetry]
|
||||
name = "django-eav2"
|
||||
description = "Entity-Attribute-Value storage for Django"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||
packages = [
|
||||
{ include = "eav" }
|
||||
|
|
@ -37,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 :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
]
|
||||
|
||||
[tool.semantic_release]
|
||||
|
|
@ -60,17 +60,17 @@ upload_to_release = false
|
|||
build_command = "pip install poetry && poetry build"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
django = ">=4.2,<5.2"
|
||||
python = "^3.9"
|
||||
django = ">=4.2,<5.3"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
mypy = "^1.6"
|
||||
ruff = "^0.6.3"
|
||||
ruff = ">=0.6.3,<0.13.0"
|
||||
|
||||
safety = ">=2.3,<4.0"
|
||||
|
||||
pytest = ">=7.4.3,<9.0.0"
|
||||
pytest-cov = ">=4.1,<6.0"
|
||||
pytest-cov = ">=4.1,<7.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-django = "^4.5.2"
|
||||
hypothesis = "^6.87.1"
|
||||
|
|
@ -82,7 +82,7 @@ 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.13.0,<0.14"
|
||||
|
|
@ -90,6 +90,7 @@ tomlkit = ">=0.13.0,<0.14"
|
|||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
|
|
|
|||
|
|
@ -101,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
|
||||
|
|
|
|||
|
|
@ -167,8 +167,15 @@ class TestAttributeModel(django.TestCase):
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_attribute_create_with_invalid_slug():
|
||||
with pytest.raises(ValidationError):
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -211,3 +211,41 @@ def test_entity_admin_form_no_attributes(patient):
|
|||
|
||||
# 3 for 'name', 'email', 'example'
|
||||
assert total_fields == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dynamic_form_renders_enum_choices():
|
||||
"""
|
||||
Test that enum choices render correctly in BaseDynamicEntityForm.
|
||||
|
||||
This test verifies the fix for issue #648 where enum choices weren't
|
||||
rendering correctly in Django 4.2.17 due to QuerySet unpacking issues.
|
||||
"""
|
||||
# Setup
|
||||
eav.register(Patient)
|
||||
|
||||
# Create enum values and group
|
||||
female = EnumValue.objects.create(value="Female")
|
||||
male = EnumValue.objects.create(value="Male")
|
||||
gender_group = EnumGroup.objects.create(name="Gender")
|
||||
gender_group.values.add(female, male)
|
||||
|
||||
Attribute.objects.create(
|
||||
name="gender",
|
||||
datatype=Attribute.TYPE_ENUM,
|
||||
enum_group=gender_group,
|
||||
)
|
||||
|
||||
# Create a patient
|
||||
patient = Patient.objects.create(name="Test Patient")
|
||||
|
||||
# Initialize the dynamic form
|
||||
form = PatientDynamicForm(instance=patient)
|
||||
|
||||
# Test rendering - should not raise any exceptions
|
||||
rendered_form = form.as_p()
|
||||
|
||||
# Verify the form rendered and contains the enum choices
|
||||
assert 'name="gender"' in rendered_form
|
||||
assert f'value="{female.pk}">{female.value}' in rendered_form
|
||||
assert f'value="{male.pk}">{male.value}' in rendered_form
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ def test_generate_slug_uniqueness() -> None:
|
|||
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}'"
|
||||
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(
|
||||
|
|
|
|||
319
tests/test_value.py
Normal file
319
tests/test_value.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from eav.models import Attribute, Value
|
||||
from test_project.models import Doctor, Patient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patient_ct() -> ContentType:
|
||||
"""Return the content type for the Patient model."""
|
||||
return ContentType.objects.get_for_model(Patient)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def doctor_ct() -> ContentType:
|
||||
"""Return the content type for the Doctor model."""
|
||||
# We use Doctor model for UUID tests since it already uses UUID as primary key
|
||||
return ContentType.objects.get_for_model(Doctor)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attribute() -> Attribute:
|
||||
"""Create and return a test attribute."""
|
||||
return Attribute.objects.create(
|
||||
name="test_attribute",
|
||||
datatype="text",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patient() -> Patient:
|
||||
"""Create and return a patient with integer PK."""
|
||||
# Patient model uses auto-incrementing integer primary keys
|
||||
return Patient.objects.create(name="Patient with Int PK")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def doctor() -> Doctor:
|
||||
"""Create and return a doctor with UUID PK."""
|
||||
# Doctor model uses UUID primary keys, ideal for testing entity_uuid constraints
|
||||
return Doctor.objects.create(name="Doctor with UUID PK")
|
||||
|
||||
|
||||
class TestValueModelValidation:
|
||||
"""Test Value model Python-level validation (via full_clean in save)."""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unique_entity_id_validation(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
patient: Patient,
|
||||
) -> None:
|
||||
"""
|
||||
Test that model validation prevents duplicate entity_id values.
|
||||
|
||||
The model's save() method calls full_clean() which should detect the
|
||||
duplicate before it hits the database constraint.
|
||||
"""
|
||||
# Create first value - this should succeed
|
||||
Value.objects.create(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
attribute=attribute,
|
||||
value_text="First value",
|
||||
)
|
||||
|
||||
# Try to create a second value with the same entity_ct, attribute, and entity_id
|
||||
# This should fail with ValidationError from full_clean()
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
Value.objects.create(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
attribute=attribute,
|
||||
value_text="Second value",
|
||||
)
|
||||
|
||||
# Verify the error message indicates uniqueness violation
|
||||
assert "already exists" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unique_entity_uuid_validation(
|
||||
self,
|
||||
doctor_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
doctor: Doctor,
|
||||
) -> None:
|
||||
"""
|
||||
Test that model validation prevents duplicate entity_uuid values.
|
||||
|
||||
The model's full_clean() should detect the duplicate before it hits
|
||||
the database constraint.
|
||||
"""
|
||||
# Create first value with UUID - this should succeed
|
||||
Value.objects.create(
|
||||
entity_ct=doctor_ct,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="First UUID value",
|
||||
)
|
||||
|
||||
# Try to create a second value with the same entity_ct,
|
||||
# attribute, and entity_uuid
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
Value.objects.create(
|
||||
entity_ct=doctor_ct,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="Second UUID value",
|
||||
)
|
||||
|
||||
# Verify the error message indicates uniqueness violation
|
||||
assert "already exists" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_entity_id_xor_entity_uuid_validation(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
patient: Patient,
|
||||
doctor: Doctor,
|
||||
) -> None:
|
||||
"""
|
||||
Test that model validation enforces XOR between entity_id and entity_uuid.
|
||||
|
||||
The model's full_clean() should detect if both or neither field is provided.
|
||||
"""
|
||||
# Try to create with both ID types
|
||||
with pytest.raises(ValidationError):
|
||||
Value.objects.create(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="Both IDs provided",
|
||||
)
|
||||
|
||||
# Try to create with neither ID type
|
||||
with pytest.raises(ValidationError):
|
||||
Value.objects.create(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=None,
|
||||
entity_uuid=None,
|
||||
attribute=attribute,
|
||||
value_text="No IDs provided",
|
||||
)
|
||||
|
||||
|
||||
class TestValueDatabaseConstraints:
|
||||
"""
|
||||
Test Value model database constraints when bypassing model validation.
|
||||
|
||||
These tests use bulk_create() which bypasses the save() method and its
|
||||
full_clean() validation, allowing us to test the database constraints directly.
|
||||
"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unique_entity_id_constraint(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
patient: Patient,
|
||||
) -> None:
|
||||
"""
|
||||
Test that database constraints prevent duplicate entity_id values.
|
||||
|
||||
Even when bypassing model validation with bulk_create, the database
|
||||
constraint should still prevent duplicates.
|
||||
"""
|
||||
# Create first value - this should succeed
|
||||
Value.objects.create(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
attribute=attribute,
|
||||
value_text="First value",
|
||||
)
|
||||
|
||||
# Try to bulk create a duplicate value, bypassing model validation
|
||||
with pytest.raises(IntegrityError):
|
||||
Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
attribute=attribute,
|
||||
value_text="Second value",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unique_entity_uuid_constraint(
|
||||
self,
|
||||
doctor_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
doctor: Doctor,
|
||||
) -> None:
|
||||
"""
|
||||
Test that database constraints prevent duplicate entity_uuid values.
|
||||
|
||||
Even when bypassing model validation, the database constraint should
|
||||
still prevent duplicates.
|
||||
"""
|
||||
# Create first value with UUID - this should succeed
|
||||
Value.objects.create(
|
||||
entity_ct=doctor_ct,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="First UUID value",
|
||||
)
|
||||
|
||||
# Try to bulk create a duplicate value, bypassing model validation
|
||||
with pytest.raises(IntegrityError):
|
||||
Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=doctor_ct,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="Second UUID value",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_entity_id_and_entity_uuid_constraint(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
patient: Patient,
|
||||
doctor: Doctor,
|
||||
) -> None:
|
||||
"""
|
||||
Test that database constraints prevent having both entity_id and entity_uuid.
|
||||
|
||||
Even when bypassing model validation, the database constraint should
|
||||
prevent having both fields set.
|
||||
"""
|
||||
# Try to bulk create with both ID types
|
||||
with pytest.raises(IntegrityError):
|
||||
Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="Both IDs provided",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_neither_entity_id_nor_entity_uuid_constraint(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
) -> None:
|
||||
"""
|
||||
Test that database constraints prevent having neither entity_id nor entity_uuid.
|
||||
|
||||
Even when bypassing model validation, the database constraint should
|
||||
prevent having neither field set.
|
||||
"""
|
||||
# Try to bulk create with neither ID type
|
||||
with pytest.raises(IntegrityError):
|
||||
Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=None,
|
||||
entity_uuid=None,
|
||||
attribute=attribute,
|
||||
value_text="No IDs provided",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_happy_path_constraints(
|
||||
self,
|
||||
patient_ct: ContentType,
|
||||
doctor_ct: ContentType,
|
||||
attribute: Attribute,
|
||||
patient: Patient,
|
||||
doctor: Doctor,
|
||||
) -> None:
|
||||
"""
|
||||
Test that valid values pass both database constraints.
|
||||
|
||||
Values with either entity_id or entity_uuid (but not both) should be accepted.
|
||||
"""
|
||||
# Test with entity_id using bulk_create
|
||||
values = Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=patient_ct,
|
||||
entity_id=patient.id,
|
||||
attribute=attribute,
|
||||
value_text="Integer ID bulk created",
|
||||
),
|
||||
],
|
||||
)
|
||||
assert len(values) == 1
|
||||
|
||||
# Test with entity_uuid using bulk_create
|
||||
values = Value.objects.bulk_create(
|
||||
[
|
||||
Value(
|
||||
entity_ct=doctor_ct,
|
||||
entity_uuid=doctor.id,
|
||||
attribute=attribute,
|
||||
value_text="UUID bulk created",
|
||||
),
|
||||
],
|
||||
)
|
||||
assert len(values) == 1
|
||||
Loading…
Reference in a new issue