Compare commits

..

No commits in common. "master" and "1.7.0" have entirely different histories.

17 changed files with 866 additions and 1887 deletions

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@release/v1
uses: pypa/gh-action-pypi-publish@master
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}

View file

@ -1,30 +1,26 @@
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
name: test
"on":
push:
branches:
- '**'
pull_request:
workflow_dispatch:
"on": [push, pull_request, workflow_dispatch]
jobs:
test-matrix:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
django-version: ['4.2', '5.1', '5.2']
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["4.2", "5.0", "5.1"]
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'
django-version: '5.0'
# Exclude Python 3.8 and 3.9 with Django 5.1
- python-version: '3.8'
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'
django-version: '5.1'
steps:
- uses: actions/checkout@v4
@ -36,7 +32,6 @@ jobs:
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.8.4
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
@ -60,6 +55,6 @@ jobs:
poetry run pip check
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
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: v5.0.0
rev: v4.6.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.11.12
rev: v0.6.3
hooks:
# Run the linter.
- id: ruff

View file

@ -2,33 +2,12 @@
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)
## v1.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)

View file

@ -2,7 +2,7 @@
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.admin.options import InlineModelAdmin, ModelAdmin
@ -11,9 +11,6 @@ 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]]

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from django.contrib.contenttypes.models import ContentType
@ -312,10 +311,13 @@ class Attribute(models.Model):
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,
raise ValidationError(
{
"slug": _(
"Slug must be a valid Python identifier (no spaces, "
+ "special characters, or leading digits).",
),
},
)
def get_choices(self):

View file

@ -1,7 +1,7 @@
# ruff: noqa: UP007
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.models import ContentType
@ -173,39 +173,21 @@ 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.

2193
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
[build-system]
requires = ["poetry-core>=1.9"]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "django-eav2"
description = "Entity-Attribute-Value storage for Django"
version = "1.8.1"
version = "1.7.0"
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.9"
django = ">=4.2,<5.3"
python = "^3.8"
django = ">=4.2,<5.2"
[tool.poetry.group.test.dependencies]
mypy = "^1.6"
ruff = ">=0.6.3,<0.13.0"
ruff = "^0.6.3"
safety = ">=2.3,<4.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-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,<4.0"
sphinx-rtd-theme = ">=1.3,<3.0"
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
m2r2 = "^0.3"
tomlkit = ">=0.13.0,<0.14"
@ -90,7 +90,6 @@ tomlkit = ">=0.13.0,<0.14"
[tool.ruff]
line-length = 88
target-version = "py38"
[tool.ruff.lint]
select = ["ALL"]

View file

@ -101,7 +101,7 @@ class Encounter(TestBase):
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
def __str__(self):
return f"{self.patient}: encounter num {self.num}"
return "%s: encounter num %d" % (self.patient, self.num)
def __repr__(self):
return self.name

View file

@ -167,15 +167,8 @@ class TestAttributeModel(django.TestCase):
@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):
def test_attribute_create_with_invalid_slug():
with pytest.raises(ValidationError):
Attribute.objects.create(
name="Test Attribute",
slug="123-invalid",

View file

@ -211,41 +211,3 @@ 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

View file

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

View file

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