Compare commits

..

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

102 changed files with 1894 additions and 8660 deletions

4
.coveragerc Normal file
View file

@ -0,0 +1,4 @@
[run]
omit =
*/migrations/*
eav/__init__.py

View file

@ -1,17 +0,0 @@
# Check https://editorconfig.org for more information
# This is the main config file for this project:
root = true
[*]
charset = utf-8
trim_trailing_whitespace = true
end_of_line = lf
indent_style = space
insert_final_newline = true
indent_size = 2
[*.py]
indent_size = 4
[*.pyi]
indent_size = 4

View file

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

View file

@ -1,31 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
---
Provide a general summary of the issue in the title above.
## Expected Behavior
Tell us what should happen.
## Actual Behavior
Tell us what happens instead.
## Possible Fix
Not obligatory, but you can suggest a fix or reason for the bug.
## Steps to Reproduce
This is not required, but it would be highly appreciated if you
provided a link to a live example, or an unambiguous set of steps to
reproduce this bug. Include code to reproduce, if relevant.
## Your Environment
Include relevant details about the environment you experienced the bug in.
* Version used:
* Environment name and version (e.g. Django 2.0.4, pip 9.0.1):
* Operating System and version:
* Link to your project (if applicable):

View file

@ -1,17 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
---
Provide a general summary of the issue in the title above.
## Detailed Description
Provide a description of the change or addition you are proposing.
## Context
Why is this change important to you? How would you use it?
How can it benefit other users?
## Possible Implementation
Not obligatory, but you may suggest an idea for implementing addition or change.

View file

@ -1,15 +0,0 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
time: "02:00"
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
time: "02:00"
open-pull-requests-limit: 10

View file

@ -1,54 +0,0 @@
# I'm helping!
<!--
Thank you for submitting a Pull Request. We appreciate it!
Please, fill in all the required information
to make our review and merging processes easier.
-->
## Checklist
<!--
Please check everything that applies:
-->
- [ ] I have double checked that there are no unrelated changes in this pull request (old patches, accidental config files, etc.)
- [ ] I have created at least one test case for the changes I have made
- [ ] I have updated the documentation for the changes I have made
- [ ] I have added my changes to the `CHANGELOG.md`
## Pull Request type
<!--
Please try to limit your pull request to one type, submit multiple pull requests if needed.
-->
Please check the type of change your PR introduces:
- [ ] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, renaming)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] Documentation content changes
- [ ] Other (please describe):
## Related issue(s)
<!--
Mark what issues this Pull Request closes or references.
Format is:
- Closes #issue-number
- Refs #issue-number
Example. Refs #0
Documentation: https://blog.github.com/2013-05-14-closing-issues-via-pull-requests/
-->
## Other Information
<!--
If you have any other comments, feel free to share!
-->

View file

@ -1,39 +0,0 @@
name: Release
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository == 'jazzband/django-eav2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U poetry twine
- name: Build package
run: |
poetry build
twine check dist/*
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository_url: https://jazzband.co/projects/django-eav2/upload

View file

@ -1,65 +0,0 @@
# 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:
jobs:
test-matrix:
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']
exclude:
# Exclude Python 3.9 with Django 5.1 and 5.2
- python-version: '3.9'
django-version: '5.1'
- python-version: '3.9'
django-version: '5.2'
# Exclude Python 3.13 with Django 4.2
- python-version: '3.13'
django-version: '4.2'
steps:
- uses: actions/checkout@v4
- name: Set up python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.8.4
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Set up cache
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
- name: Install dependencies
run: |
poetry install
poetry run pip install --upgrade pip
poetry run pip install --upgrade "django==${{ matrix.django-version }}.*"
- name: Run tests
run: |
poetry run pytest
poetry check
poetry run pip check
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage.xml

5
.gitignore vendored
View file

@ -126,8 +126,3 @@ tags
## Mac
.DS_Store
## IDE
.idea
.vscode

View file

@ -1,27 +0,0 @@
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.12
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/remastr/pre-commit-django-check-migrations
rev: v0.1.0
hooks:
- id: check-migrations-created
args: [--manage-path=manage.py]
additional_dependencies: [django==4.1]

View file

@ -1,24 +0,0 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
tools:
python: '3.9'
jobs:
# See https://github.com/readthedocs/readthedocs.org/issues/4912
pre_create_environment:
- asdf plugin add poetry
- asdf install poetry latest
- asdf global poetry latest
- poetry config virtualenvs.create false
post_install:
- . "$(pwd | rev | sed 's/stuokcehc/svne/' | rev)/bin/activate" && poetry install --only main --only docs
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/source/conf.py
fail_on_warning: true

24
.travis.yml Normal file
View file

@ -0,0 +1,24 @@
language: python
matrix:
include:
- python: 2.7
env: TOXENV=py27-django111
- python: 3.5
env: TOXENV=py35-django111
- python: 3.6
env: TOXENV=py36-django111
- python: 3.7-dev
env: TOXENV=py37-django20
- python: 3.7-dev
env: TOXENV=py37-djangotip
install:
- pip install Django>=1.11
- pip install coveralls==1.3.0
- pip install coverage==4.5.1
- pip install tox-travis==0.10
before_script:
- coverage erase
script:
- coverage run --source=eav runtests; tox
after_success:
- COVERALLS_REPO_TOKEN=71NkMDQFpFKB9QYXoK12LYuWUEmQ2wD6V coveralls

View file

@ -1,171 +0,0 @@
# Version History
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
- Corrects `BaseEntityAdmin` integration into Django Admin site
- Split model modules by @iacobfred in https://github.com/jazzband/django-eav2/pull/467
- Add Django 5.0 and Python 3.12 to the testing by @cclauss in https://github.com/jazzband/django-eav2/pull/487
- Fix typos with codespell by @cclauss in https://github.com/jazzband/django-eav2/pull/489
- Enhance BaseEntityAdmin by @Dresdn in https://github.com/jazzband/django-eav2/pull/541
- Remove support for Django < 3.2 and Python < 3.8 by @Dresdn in https://github.com/jazzband/django-eav2/pull/542
## 1.5.0 (2023-11-08)
### Bug Fixes
- Fixes querying with multiple eav kwargs [#395](https://github.com/jazzband/django-eav2/issues/395)
### Features
- Support for many type of primary key (UUIDField, BigAutoField)
- Support for natural key use for some models for serialization (EnumValue, EnumGroup, Attribute, Value)
- Add support for Django 4.2
- Add support for Python 3.11
## 1.4.0 (2023-07-07)
### Features
- Support Bahasa Indonesia Translations
- Support Django 4.2
## 1.3.1 (2023-02-22)
### Bug Fixes
- Generate missing migrations [#331](https://github.com/jazzband/django-eav2/issues/331)
## 1.3.0 (2023-02-10)
### Features
- Add support for Django 4.1
### Bug Fixes
- Fixes missing `Add another` button for inlines in `BaseEntityAdmin`
- Fixes saving of Attribute date types rendering using `BaseDynamicEntityForm` [#261](https://github.com/jazzband/django-eav2/issues/261)
### Misc
- Drops support for Django 2.2 and Python 3.7
## 1.2.3 (2022-08-15)
### Bug Fixes
- Don't mark doc8 as a dependency [#235](https://github.com/jazzband/django-eav2/issues/235)
- Make Read the Docs dependencies all optional
## 1.2.2 (2022-08-13)
### Bug Fixes
- Fixes AttributeError when using CSVFormField [#187](https://github.com/jazzband/django-eav2/issues/187)
- Fixes slug generation for Attribute.name fields longer than 50 characters [#223](https://github.com/jazzband/django-eav2/issues/223)
- Migrates Attribute.slug to django.db.models.SlugField() [#223](https://github.com/jazzband/django-eav2/issues/223)
## 1.2.1 (2022-02-08)
### Bug Fixes
- Fixes FieldError when filtering on foreign keys [#163](https://github.com/jazzband/django-eav2/issues/163)
## 1.2.0 (2021-12-18)
### Features
- Adds 64-bit support for `Value.value_int`
- Adds Django 4.0 and Python 3.10 support
### Misc
- Drops support for Django 3.1 and Python 3.6
## 1.1.0 (2021-11-07)
### Features
- Adds support for entity models with UUId as a primary key #38
### Bug Fixes
- Fixes `ValueError` for models without local managers #41
- Fixes `str()` and `repr()` for `EnumGroup` and `EnumValue` objects #91
### Misc
- Bumps min python version to `3.6.2`
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/1.0.0...1.1.0>
## 1.0.0 (2021-10-21)
### Breaking Changes
- Drops support for `django1.x`
- Drops support for `django3.0`
- Moves `JSONField()` datatype to `django-jsonfield-backport` for Django2.2 instances
### Features
- Adds support for `django3.2`
- Adds support for `python3.9`
- Adds support for `defaults` keyword on `get_or_create()`
### #Misc
- Revamps all tooling, including moving to `poetry`, `pytest`, and `black`
- Adds Github Actions and Dependabot
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.14.0...1.0.0>
## 0.14.0 (2021-04-23)
### Misc
- This release will be the last to support this range of Django versions: 1.11, 2.0, 2.1, 2.2, 3.0. SInce all of their extended support was ended by Django Project.
- From the next release only will be supported 2.2 LTS, 3.1, and 3.2 LTS (eventually 4.x)
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.13.0...0.14.0>
(Anything before 0.14.0 was not recorded.)

View file

@ -1,46 +0,0 @@
# Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -1,65 +0,0 @@
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/)
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).
# Contributing
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
- Becoming a maintainer
## Dependencies
We use [poetry](https://github.com/sdispater/poetry) to manage the dependencies.
To install them you would need to run `install` command:
```bash
poetry install
```
To activate your `virtualenv` run `poetry shell`.
## Tests
We use `pytest` and `flake8` for quality control.
To run all tests:
```bash
pytest
```
## We develop with Github
We use github to host code, to track issues and feature requests, as well as accept pull requests.
### We use [Github Flow](https://guides.github.com/introduction/flow/index.html), so all code changes from community happen through pull requests
Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests:
1. Fork the repo and create your branch from `master`.
2. If you've added code that should be tested, add tests.
3. If you've changed APIs, update the documentation.
4. Ensure the test suite passes.
5. Make sure your code lints.
6. Describe the pull request using [this](https://github.com/jazzband/django-eav2/blob/master/PULL_REQUEST_TEMPLATE.md) template.
### Any contributions you make will be under the GNU Lesser General Public License v3.0
In short, when you submit code changes, your submissions are understood to be under the same [LGPLv3](https://choosealicense.com/licenses/lgpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
### Report bugs using Github's [issues](https://github.com/jazzband/django-eav2/issues)
We use GitHub issues to track public bugs. Report a bug by opening a new issue. Use [this](https://github.com/jazzband/django-eav2/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) template to describe your reports.
### Use a consistent coding style
We use [Black](https://github.com/psf/black) and (working towards) [wemake-python-styleguide](https://github.com/wemake-services/wemake-python-styleguide) for code and [Google-style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) reStructuredText for doc-strings.
<hr>
<br>
<br>
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md).

View file

@ -9,11 +9,8 @@ Contributors, alphabetically:
* IwoHerka (Iwo Herka)
* jpwhite3
* katembu (Moses Katembu)
* lvm (Mauro Lizaur)
* madEng84
* MajekX (Adam Majczyk)
* nicpottier (Nic Pottier)
* pisemsky (Evgeny Pisemsky)
* tavaresb (Bruno Tavares)
* therefromhere (John Carter)
* timlinux (Tim Sutton)

View file

@ -1,6 +1,6 @@
This software is derived from Django EAV (https://github.com/mvpdev/django-eav)
which, in turn, was derived from EAV Django, originally written and copyrighted
by Andrey Mikhaylenko <https://pypi.org/project/eav-django/>.
by Andrey Mikhaylenko <http://pypi.python.org/pypi/eav-django>.
This is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published

174
README.md
View file

@ -1,158 +1,33 @@
[![Build Status](https://github.com/jazzband/django-eav2/actions/workflows/test.yml/badge.svg)](https://github.com/jazzband/django-eav2/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/jazzband/django-eav2/branch/master/graph/badge.svg?token=BJk3zS22BS)](https://codecov.io/gh/jazzband/django-eav2)
[![Python Version](https://img.shields.io/pypi/pyversions/django-eav2.svg)](https://pypi.org/project/django-eav2/)
[![Django Version](https://img.shields.io/pypi/djversions/django-eav2.svg?color=green)](https://pypi.org/project/django-eav2/)
[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/)
[![Build Status](https://travis-ci.org/makimo/django-eav2.svg?branch=master)](https://travis-ci.org/makimo/django-eav2)
[![Coverage Status](https://coveralls.io/repos/github/makimo/django-eav2/badge.svg?branch=master)](https://coveralls.io/github/makimo/django-eav2?branch=master)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/159540d899bd41bb860f0ce996427e1f)](https://www.codacy.com/app/IwoHerka/django-eav2?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=makimo/django-eav2&amp;utm_campaign=Badge_Grade)
[![Maintainability](https://api.codeclimate.com/v1/badges/b90eacf7a90db4b58f13/maintainability)](https://codeclimate.com/github/makimo/django-eav2/maintainability)
![Python Version](https://img.shields.io/badge/Python-2.7,%203.5,%203.6,%203.7dev-blue.svg)
![Django Version](https://img.shields.io/badge/Django-1.11,%202.0,%20tip-green.svg)
## Django EAV 2 - Entity-Attribute-Value storage for Django
Django EAV 2 is a fork of django-eav (which itself was derived from eav-django).
You can find documentation <a href="https://django-eav2.rtfd.io">here</a>.
## What is EAV anyway?
> Entityattributevalue model (EAV) is a data model to encode, in a space-efficient manner, entities where the number of attributes (properties, parameters) that can be used to describe them is potentially vast, but the number that will actually apply to a given entity is relatively modest. Such entities correspond to the mathematical notion of a sparse matrix. (Wikipedia)
Data in EAV is stored as a 3-tuple (typically corresponding to three distinct tables):
- The entity: the item being described, e.g. `Person(name='Mike')`.
- The attribute: often a foreign key into a table of attributes, e.g. `Attribute(slug='height', datatype=FLOAT)`.
- The value of the attribute, with links both an attribute and an entity, e.g. `Value(value_float=15.5, person=mike, attr=height)`.
Entities in **django-eav2** are your typical Django model instances. Attributes (name and type) are stored in their own table, which makes it easy to manipulate the list of available attributes in the system. Values are an intermediate table between attributes and entities, each instance holding a single value.
This implementation also makes it easy to edit attributes in Django Admin and form instances.
You will find detailed description of the EAV here:
- [Wikipedia - Entityattributevalue model](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model)
## EAV - The Good, the Bad or the Ugly?
EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](https://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintenance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV.
### When to use EAV?
Originally, EAV was introduced to workaround a problem which cannot be easily solved within relational model. In order to achieve this, EAV bypasses normal schema restrictions. Some refer to this as an example of the [inner-platform effect](https://en.wikipedia.org/wiki/Inner-platform_effect#Examples). Naturally, in such scenarios RDMS resources cannot be used efficiently.
Typical application of the EAV model sets to solve the problem of sparse data with a large number of applicable attributes, but only a small fraction that applies to a given entity that may not be known beforehand. Consider the classic example:
> A problem that data modelers commonly encounter in the biomedical domain is organizing and storing highly diverse and heterogeneous data. For example, a single patient may have thousands of applicable descriptive parameters, all of which need to be easily accessible in an electronic patient record system. These requirements pose significant modeling and implementation challenges. [1]
And:
> [...] what do you do when you have customers that demand real-time, on-demand addition of attributes that they want to store? In one of the systems I manage, our customers wanted to do exactly this. Since we run a SaaS (software as a service) application, we have many customers across several different industries, who in turn want to use our system to store different types of information about _their_ customers. A salon chain might want to record facts such as 'hair color,' 'hair type,' and 'haircut frequency'; while an investment company might want to record facts such as 'portfolio name,' 'last portfolio adjustment date,' and 'current portfolio balance.' [2]
In both of these problems we have to deal with sparse and heterogeneous properties that apply only to potentially different subsets of particular entities. Applying EAV to a sub-schema of the database allows to model the desired behaviour. Traditional solution would involves wide tables with many columns storing NULL values for attributes that don't apply to an entity.
Very common use case for EAV are custom product attributes in E-commerce implementations, such as Magento. [3]
As a rule of thumb, EAV can be used when:
- Model attributes are to be added and removed by end users (or are unknowable in some different way). EAV supports these without ALTER TABLE statements and allows the attributes to be strongly typed and easily searchable.
- There will be many attributes and values are sparse, in contrast to having tables with mostly-null columns.
- The data is highly dynamic/volatile/vulnerable to change. This problem is present in the second example given above. Other example would be rapidly evolving system, such as a prototype with constantly changing requirements.
- We want to store meta-data or supporting information, e.g. to customize system's behavior.
- Numerous classes of data need to be represented, each class has a limited number of attributes, but the number of instances of each class is very small.
- We want to minimise programmer's input when changing the data model.
For more throughout discussion on the appropriate use-cases see:
1. [Wikipedia - Scenarios that are appropriate for EAV modeling](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model#Scenarios_that_are_appropriate_for_EAV_modeling)
2. [StackOverflow - Entity Attribute Value Database vs. strict Relational Model E-commerce](https://stackoverflow.com/questions/870808/entity-attribute-value-database-vs-strict-relational-model-ecommerce)
3. [WikiWikiWeb - Generic Data Model](https://wiki.c2.com/?GenericDataModel)
## When to avoid it?
As we outlined in the opening section, EAV is a trade-off. It should not be used when:
##### 1. System is performance critical
> Attribute-centric query is inherently more difficult when data are stored in EAV form than when they are stored conventionally. [4]
In general, the more structured your data model, the more efficiently you can deal with it. Therefore, loose data storage such as EAV has obvious trade-off in performance. Specifically, application of the EAV model makes performing JOINs on tables more complicated.
##### 2. Low complexity/low maintenance cost is of priority
EAV complicates data model by splitting information across tables. This increases conceptual complexity as well as SQL statements required to query the data. In consequence, optimization in one area that also makes the system harder to understand and maintain.
However, it is important to note that:
> An EAV design should be employed only for that sub-schema of a database where sparse attributes need to be modeled: even here, they need to be supported by third normal form metadata tables. There are relatively few database-design problems where sparse attributes are encountered: this is why the circumstances where EAV design is applicable are relatively rare. [1]
## Alternatives
In some use-cases, JSONB (binary JSON data) datatype (Postgres 9.4+ and analogous in other RDMSs) can be used as an alternative to EAV. JSONB supports indexing, which amortizes performance trade-off. It's important to keep in mind that JSONB is not RDMS-agnostic solution and has it's own problems, such as typing.
You can find documentation <a href="https://django-eav-2.rtfd.io">here</a>.
## Installation
Install with pip
You can install **django-eav2** from three sources:
```bash
# From PyPI via pip
pip install django-eav2
```
## Configuration
# From source via pip
pip install git+https://github.com/makimo/django-eav2@master
Add `eav` to `INSTALLED_APPS` in your settings.
# From source via setuptools
git clone git@github.com:makimo/django-eav2.git
cd django-eav2
python setup.py install
```python
INSTALLED_APPS = [
...
'eav',
]
```
Add `django.db.models.UUIDField` or `django.db.models.BigAutoField` as value of `EAV2_PRIMARY_KEY_FIELD` in your settings
``` python
EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" # as example
```
### Note: Primary key mandatory modification field
If the primary key of eav models are to be modified (UUIDField -> BigAutoField, BigAutoField -> UUIDField) in the middle of the project when the migrations are already done, you have to change the value of `EAV2_PRIMARY_KEY_FIELD` in your settings.
##### Step 1
Change the value of `EAV2_PRIMARY_KEY_FIELD` into `django.db.models.CharField` in your settings.
```python
EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField"
```
Run the migrations
```bash
python manage.py makemigrations
python manage.py migrate
```
##### Step 2
Change the value of `EAV2_PRIMARY_KEY_FIELD` into the desired value (`django.db.models.BigAutoField` or `django.db.models.UUIDField`) in your settings.
```python
EAV2_PRIMARY_KEY_FIELD = "django.db.models.BigAutoField" # as example
```
Run again the migrations.
```bash
python manage.py makemigrations
python manage.py migrate
```
### Note: Django 2.2 Users
Since `models.JSONField()` isn't supported in Django 2.2, we use [django-jsonfield-backport](https://github.com/laymonage/django-jsonfield-backport) to provide [JSONField](https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends) functionality.
This requires adding `django_jsonfield_backport` to your `INSTALLED_APPS` as well.
```python
INSTALLED_APPS = [
...
'eav',
'django_jsonfield_backport',
]
# To uninstall:
python setup.py install --record files.txt
rm $(cat files.txt)
```
## Getting started
@ -190,13 +65,4 @@ Supplier.objects.filter(eav__city='London')
# = <EavQuerySet [<Supplier: Supplier object (1)>]>
```
**What next? Check out the <a href="https://django-eav2.readthedocs.io/en/latest/#documentation">documentation</a>.**
---
### References
[1] Exploring Performance Issues for a Clinical Database Organized Using an Entity-Attribute-Value Representation, https://doi.org/10.1136/jamia.2000.0070475 <br>
[2] What is so bad about EAV, anyway?, https://sqlblog.org/2009/11/19/what-is-so-bad-about-eav-anyway <br>
[3] Magento for Developers: Part 7—Advanced ORM: Entity Attribute Value, https://devdocs.magento.com/guides/m1x/magefordev/mage-for-dev-7.html <br>
[4] Data Extraction and Ad Hoc Query of an Entity— Attribute— Value Database, https://www.ncbi.nlm.nih.gov/pmc/articles/PMC61332/
### What next? Check out <a href="https://django-eav-2.readthedocs.io/en/improvement-docs/">documentation</a>.

View file

@ -17,4 +17,4 @@ help:
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

Binary file not shown.

View file

@ -0,0 +1,66 @@
@import url('https://fonts.googleapis.com/css?family=Roboto');
@font-face {
font-family: "Roboto Slab";
src: url("./RobotoSlab-Regular.ttf");
}
pre {
background-color: #f6f6f6 !important;
}
.doc-title h1 {
text-align: center;
padding: 2rem !important;
}
.doc-api {
font-size: 14px;
}
div.sphinxsidebar h3 {
font-size: 21px !important;
}
body {
font-size: 16px;
}
div.admonition p.admonition-title, div.sphinxsidebar h3, div.sphinxsidebar h4,
div.sphinxsidebar input, div.body h1, div.body h2, div.body h3, div.body h4,
div.body h5, div.body h6 { font-family: 'Roboto Slab', 'Helvetica', 'Arial',
sans-serif; font-weight: 400; }
div.body h1, div.body h2, div.body h3, div.body h4,
div.body h5, div.body h6 { color: #353535; }
pre, code { font-family: 'Ubuntu Mono', 'Consolas', 'Menlo',
'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono';
font-size: 15px; background: transparent; }
pre, * pre { padding: 7px 0 7px 30px!important;
margin: 15px 0!important;
line-height: 1.3; }
div.body { color: #3E4349; }
a { color: #5D2CD1; }
a:hover { color: #7546E3; }
p.version-warning { background-color: #7546E3; }
a.reference { border-bottom: 1px dotted #5D2CD1; }
a.reference:hover { border-bottom: 1px solid #7546E3; }
a.footnote-reference { border-bottom: 1px dotted #5D2CD1; }
a.footnote-reference:hover { border-bottom: 1px solid #7546E3; }
a:hover code { background-color: #eeeeee; }
code.xref, a code { background-color: #E8EFF0;
border-bottom: 1px solid white; }
div.indexwrapper h1 {
text-indent: -999999px;
background: url(click.png) no-repeat center center;
height: 200px;
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
div.indexwrapper h1 {
background: url(click@2x.png) no-repeat center center;
background-size: 420px 175px;
}
}

View file

@ -1,4 +1,4 @@
{% extends "!layout.html" %}
{% block extrahead %}
<a href="https://github.com/jazzband/django-eav2"><img style="position: fixed; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png" alt="Fork me on GitHub"></a>
<a href="https://github.com/makimo/django-eav2"><img style="position: fixed; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png" alt="Fork me on GitHub"></a>
{% endblock %}

View file

@ -6,6 +6,6 @@ Django EAV 2 is an entity-attribute-value storage for modern Django.
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="https://pypi.org/project/django-eav2/">PyPI</a></li>
<li><a href="https://github.com/jazzband/django-eav2">GitHub</a></li>
<li><a href="https://github.com/jazzband/django-eav2/issues">Issue Tracker</a></li>
<li><a href="http://github.com/makimo/django-eav2">GitHub</a></li>
<li><a href="http://github.com/makimo/django-eav2/issues">Issue Tracker</a></li>
</ul>

View file

@ -1,37 +1,32 @@
# Sphinx documentation generator configuration.
#
# More information on the configuration options is available at:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
from __future__ import annotations
# -*- coding: utf-8 -*-
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
import os
import sys
from pathlib import Path
import django
from django.conf import settings
from sphinx.ext.autodoc import between
# 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()))
sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath('../../'))
# 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",
],
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"),
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'eav'
]
)
# Call django.setup to load installed apps and other stuff.
@ -39,82 +34,128 @@ django.setup()
# -- Project information -----------------------------------------------------
project = "Django EAV 2"
copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001
author = "-"
project = 'Django EAV 2'
copyright = '2018, Iwo Herka and team at MAKIMO'
author = '-'
# The short X.Y version
version = ""
version = ''
# The full version, including alpha/beta/rc tags
release = "0.10.0"
def setup(app):
"""Use the configuration file itself as an extension."""
app.connect(
"autodoc-process-docstring",
between(
"^.*IGNORE.*$",
exclude=True,
),
)
return app
release = '0.10.0'
# -- 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',
]
templates_path = ["_templates"]
html_theme_options = dict(
show_powered_by = False,
show_related = True,
fixed_sidebar = True,
font_family = 'Roboto'
)
source_suffix = ".rst"
templates_path = ['_templates']
master_doc = "index"
source_suffix = '.rst'
language = "en"
master_doc = 'index'
exclude_patterns = ["build"]
language = None
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
pygments_style = 'sphinx'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
def setup(app):
app.add_stylesheet('styles.css')
app.connect('autodoc-process-docstring', between('^.*IGNORE.*$', exclude=True))
return app
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"
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'DjangoEAV2doc'
# -- Options for LaTeX output ------------------------------------------------
latex_elements: dict[str, str] = {}
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# 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'),
]
@ -123,13 +164,8 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(
master_doc,
"djangoeav2",
"Django EAV 2 Documentation",
[author],
1,
),
(master_doc, 'djangoeav2', 'Django EAV 2 Documentation',
[author], 1)
]
@ -139,15 +175,9 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
"DjangoEAV2",
"Django EAV 2 Documentation",
author,
"DjangoEAV2",
"One line description of project.",
"Miscellaneous",
),
(master_doc, 'DjangoEAV2', 'Django EAV 2 Documentation',
author, 'DjangoEAV2', 'One line description of project.',
'Miscellaneous'),
]
# -- Extension configuration -------------------------------------------------
@ -155,7 +185,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 = {'https://docs.python.org/': None}
# -- Autodoc configuration ---------------------------------------------------

View file

@ -3,35 +3,43 @@ Getting Started
Installation
------------
You can install **django-eav2** from PyPI, git or directly from source:
From PyPI
^^^^^^^^^
::
pip install django-eav2
With pip via git
^^^^^^^^^^^^^^^^
::
pip install git+https://github.com/makimo/django-eav2@master
From source
^^^^^^^^^^^
::
git clone git@github.com:makimo/django-eav2.git
cd django-eav2
python setup.py install
To uninstall::
python setup.py install --record files.txt
rm $(cat files.txt)
Configuration
-------------
After you've installed the package, you have to add it to your Django apps
::
After you've installed the package, you have to add it to your Django apps::
INSTALLED_APPS = [
...
# ...
'eav',
]
Note: Django 2.2 Users
^^^^^^^^^^^^^^^^^^^^^^
Since ``models.JSONField()`` isn't supported in Django 2.2, we use `django-jsonfield-backport <https://github.com/laymonage/django-jsonfield-backport>`_
to provide `JSONField <https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends>`_
functionality.
This requires adding ``django_jsonfield_backport`` to your INSTALLED_APPS as well.
::
INSTALLED_APPS = [
...
'eav',
'django_jsonfield_backport',
# ...
]

View file

@ -25,8 +25,8 @@ or with decorators:
class Supplier(models.Model):
...
Generally, if you chose the former, the most appropriate place for the
statement would be at the bottom of your ``models.py`` or immediately after
Generally, if you chose the former, the most appriopriate place for the
statement would be at the bottom of your ``models.py`` or immmediately after
model definition.
Advanced Registration
@ -160,8 +160,6 @@ stored in :class:`~eav.models.Value`). Available choices are:
*bool* ``TYPE_BOOLEAN``
*object* ``TYPE_OBJECT``
*enum* ``TYPE_ENUM``
*json* ``TYPE_JSON``
*csv* ``TYPE_CSV``
========= ==================
If you want to create an attribute with data-type *enum*, you need to provide
@ -183,64 +181,6 @@ it with ``enum_group``:
)
# = <Attribute: hungry? (Multiple Choice)>
The attribute type *json* allows to store them in JSON format, which internally use JSONField:
.. code-block:: python
Attribute.objects.create(name='name_intl', datatype=Attribute.TYPE_JSON)
prod = Product.objects.create(sku='PRD00001', eav__name_intl={
"es": "Escoba Verde",
"en": "Green Broom",
"it": "Scopa Verde"
})
prod2 = Product.objects.create(sku='PRD00002', eav__name_intl={
"es": "Escoba Roja",
"en": "Red Broom"
})
prod3 = Product.objects.create(sku='PRD00003', eav__name_intl={
"es": "Escoba Azul",
"it": "Scopa Blu"
})
prod.eav.name_intl
{'es': 'Escoba Verde', 'en': 'Green Broom', 'it': 'Scopa Verde'}
type(prod.eav.name_intl)
dict
Product.objects.filter(eav__name_intl__has_key="it")
<EavQuerySet [<Product: PRD00001>, <Product: PRD00003>]>
The attribute type *csv* allows to store Comma Separated Values, using ";" as a separator:
.. code-block:: python
Attribute.objects.create(name='colors', datatype=Attribute.TYPE_CSV)
prod = Product.objects.create(sku='PRD00001', eav__colors="red;green;blue")
prod2 = Product.objects.create(sku='PRD00002', eav__colors="red;green")
prod3 = Product.objects.create(sku='PRD00003', eav__colors="red;blue")
prod4 = Product.objects.create(sku='PRD00004', eav__colors="")
prod.eav.colors
["red", "green", "blue"]
type(prod.eav.name_intl)
list
Product.objects.filter(eav__name_colors="green")
<EavQuerySet [<Product: PRD00001>, <Product: PRD00002>]>
Product.objects.filter(~Q(eav__name_colors__isnull=False))
<EavQuerySet [<Product: PRD00004>]>
Finally, attribute type *object* allows to relate Django model instances
via generic foreign keys:
@ -270,10 +210,9 @@ foreign-keys:
# Of course, you can mix them with regular queries:
Part.objects.filter(name='Cog', eav__height=7.8)
# Querying enums works either by enum instance or by it's text representation as follows:
# Querying enums looks as follows:
yes = EnumValue.objects.get(name='Yes')
Part.objects.filter(eav__is_available=yes) # via EnumValue
Part.objects.filter(eav__is_available='yes) # via EnumValue's value
Part.objects.filter(eav__is_available=yes)
You can use ``Q`` expressions too:
@ -286,10 +225,8 @@ You can use ``Q`` expressions too:
Admin Integration
-----------------
Django EAV 2 seamlessly integrates with Django's admin interface by providing
dynamic attribute management directly within the admin panel. This feature
provides the EAV Attributes as a separate fieldset, whether use the base
fieldset or when providing your own.
Django EAV 2 includes integration for Django's admin. As usual, you need to
register your model first:
.. code-block:: python
@ -304,11 +241,3 @@ fieldset or when providing your own.
form = PatientAdminForm
admin.site.register(Patient, PatientAdmin)
Customizing the EAV Fieldset
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The Django EAV 2 integration allows you to customize the presentation of EAV
attributes in the admin interface through the use of a dedicated fieldset. You
can configure this fieldset by setting ``eav_fieldset_title`` and
``eav_fieldset_description`` within your admin class.

View file

@ -1,10 +1,9 @@
def register(model_cls, config_cls=None):
from eav.registry import Registry
__version__ = '0.11.1'
def register(model_cls, config_cls=None):
from .registry import Registry
Registry.register(model_cls, config_cls)
def unregister(model_cls):
from eav.registry import Registry
from .registry import Registry
Registry.unregister(model_cls)

View file

@ -1,129 +1,47 @@
"""This module contains classes used for admin integration."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
from django.contrib import admin
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
from django.forms.models import BaseInlineFormSet
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]]
from .models import Attribute, EnumGroup, EnumValue, Value
class BaseEntityAdmin(ModelAdmin):
"""Custom admin model to support dynamic EAV fieldsets.
Overrides the default rendering of the change form in the Django admin to
dynamically integrate EAV fields into the form fieldsets. This approach
allows EAV attributes to be rendered alongside standard model fields within
the admin interface.
Attributes:
eav_fieldset_title (str): Title for the dynamically added EAV fieldset.
eav_fieldset_description (str): Optional description for the EAV fieldset.
"""
eav_fieldset_title: str = "EAV Attributes"
eav_fieldset_description: str | None = None
def render_change_form(self, request, context, *args, **kwargs):
"""Dynamically modifies the admin form to include EAV fields.
Identifies EAV fields associated with the instance being edited and
dynamically inserts them into the admin form's fieldsets. This method
ensures EAV fields are appropriately displayed in a dedicated fieldset
and avoids field duplication.
Args:
request: HttpRequest object representing the current request.
context: Dictionary containing context data for the form template.
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
Returns:
HttpResponse object representing the rendered change form.
"""
form = context["adminform"].form
Wrapper for ``ModelAdmin.render_change_form``. Replaces standard static
``AdminForm`` with an EAV-friendly one. The point is that our form
generates fields dynamically and fieldsets must be inferred from a
prepared and validated form instance, not just the form class. Django
does not seem to provide hooks for this purpose, so we simply wrap the
view and substitute some data.
"""
form = context['adminform'].form
# Identify EAV fields based on the form instance's configuration.
eav_fields = self._get_eav_fields(form.instance)
# Infer correct data from the form.
fieldsets = self.fieldsets or [(None, {'fields': form.fields.keys()})]
adminform = admin.helpers.AdminForm(form, fieldsets, self.prepopulated_fields)
media = mark_safe(self.media + adminform.media)
# # Fallback to default if no EAV fields exist
if not eav_fields:
return super().render_change_form(request, context, *args, **kwargs)
# Get the non-EAV fieldsets and then append our own
fieldsets = list(self.get_fieldsets(request, kwargs["obj"]))
fieldsets.append(self._get_eav_fieldset(eav_fields))
# Reconstruct the admin form with updated fieldsets.
adminform = admin.helpers.AdminForm(
form,
fieldsets,
# Clear prepopulated fields on a view-only form to avoid a crash.
(
self.prepopulated_fields
if self.has_change_permission(request, kwargs["obj"])
else {}
),
readonly_fields=self.readonly_fields,
model_admin=self,
)
media = mark_safe(context["media"] + adminform.media) # noqa: S308
context.update(adminform=adminform, media=media)
return super().render_change_form(request, context, *args, **kwargs)
def _get_eav_fields(self, instance) -> list[str]:
"""Retrieves a list of EAV field slugs for the given instance.
Args:
instance: The model instance for which EAV fields are determined.
Returns:
A list of strings representing the slugs of EAV fields.
"""
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
return list(entity.get_all_attributes().values_list("slug", flat=True))
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
Generates a list representing a fieldset specifically for Entity-Attribute-Value
(EAV) fields, intended to be appended to the admin form's fieldsets
configuration. This facilitates the dynamic inclusion of EAV fields within the
Django admin interface by creating a designated section for these attributes.
Args:
eav_fields (List[str]): A list of slugs representing the EAV fields to be
included in the EAV Attributes fieldset.
"""
return [
self.eav_fieldset_title,
{"fields": eav_fields, "description": self.eav_fieldset_description},
]
return super(BaseEntityAdmin, self).render_change_form(
request, context, *args, **kwargs
)
class BaseEntityInlineFormSet(BaseInlineFormSet):
"""
An inline formset that correctly initializes EAV forms.
"""
def add_fields(self, form, index):
if self.instance:
setattr(form.instance, self.fk.name, self.instance)
form._build_dynamic_fields() # noqa: SLF001
form._build_dynamic_fields()
super().add_fields(form, index)
super(BaseEntityInlineFormSet, self).add_fields(form, index)
class BaseEntityInline(InlineModelAdmin):
@ -141,7 +59,6 @@ class BaseEntityInline(InlineModelAdmin):
with EAV-Django. You can copy or symlink the ``admin`` directory to
your templates search path (see Django documentation).
"""
formset = BaseEntityInlineFormSet
def get_fieldsets(self, request, obj=None):
@ -154,12 +71,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: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)}
list_display = ('name', 'slug', 'datatype', 'description')
prepopulated_fields = {'slug': ('name',)}
admin.site.register(Attribute, AttributeAdmin)

View file

@ -3,7 +3,6 @@ This module contains pure wrapper functions used as decorators.
Functions in this module should be simple and not involve complex logic.
"""
def register_eav(**kwargs):
"""
Registers the given model(s) classes and wrapped ``Model`` class with
@ -13,13 +12,12 @@ def register_eav(**kwargs):
class Author(models.Model):
pass
"""
from . import register
from django.db.models import Model
from eav import register
def _model_eav_wrapper(model_class):
if not issubclass(model_class, Model):
raise TypeError("Wrapped class must subclass Model.")
raise ValueError('Wrapped class must subclass Model.')
register(model_class, **kwargs)
return model_class

View file

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

View file

@ -1,8 +1,41 @@
import re
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ugettext_lazy as _
from eav.forms import CSVFormField
class EavSlugField(models.SlugField):
"""
The slug field used by :class:`~eav.models.Attribute`
"""
def validate(self, value, instance):
"""
Slugs are used to convert the Python attribute name to a database
lookup and vice versa. We need it to be a valid Python identifier. We
don't want it to start with a '_', underscore will be used in
variables we don't want to be saved in the database.
"""
super(EavSlugField, self).validate(value, instance)
slug_regex = r'[a-z][a-z0-9_]*'
if not re.match(slug_regex, value):
raise ValidationError(_(
'Must be all lower case, start with a letter, and contain '
'only letters, numbers, or underscores.'
))
@staticmethod
def create_slug_from_name(name):
"""Creates a slug based on the name."""
name = name.strip().lower()
# Change spaces to underscores.
name = '_'.join(name.split())
# Remove non alphanumeric characters.
return re.sub('[^\w]', '', name)
class EavDatatypeField(models.CharField):
@ -16,68 +49,15 @@ class EavDatatypeField(models.CharField):
:class:`~eav.models.Attribute` that is already used by
:class:`~eav.models.Value` objects.
"""
super().validate(value, instance)
super(EavDatatypeField, self).validate(value, instance)
if not instance.pk:
return
# added
if not type(instance).objects.filter(pk=instance.pk).exists():
return
if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype:
return
if instance.value_set.count():
raise ValidationError(
_(
"You cannot change the datatype of an "
+ "attribute that is already in use.",
),
)
class CSVField(models.TextField): # (models.Field):
description = _("A Comma-Separated-Value field.")
default_separator = ";"
def __init__(self, separator=";", *args, **kwargs):
self.separator = separator
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
return name, path, args, kwargs
def formfield(self, **kwargs):
defaults = {"form_class": CSVFormField}
defaults.update(kwargs)
return super().formfield(**defaults)
def from_db_value(self, value, expression, connection):
if value is None:
return []
return value.split(self.separator)
def to_python(self, value):
if value is None:
return []
if isinstance(value, list):
return value
return value.split(self.separator)
def get_prep_value(self, value):
if not value:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return self.separator.join(value)
return value
def value_to_string(self, obj):
value = self.value_from_object(obj)
return self.get_prep_value(value)
raise ValidationError(_(
'You cannot change the datatype of an attribute that is already in use.'
))

View file

@ -1,49 +1,11 @@
"""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
from django.forms import (
BooleanField,
CharField,
ChoiceField,
Field,
FloatField,
IntegerField,
JSONField,
ModelForm,
SplitDateTimeField,
)
from django.utils.translation import gettext_lazy as _
from eav.widgets import CSVWidget
class CSVFormField(Field):
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)
super().__init__(*args, **kwargs)
def to_python(self, value):
if not value:
return []
return [v.strip() for v in value.split(self.separator) if v]
def validate(self, field_value):
super().validate(field_value)
if not isinstance(field_value, list):
raise ValidationError(self.message, code=self.code)
from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField,
FloatField, IntegerField, ModelForm)
from django.utils.translation import ugettext_lazy as _
class BaseDynamicEntityForm(ModelForm):
@ -64,28 +26,22 @@ class BaseDynamicEntityForm(ModelForm):
text CharField
float IntegerField
int DateTimeField
date SplitDateTimeField
bool BooleanField
enum ChoiceField
json JSONField
csv CSVField
===== =============
"""
FIELD_CLASSES: ClassVar[dict[str, Field]] = {
"text": CharField,
"float": FloatField,
"int": IntegerField,
"date": SplitDateTimeField,
"bool": BooleanField,
"enum": ChoiceField,
"json": JSONField,
"csv": CSVFormField,
FIELD_CLASSES = {
'text': CharField,
'float': FloatField,
'int': IntegerField,
'date': DateTimeField,
'bool': BooleanField,
'enum': ChoiceField,
}
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
config_cls = self.instance._eav_config_cls # noqa: SLF001
super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs)
config_cls = self.instance._eav_config_cls
self.entity = getattr(self.instance, config_cls.eav_attr)
self._build_dynamic_fields()
@ -97,56 +53,57 @@ 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] # noqa: N806
MappedField = self.FIELD_CLASSES[datatype]
self.fields[attribute.slug] = MappedField(**defaults)
# Fill initial data (if attribute was already defined).
if value and datatype != attribute.TYPE_ENUM:
if value and not 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``.
"""
if self.errors:
raise ValueError(
_(
"The %s could not be saved because the data didn't validate.",
)
% self.instance._meta.object_name, # noqa: SLF001
)
raise ValueError(_(
'The %s could not be saved because the data'
'didn\'t validate.' % self.instance._meta.object_name
))
# Create entity instance, don't save yet.
instance = super().save(commit=False)
instance = super(BaseDynamicEntityForm, self).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:
value = attribute.enum_group.values.get(pk=value) if value else None
if value:
value = attribute.enum_group.values.get(pk=value)
else:
value = None
setattr(self.entity, attribute.slug, value)

View file

@ -1,278 +0,0 @@
# Indonesian translation for django-eav2
# Copyright (C) 2023
# This file is distributed under the same license as the django-eav2 package.
# Kira <kiraware@github.com>, 2023.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: django-eav2 1.3.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-06-29 16:43+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Kira <kiraware@github.com>, 2023\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: .\eav\fields.py:30
msgid "You cannot change the datatype of an attribute that is already in use."
msgstr "Anda tidak dapat mengubah tipe data atribut yang sudah digunakan."
#: .\eav\fields.py:36
msgid "A Comma-Separated-Value field."
msgstr "Bidang Nilai-yang-Dipisahkan-Koma."
#: .\eav\forms.py:28
msgid "Enter comma-separated-values. eg: one;two;three."
msgstr "Masukkan nilai-yang-dipisahkan-koma. misalnya: satu;dua;tiga."
#: .\eav\forms.py:138
#, python-format
msgid "The %s could not be saved because the datadidn't validate."
msgstr "%s tidak dapat disimpan karena datanya tidak tervalidasi."
#: .\eav\models.py:77
msgid "EnumValue"
msgstr "EnumValue"
#: .\eav\models.py:78
msgid "EnumValues"
msgstr "EnumValues"
#: .\eav\models.py:81 .\eav\models.py:439
msgid "Value"
msgstr "Nilai"
#: .\eav\models.py:106
msgid "EnumGroup"
msgstr "EnumGroup"
#: .\eav\models.py:107
msgid "EnumGroups"
msgstr "EnumGroups"
#: .\eav\models.py:112 .\eav\models.py:218
msgid "Name"
msgstr "Nama"
#: .\eav\models.py:116
msgid "Enum group"
msgstr "Grup enum"
#: .\eav\models.py:182 .\eav\models.py:447
msgid "Attribute"
msgstr "Atribut"
#: .\eav\models.py:183
msgid "Attributes"
msgstr "Atribut"
#: .\eav\models.py:196
msgid "Text"
msgstr "Teks"
#: .\eav\models.py:197
msgid "Date"
msgstr "Tanggal"
#: .\eav\models.py:198
msgid "Float"
msgstr "Bilangan desimal"
#: .\eav\models.py:199
msgid "Integer"
msgstr "Bilangan bulat"
#: .\eav\models.py:200
msgid "True / False"
msgstr "Benar / Salah"
#: .\eav\models.py:201
msgid "Django Object"
msgstr "Objek Django"
#: .\eav\models.py:202
msgid "Multiple Choice"
msgstr "Pilihan Ganda"
#: .\eav\models.py:203
msgid "JSON Object"
msgstr "Objek JSON"
#: .\eav\models.py:204
msgid "Comma-Separated-Value"
msgstr "Nilai-yang-Dipisahkan-Koma"
#: .\eav\models.py:212
msgid "Data Type"
msgstr "Tipe Data"
#: .\eav\models.py:217
msgid "User-friendly attribute name"
msgstr "Nama atribut yang ramah pengguna"
#: .\eav\models.py:230
msgid "Short unique attribute label"
msgstr "Label atribut unik yang pendek"
#: .\eav\models.py:231
msgid "Slug"
msgstr "Slug"
#: .\eav\models.py:242
msgid "Required"
msgstr "Diperlukan"
#: .\eav\models.py:248
msgid "Entity content type"
msgstr "Jenis konten entitas"
#: .\eav\models.py:262
msgid "Choice Group"
msgstr "Grup Pilihan"
#: .\eav\models.py:269
msgid "Short description"
msgstr "Deskripsi singkat"
#: .\eav\models.py:270
msgid "Description"
msgstr "Deskripsi"
#: .\eav\models.py:277
msgid "Display order"
msgstr "Urutan tampilan"
#: .\eav\models.py:282 .\eav\models.py:490
msgid "Modified"
msgstr "Dimodifikasi"
#: .\eav\models.py:288 .\eav\models.py:485
msgid "Created"
msgstr "Dibuat"
#: .\eav\models.py:332
#, python-format
msgid "%(val)s is not a valid choice for %(attr)s"
msgstr "%(val)s bukan pilihan yang valid untuk %(attr)s"
#: .\eav\models.py:355
msgid "You must set the choice group for multiple choice attributes"
msgstr "Anda harus mengatur grup pilihan untuk atribut pilihan ganda"
#: .\eav\models.py:360
msgid "You can only assign a choice group to multiple choice attributes"
msgstr "Anda hanya dapat menetapkan grup pilihan ke atribut pilihan ganda"
#: .\eav\models.py:440
msgid "Values"
msgstr "Nilai"
#: .\eav\models.py:456
msgid "Entity id"
msgstr "id entitas"
#: .\eav\models.py:462
msgid "Entity uuid"
msgstr "uuid entitas"
#: .\eav\models.py:469
msgid "Entity ct"
msgstr "Entitas ct"
#: .\eav\models.py:497
msgid "Value bool"
msgstr "Nilai bool"
#: .\eav\models.py:502
msgid "Value CSV"
msgstr "Nilai CSV"
#: .\eav\models.py:507
msgid "Value date"
msgstr "Nilai tanggal"
#: .\eav\models.py:512
msgid "Value float"
msgstr "Nilai float"
#: .\eav\models.py:517
msgid "Value int"
msgstr "Nilai int"
#: .\eav\models.py:522
msgid "Value text"
msgstr "Nilai teks"
#: .\eav\models.py:530
msgid "Value JSON"
msgstr "Nilai JSON"
#: .\eav\models.py:539
msgid "Value enum"
msgstr "Nilai enum"
#: .\eav\models.py:546
msgid "Generic value id"
msgstr "Id nilai generik"
#: .\eav\models.py:555
msgid "Generic value content type"
msgstr "Jenis konten nilai generik"
#: .\eav\models.py:653
#, python-format
msgid "%(obj)s has no EAV attribute named %(attr)s"
msgstr "%(obj)s tidak memiliki atribut EAV bernama %(attr)s"
#: .\eav\models.py:725
msgid "{} EAV field cannot be blank"
msgstr "{} Bidang EAV tidak boleh kosong"
#: .\eav\models.py:732
#, python-format
msgid "%(attr)s EAV field %(err)s"
msgstr "%(attr)s bidang EAV %(err)s"
#: .\eav\validators.py:26
msgid "Must be str or unicode"
msgstr "Harus berupa str atau unicode"
#: .\eav\validators.py:36
msgid "Must be a float"
msgstr "Harus berupa float"
#: .\eav\validators.py:46
msgid "Must be an integer"
msgstr "Harus berupa integer"
#: .\eav\validators.py:57
msgid "Must be a date or datetime"
msgstr "Harus berupa date atau datetime"
#: .\eav\validators.py:65
msgid "Must be a boolean"
msgstr "Harus berupa boolean"
#: .\eav\validators.py:74
msgid "Must be a django model object instance"
msgstr "Harus berupa instance objek model django"
#: .\eav\validators.py:77
msgid "Model has not been saved yet"
msgstr "Model belum disimpan"
#: .\eav\validators.py:88
msgid "EnumValue has not been saved yet"
msgstr "EnumValue belum disimpan"
#: .\eav\validators.py:99 .\eav\validators.py:101
msgid "Must be a JSON Serializable object"
msgstr "Harus berupa objek JSON yang dapat diserialisasikan"
#: .\eav\validators.py:111
msgid "Must be Comma-Separated-Value."
msgstr "Harus berupa Nilai-ang-Dipisahkan-Koma."

Binary file not shown.

View file

@ -1,194 +0,0 @@
# Russian translation for django-eav2
# Copyright (C) 2019
# This file is distributed under the same license as the django-eav2 package.
# Evgeny Pisemsky <evgeny@pisemsky.com>, 2019.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-02-15 01:10+0300\n"
"PO-Revision-Date: 2019-02-15 02:13+0300\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"Last-Translator: Evgeny Pisemsky <evgeny@pisemsky.com>\n"
"Language-Team: \n"
"X-Generator: Poedit 1.8.11\n"
#: fields.py:25
msgid ""
"Must be all lower case, start with a letter, and contain only letters, "
"numbers, or underscores."
msgstr ""
"Должно быть в нижнем регистре, начинаться с буквы и содержать только буквы, "
"числа или подчёркивания."
#: fields.py:62
msgid "You cannot change the datatype of an attribute that is already in use."
msgstr "Вы не можете изменить тип данных атрибута, который уже используется."
#: forms.py:91
#, python-format
msgid "The %s could not be saved because the datadidn't validate."
msgstr "%s не может быть сохранено, потому что данные не корректны."
#: models.py:54
msgid "Value"
msgstr "Значение"
#: models.py:68 models.py:153
msgid "Name"
msgstr "Название"
#: models.py:69
msgid "Enum group"
msgstr "Группа выбора"
#: models.py:135
msgid "Text"
msgstr "Текст"
#: models.py:136
msgid "Date"
msgstr "Дата"
#: models.py:137
msgid "Float"
msgstr "Число с плавающей запятой"
#: models.py:138
msgid "Integer"
msgstr "Целое число"
#: models.py:139
msgid "True / False"
msgstr "Правда / Ложь"
#: models.py:140
msgid "Django Object"
msgstr "Объект Django"
#: models.py:141
msgid "Multiple Choice"
msgstr "Множественный выбор"
#: models.py:147
msgid "Data Type"
msgstr "Тип данных"
#: models.py:155
msgid "User-friendly attribute name"
msgstr "Понятное пользователю название атрибута"
#: models.py:164
msgid "Slug"
msgstr "Псевдоним"
#: models.py:168
msgid "Short unique attribute label"
msgstr "Короткая уникальная метка атрибута"
#: models.py:177
msgid "Required"
msgstr "Обязательно"
#: models.py:181
msgid "Choice Group"
msgstr "Группа выбора"
#: models.py:188
msgid "Description"
msgstr "Описание"
#: models.py:192
msgid "Short description"
msgstr "Краткое описание"
#: models.py:198
msgid "Display order"
msgstr "Порядок отображения"
#: models.py:203 models.py:392
msgid "Modified"
msgstr "Изменено"
#: models.py:208 models.py:391
msgid "Created"
msgstr "Создано"
#: models.py:250
#, python-format
msgid "%(val)s is not a valid choice for %(attr)s"
msgstr "%(val)s не является корректным выбором для %(attr)s"
#: models.py:273
msgid "You must set the choice group for multiple choice attributes"
msgstr "Вы должны назначить группу выбора для атрибутов множественного выбора"
#: models.py:278
msgid "You can only assign a choice group to multiple choice attributes"
msgstr ""
"Вы можете назначить группу выбора только для атрибутов множественного выбора"
#: models.py:398
msgid "Attribute"
msgstr "Атрибут"
#: models.py:416
#, python-format
msgid "%(enum)s is not a valid choice for %(attr)s"
msgstr "%(enum)s не является корректным выбором для %(attr)s"
#: models.py:492
#, python-format
msgid "%(obj)s has no EAV attribute named %(attr)s"
msgstr "%(obj)s не имеет атрибута EAV с названием %(attr)s"
#: models.py:557
msgid "{} EAV field cannot be blank"
msgstr "Поле EAV {} не может быть пустым"
#: models.py:564
#, python-format
msgid "%(attr)s EAV field %(err)s"
msgstr "Поле EAV %(attr)s %(err)s"
#: validators.py:25
msgid "Must be str or unicode"
msgstr "Должно быть строкой или юникодом"
#: validators.py:35
msgid "Must be a float"
msgstr "Должно быть числом с плавающей запятой"
#: validators.py:45
msgid "Must be an integer"
msgstr "Должно быть целым числом"
#: validators.py:54
msgid "Must be a date or datetime"
msgstr "Должно быть датой или датой со временем"
#: validators.py:62
msgid "Must be a boolean"
msgstr "Должно быть булевым значением"
#: validators.py:71
msgid "Must be a django model object instance"
msgstr "Должно быть экземпляром объекта модели Django"
#: validators.py:74
msgid "Model has not been saved yet"
msgstr "Модель ещё не была сохранена"
#: validators.py:85
msgid "Must be an EnumValue model object instance"
msgstr "Должно быть экземпляром объекта модели EnumValue"
#: validators.py:88
msgid "EnumValue has not been saved yet"
msgstr "EnumValue ещё не было сохранено"

View file

@ -1,279 +0,0 @@
# Simplified Chinese translation for django-eav2
# Copyright (C) 2023
# This file is distributed under the same license as the django-eav2 package.
# FIRST 954-Ivory <954ivory@gmail.com>, 2023.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-07 02:17+0800\n"
"PO-Revision-Date: 2023-02-27 16:36+0800\n"
"Last-Translator: 954-Ivory <954ivory@gmail.com>\n"
"Language-Team: \n"
"Language: zh-Hans\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: .\fields.py:30
msgid "You cannot change the datatype of an attribute that is already in use."
msgstr "您不能更改已使用属性的数据类型。"
#: .\fields.py:36
msgid "A Comma-Separated-Value field."
msgstr "字符分隔值(CSV)字段。"
#: .\forms.py:28
msgid "Enter comma-separated-values. eg: one;two;three."
msgstr "输入字符分隔值(CSV)字段例如one;two;three。"
#: .\forms.py:137
#, python-format
msgid "The %s could not be saved because the datadidn't validate."
msgstr "由于数据未验证,无法保存 %s 。"
#: .\models.py:81
msgid "EnumValue"
msgstr "枚举值"
#: .\models.py:82
msgid "EnumValues"
msgstr "枚举值"
#: .\models.py:85 .\models.py:443
msgid "Value"
msgstr "值"
#: .\models.py:110
msgid "EnumGroup"
msgstr "枚举组"
#: .\models.py:111
msgid "EnumGroups"
msgstr "枚举组"
#: .\models.py:116 .\models.py:222
msgid "Name"
msgstr "名称"
#: .\models.py:120
msgid "Enum group"
msgstr "枚举组"
#: .\models.py:186 .\models.py:451
msgid "Attribute"
msgstr "属性"
#: .\models.py:187
msgid "Attributes"
msgstr "属性"
#: .\models.py:200
msgid "Text"
msgstr "文本"
#: .\models.py:201
msgid "Date"
msgstr "日期"
#: .\models.py:202
msgid "Float"
msgstr "浮点数"
#: .\models.py:203
msgid "Integer"
msgstr "整数"
#: .\models.py:204
msgid "True / False"
msgstr "布尔值"
#: .\models.py:205
msgid "Django Object"
msgstr "Django 对象"
#: .\models.py:206
msgid "Multiple Choice"
msgstr "多项选择"
#: .\models.py:207
msgid "JSON Object"
msgstr "JSON 对象"
#: .\models.py:208
msgid "Comma-Separated-Value"
msgstr "字符分隔值(CSV)"
#: .\models.py:216
msgid "Data Type"
msgstr "数据类型"
#: .\models.py:221
msgid "User-friendly attribute name"
msgstr "面向用户的名称"
#: .\models.py:234
msgid "Short unique attribute label"
msgstr "唯一的属性短标识符"
#: .\models.py:235
msgid "Slug"
msgstr "短标识符(Slug)"
#: .\models.py:246
msgid "Required"
msgstr "必填项"
#: .\models.py:252
msgid "Entity content type"
msgstr "实体内容类型"
#: .\models.py:266
msgid "Choice Group"
msgstr "选项组"
#: .\models.py:273
msgid "Short description"
msgstr "简短描述"
#: .\models.py:274
msgid "Description"
msgstr "描述"
#: .\models.py:281
msgid "Display order"
msgstr "显示顺序"
#: .\models.py:286 .\models.py:494
msgid "Modified"
msgstr "修改"
#: .\models.py:292 .\models.py:489
msgid "Created"
msgstr "创建"
#: .\models.py:336
#, python-format
msgid "%(val)s is not a valid choice for %(attr)s"
msgstr "%(val)s 不是有效的 %(attr)s 选项"
#: .\models.py:359
msgid "You must set the choice group for multiple choice attributes"
msgstr "您必须为多项选择属性设置选项组"
#: .\models.py:364
msgid "You can only assign a choice group to multiple choice attributes"
msgstr "您只能将选项组分配给多项选择属性"
#: .\models.py:444
msgid "Values"
msgstr "值"
#: .\models.py:460
msgid "Entity id"
msgstr "实体 ID"
#: .\models.py:466
msgid "Entity uuid"
msgstr "实体 UUID"
#: .\models.py:473
msgid "Entity ct"
msgstr "实体内容类型"
#: .\models.py:501
msgid "Value bool"
msgstr "布尔值"
#: .\models.py:506
msgid "Value CSV"
msgstr "字符分隔值(CSV)"
#: .\models.py:511
msgid "Value date"
msgstr "日期值"
#: .\models.py:516
msgid "Value float"
msgstr "浮点值"
#: .\models.py:521
msgid "Value int"
msgstr "整型值"
#: .\models.py:526
msgid "Value text"
msgstr "文本值"
#: .\models.py:534
msgid "Value JSON"
msgstr "JSON 值"
#: .\models.py:543
msgid "Value enum"
msgstr "枚举值"
#: .\models.py:550
msgid "Generic value id"
msgstr "通用值 ID"
#: .\models.py:559
msgid "Generic value content type"
msgstr "通用值内容类型"
#: .\models.py:657
#, python-format
msgid "%(obj)s has no EAV attribute named %(attr)s"
msgstr "%(obj)s 中不存在为 %(attr)s 的属性"
#: .\models.py:729
msgid "{} EAV field cannot be blank"
msgstr "{} 字段不能为空白(blank)"
#: .\models.py:736
#, python-format
msgid "%(attr)s EAV field %(err)s"
msgstr "%(attr)s 字段错误:%(err)s"
#: .\validators.py:26
msgid "Must be str or unicode"
msgstr "必须是一个 str 或 unicode"
#: .\validators.py:36
msgid "Must be a float"
msgstr "必须是一个浮点数"
#: .\validators.py:46
msgid "Must be an integer"
msgstr "必须是一个整数"
#: .\validators.py:57
msgid "Must be a date or datetime"
msgstr "必须是一个日期(date)或者日期时间(datetime)"
#: .\validators.py:65
msgid "Must be a boolean"
msgstr "必须是一个布尔值"
#: .\validators.py:74
msgid "Must be a django model object instance"
msgstr "必须是一个 Django Model 对象的实例"
#: .\validators.py:77
msgid "Model has not been saved yet"
msgstr "Model 尚未保存"
#: .\validators.py:88
msgid "EnumValue has not been saved yet"
msgstr "枚举值尚未保存"
#: .\validators.py:99 .\validators.py:101
msgid "Must be a JSON Serializable object"
msgstr "必须是一个 JSON 序列化对象"
#: .\validators.py:111
msgid "Must be Comma-Separated-Value."
msgstr "必须是一个字符分隔值(CSV)"

View file

@ -1,12 +0,0 @@
from django.db.models.fields import UUIDField
def get_entity_pk_type(entity_cls) -> str:
"""Returns the entity PK type to use.
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): # noqa: SLF001
return "entity_uuid"
return "entity_id"

View file

@ -1,97 +0,0 @@
from django.db import models
class EnumValueManager(models.Manager):
"""
Custom manager for `EnumValue` model.
This manager adds utility methods specific to the `EnumValue` model.
"""
def get_by_natural_key(self, value):
"""
Retrieves an EnumValue instance using its `value` as a natural key.
Args:
value (str): The value of the EnumValue instance.
Returns:
EnumValue: The instance matching the provided value.
"""
return self.get(value=value)
class EnumGroupManager(models.Manager):
"""
Custom manager for `EnumGroup` model.
This manager adds utility methods specific to the `EnumGroup` model.
"""
def get_by_natural_key(self, name):
"""
Retrieves an EnumGroup instance using its `name` as a natural key.
Args:
name (str): The name of the EnumGroup instance.
Returns:
EnumGroup: The instance matching the provided name.
"""
return self.get(name=name)
class AttributeManager(models.Manager):
"""
Custom manager for `Attribute` model.
This manager adds utility methods specific to the `Attribute` model.
"""
def get_by_natural_key(self, name, slug):
"""
Retrieves an Attribute instance using its `name` and `slug` as natural keys.
Args:
name (str): The name of the Attribute instance.
slug (str): The slug of the Attribute instance.
Returns:
Attribute: The instance matching the provided name and slug.
"""
return self.get(name=name, slug=slug)
class ValueManager(models.Manager):
"""
Custom manager for `Value` model.
This manager adds utility methods specific to the `Value` model.
"""
def get_by_natural_key(self, attribute, entity_id, entity_uuid):
"""
Retrieve a Value instance using multiple natural keys.
This method utilizes a combination of an `attribute` (defined by its
name and slug), `entity_id`, and `entity_uuid` to retrieve a unique
Value instance.
Args:
attribute (tuple): A tuple containing the name and slug of the
Attribute instance.
entity_id (int): The ID of the associated entity.
entity_uuid (str): The UUID of the associated entity.
Returns:
Value: The instance matching the provided keys.
"""
from eav.models import Attribute
attribute = Attribute.objects.get(name=attribute[0], slug=attribute[1])
return self.get(
attribute=attribute,
entity_id=entity_id,
entity_uuid=entity_uuid,
)

View file

@ -1,44 +0,0 @@
import uuid
from functools import partial
from django.conf import settings
from django.db import models
#: Constants
_DEFAULT_CHARFIELD_LEN: int = 40
_FIELD_MAPPING = {
"django.db.models.UUIDField": partial(
models.UUIDField,
primary_key=True,
editable=False,
default=uuid.uuid4,
),
"django.db.models.CharField": partial(
models.CharField,
primary_key=True,
editable=False,
max_length=_DEFAULT_CHARFIELD_LEN,
),
}
def get_pk_format() -> models.Field:
"""
Get the primary key field format based on the Django settings.
This function returns a field factory function that corresponds to the
primary key format specified in Django settings. If the primary key
format is not recognized, it defaults to using BigAutoField.
Returns:
Type[models.Field]: A field factory function that can be used to
create the primary key field instance.
"""
field_factory = _FIELD_MAPPING.get(
settings.EAV2_PRIMARY_KEY_FIELD,
partial(models.BigAutoField, primary_key=True, editable=False),
)
# Create and return the field instance
return field_factory()

View file

@ -1,61 +0,0 @@
from __future__ import annotations
import secrets
import string
from typing import Final
from django.utils.text import slugify
SLUGFIELD_MAX_LENGTH: Final = 50
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:
chars = string.ascii_lowercase + string.digits
randstr = "".join(secrets.choice(chars) for _ in range(8))
slug = f"rand_{randstr}"
# 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

@ -4,14 +4,13 @@ This module contains the custom manager used by entities registered with eav.
from django.db import models
from eav.queryset import EavQuerySet
from .queryset import EavQuerySet
class EntityManager(models.Manager):
"""
Our custom manager, overrides ``models.Manager``.
"""
_queryset_class = EavQuerySet
def create(self, **kwargs):
@ -19,18 +18,18 @@ 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().create(**kwargs)
return super(EntityManager, self).create(**kwargs)
prefix = f"{config_cls.eav_attr}__"
prefix = '%s__' % config_cls.eav_attr
new_kwargs = {}
eav_kwargs = {}
for key, value in kwargs.items():
if key.startswith(prefix):
eav_kwargs.update({key[len(prefix) :]: value})
eav_kwargs.update({key[len(prefix):]: value})
else:
new_kwargs.update({key: value})
@ -43,13 +42,11 @@ class EntityManager(models.Manager):
obj.save()
return obj
def get_or_create(self, defaults=None, **kwargs):
def get_or_create(self, **kwargs):
"""
Reproduces the behavior of get_or_create, eav friendly.
"""
try:
return self.get(**kwargs), False
except self.model.DoesNotExist:
if defaults:
kwargs = {**kwargs, **defaults}
return self.create(**kwargs), True

View file

@ -1,227 +1,78 @@
# Generated by Django 2.0.4 on 2018-06-01 09:36
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
import eav.fields
class Migration(migrations.Migration):
"""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",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
help_text="User-friendly attribute name",
max_length=100,
verbose_name="Name",
),
),
(
"slug",
models.SlugField(
help_text="Short unique attribute label",
unique=True,
verbose_name="Slug",
),
),
(
"description",
models.CharField(
blank=True,
help_text="Short description",
max_length=256,
null=True,
verbose_name="Description",
),
),
(
"datatype",
eav.fields.EavDatatypeField(
choices=[
("text", "Text"),
("date", "Date"),
("float", "Float"),
("int", "Integer"),
("bool", "True / False"),
("object", "Django Object"),
("enum", "Multiple Choice"),
],
max_length=6,
verbose_name="Data Type",
),
),
(
"created",
models.DateTimeField(
default=django.utils.timezone.now,
editable=False,
verbose_name="Created",
),
),
(
"modified",
models.DateTimeField(auto_now=True, verbose_name="Modified"),
),
(
"required",
models.BooleanField(default=False, verbose_name="Required"),
),
(
"display_order",
models.PositiveIntegerField(
default=1,
verbose_name="Display order",
),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='User-friendly attribute name', max_length=100, verbose_name='name')),
('slug', eav.fields.EavSlugField(help_text='Short unique attribute label', unique=True, verbose_name='slug')),
('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='description')),
('datatype', eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('float', 'Float'), ('int', 'Integer'), ('date', 'Date'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice')], max_length=6, verbose_name='data type')),
('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('required', models.BooleanField(default=False, verbose_name='required')),
('display_order', models.PositiveIntegerField(default=1, verbose_name='display order')),
],
options={
"ordering": ["name"],
'ordering': ['name'],
},
),
migrations.CreateModel(
name="EnumGroup",
name='EnumGroup',
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=100, unique=True, verbose_name="Name"),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='name')),
],
),
migrations.CreateModel(
name="EnumValue",
name='EnumValue',
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"value",
models.CharField(
db_index=True,
max_length=50,
unique=True,
verbose_name="Value",
),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='value')),
],
),
migrations.CreateModel(
name="Value",
name='Value',
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
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)),
(
"created",
models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="Created",
),
),
(
"modified",
models.DateTimeField(auto_now=True, verbose_name="Modified"),
),
(
"attribute",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="eav.Attribute",
verbose_name="Attribute",
),
),
(
"entity_ct",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="value_entities",
to="contenttypes.ContentType",
),
),
(
"generic_value_ct",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="value_values",
to="contenttypes.ContentType",
),
),
(
"value_enum",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="eav_values",
to="eav.EnumValue",
),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, 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)),
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='eav.Attribute', verbose_name='attribute')),
('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='value_entities', to='contenttypes.ContentType')),
('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='value_values', to='contenttypes.ContentType')),
('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, 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",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="eav.EnumGroup",
verbose_name="Choice 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'),
),
]

View file

@ -1,18 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Add entity_ct field to Attribute model."""
dependencies = [
("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"),
),
]

View file

@ -1,44 +0,0 @@
# Generated by Django 3.1.6 on 2021-04-04 22:09
import django.core.serializers.json
from django.db import migrations
from django.db.models import JSONField
import eav.fields
class Migration(migrations.Migration):
dependencies = [
("eav", "0002_add_entity_ct_field"),
]
operations = [
migrations.AddField(
model_name="value",
name="value_json",
field=JSONField(
blank=True,
default=dict,
encoder=django.core.serializers.json.DjangoJSONEncoder,
null=True,
),
),
migrations.AlterField(
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"),
],
max_length=6,
verbose_name="Data Type",
),
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.2 on 2021-04-23 19:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("eav", "0003_auto_20210404_2209"),
]
operations = [
migrations.AlterField(
model_name="value",
name="value_bool",
field=models.BooleanField(blank=True, null=True),
),
]

View file

@ -1,38 +0,0 @@
# Generated by Django 3.2 on 2021-05-10 13:05
from django.db import migrations
import eav.fields
class Migration(migrations.Migration):
dependencies = [
("eav", "0004_alter_value_value_bool"),
]
operations = [
migrations.AddField(
model_name="value",
name="value_csv",
field=eav.fields.CSVField(blank=True, default="", null=True),
),
migrations.AlterField(
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"),
],
max_length=6,
verbose_name="Data Type",
),
),
]

View file

@ -1,22 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Creates UUID field to map to Entity FK."""
dependencies = [
("eav", "0005_auto_20210510_1305"),
]
operations = [
migrations.AddField(
model_name="value",
name="entity_uuid",
field=models.UUIDField(blank=True, null=True),
),
migrations.AlterField(
model_name="value",
name="entity_id",
field=models.IntegerField(blank=True, null=True),
),
]

View file

@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Convert Value.value_int to BigInteger."""
dependencies = [
("eav", "0006_add_entity_uuid"),
]
operations = [
migrations.AlterField(
model_name="value",
name="value_int",
field=models.BigIntegerField(blank=True, null=True),
),
]

View file

@ -1,21 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Use Django SlugField() for Attribute.slug."""
dependencies = [
("eav", "0007_alter_value_value_int"),
]
operations = [
migrations.AlterField(
model_name="attribute",
name="slug",
field=models.SlugField(
help_text="Short unique attribute label",
unique=True,
verbose_name="Slug",
),
),
]

View file

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

View file

@ -1,48 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Migration to use BigAutoField as default for all models."""
dependencies = [
("eav", "0009_enchance_naming"),
]
operations = [
migrations.AlterField(
model_name="attribute",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
serialize=False,
),
),
migrations.AlterField(
model_name="enumgroup",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
serialize=False,
),
),
migrations.AlterField(
model_name="enumvalue",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
serialize=False,
),
),
migrations.AlterField(
model_name="value",
name="id",
field=models.BigAutoField(
editable=False,
primary_key=True,
serialize=False,
),
),
]

View file

@ -1,36 +0,0 @@
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

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

627
eav/models.py Normal file
View file

@ -0,0 +1,627 @@
"""
This module defines the four concrete, non-abstract models:
* :class:`Value`
* :class:`Attribute`
* :class:`EnumValue`
* :class:`EnumGroup`
Along with the :class:`Entity` helper class and :class:`EAVModelMeta`
optional metaclass for each eav model class.
"""
from copy import copy
from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.base import ModelBase
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from .exceptions import IllegalAssignmentException
from .fields import EavDatatypeField, EavSlugField
from .validators import *
from . import register
class EnumValue(models.Model):
"""
*EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM*
:class:`Attribute` objects. They have only one field, *value*, a
``CharField`` that must be unique.
For example::
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
no = EnumValue.objects.create(value='No')
unknown = EnumValue.objects.create(value='Unknown')
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu.values.add(yes, no, unknown)
Attribute.objects.create(name='has fever?',
datatype=Attribute.TYPE_ENUM, enum_group=ynu)
# = <Attribute: has fever? (Multiple Choice)>
.. note::
The same *EnumValue* objects should be reused within multiple
*EnumGroups*. For example, if you have one *EnumGroup* called: *Yes /
No / Unknown* and another called *Yes / No / Not applicable*, you should
only have a total of four *EnumValues* objects, as you should have used
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
"""
value = models.CharField(_('value'), db_index=True, unique=True, max_length=50)
def __str__(self):
return '<EnumValue {}>'.format(self.value)
class EnumGroup(models.Model):
"""
*EnumGroup* objects have two fields - a *name* ``CharField`` and *values*,
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
See :class:`EnumValue` for an example.
"""
name = models.CharField(_('name'), unique = True, max_length = 100)
values = models.ManyToManyField(EnumValue, verbose_name = _('enum group'))
def __str__(self):
return '<EnumGroup {}>'.format(self.name)
class Attribute(models.Model):
"""
Putting the **A** in *EAV*. This holds the attributes, or concepts.
Examples of possible *Attributes*: color, height, weight, number of
children, number of patients, has fever?, etc...
Each attribute has a name, and a description, along with a slug that must
be unique. If you don't provide a slug, a default slug (derived from
name), will be created.
The *required* field is a boolean that indicates whether this EAV attribute
is required for entities to which it applies. It defaults to *False*.
.. warning::
Just like a normal model field that is required, you will not be able
to save or create any entity object for which this attribute applies,
without first setting this EAV attribute.
There are 7 possible values for datatype:
* int (TYPE_INT)
* float (TYPE_FLOAT)
* text (TYPE_TEXT)
* date (TYPE_DATE)
* bool (TYPE_BOOLEAN)
* object (TYPE_OBJECT)
* enum (TYPE_ENUM)
Examples::
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
# = <Attribute: Height (Integer)>
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
# = <Attribute: Color (Text)>
yes = EnumValue.objects.create(value='yes')
no = EnumValue.objects.create(value='no')
unknown = EnumValue.objects.create(value='unknown')
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu.values.add(yes, no, unknown)
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
# = <Attribute: has fever? (Multiple Choice)>
.. warning:: Once an Attribute has been used by an entity, you can not
change it's datatype.
"""
class Meta:
ordering = ['name']
TYPE_TEXT = 'text'
TYPE_FLOAT = 'float'
TYPE_INT = 'int'
TYPE_DATE = 'date'
TYPE_BOOLEAN = 'bool'
TYPE_OBJECT = 'object'
TYPE_ENUM = 'enum'
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')),
)
# Core attributes
datatype = EavDatatypeField(
verbose_name = _('Data Type'),
choices = DATATYPE_CHOICES,
max_length = 6
)
name = models.CharField(
verbose_name = _('Name'),
max_length = 100,
help_text = _('User-friendly attribute name')
)
"""
Main identifer for the attribute.
Upon creation, slug is autogenerated from the name.
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
"""
slug = EavSlugField(
verbose_name = _('Slug'),
max_length = 50,
db_index = True,
unique = True,
help_text = _('Short unique attribute label')
)
"""
.. warning::
This attribute should be used with caution. Setting this to *True*
means that *all* entities that *can* have this attribute will
be required to have a value for it.
"""
required = models.BooleanField(verbose_name = _('Required'), default = False)
enum_group = models.ForeignKey(
EnumGroup,
verbose_name = _('Choice Group'),
on_delete = models.PROTECT,
blank = True,
null = True
)
description = models.CharField(
verbose_name = _('Description'),
max_length = 256,
blank = True,
null = True,
help_text = _('Short description')
)
# Useful meta-information
display_order = models.PositiveIntegerField(
verbose_name = _('Display order'),
default = 1
)
modified = models.DateTimeField(
verbose_name = _('Modified'),
auto_now = True
)
created = models.DateTimeField(
verbose_name = _('Created'),
default = timezone.now,
editable = False
)
@property
def help_text(self):
return self.description
def get_validators(self):
"""
Returns the appropriate validator function from :mod:`~eav.validators`
as a list (of length one) for the datatype.
.. note::
The reason it returns it as a list, is eventually we may want this
method to look elsewhere for additional attribute specific
validators to return as well as the default, built-in one.
"""
DATATYPE_VALIDATORS = {
'text': validate_text,
'float': validate_float,
'int': validate_int,
'date': validate_date,
'bool': validate_bool,
'object': validate_object,
'enum': validate_enum,
}
return [DATATYPE_VALIDATORS[self.datatype]]
def validate_value(self, value):
"""
Check *value* against the validators returned by
:meth:`get_validators` for this attribute.
"""
for validator in self.get_validators():
validator(value)
if self.datatype == self.TYPE_ENUM:
if value not in self.enum_group.values.all():
raise ValidationError(
_('%(val)s is not a valid choice for %(attr)s')
% dict(val = value, attr = self)
)
def save(self, *args, **kwargs):
"""
Saves the Attribute and auto-generates a slug field
if one wasn't provided.
"""
if not self.slug:
self.slug = EavSlugField.create_slug_from_name(self.name)
self.full_clean()
super(Attribute, self).save(*args, **kwargs)
def clean(self):
"""
Validates the attribute. Will raise ``ValidationError`` if the
attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if
the attribute is not *TYPE_ENUM* and the enum group is set.
"""
if self.datatype == self.TYPE_ENUM and not self.enum_group:
raise ValidationError(
_('You must set the choice group for multiple choice attributes')
)
if self.datatype != self.TYPE_ENUM and self.enum_group:
raise ValidationError(
_('You can only assign a choice group to multiple choice attributes')
)
def get_choices(self):
"""
Returns a query set of :class:`EnumValue` objects for this attribute.
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
"""
return self.enum_group.values.all() if self.datatype == Attribute.TYPE_ENUM else None
def save_value(self, entity, value):
"""
Called with *entity*, any Django object registered with eav, and
*value*, the :class:`Value` this attribute for *entity* should
be set to.
If a :class:`Value` object for this *entity* and attribute doesn't
exist, one will be created.
.. note::
If *value* is None and a :class:`Value` object exists for this
Attribute and *entity*, it will delete that :class:`Value` object.
"""
ct = ContentType.objects.get_for_model(entity)
try:
value_obj = self.value_set.get(
entity_ct = ct,
entity_id = entity.pk,
attribute = self
)
except Value.DoesNotExist:
if value == None or value == '':
return
value_obj = Value.objects.create(
entity_ct = ct,
entity_id = entity.pk,
attribute = self
)
if value == None or value == '':
value_obj.delete()
return
if value != value_obj.value:
value_obj.value = value
value_obj.save()
def __str__(self):
return '{} ({})'.format(self.name, self.get_datatype_display())
class Value(models.Model):
"""
Putting the **V** in *EAV*. This model stores the value for one particular
:class:`Attribute` for some entity.
As with most EAV implementations, most of the columns of this model will
be blank, as onle one *value_* field will be used.
Example::
import eav
from django.contrib.auth.models import User
eav.register(User)
u = User.objects.create(username='crazy_dev_user')
a = Attribute.objects.create(name='Fav Drink', datatype='text')
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
# = <Value: crazy_dev_user - Fav Drink: "red bull">
"""
entity_ct = models.ForeignKey(
ContentType,
on_delete = models.PROTECT,
related_name = 'value_entities'
)
entity_id = models.IntegerField()
entity = generic.GenericForeignKey(ct_field = 'entity_ct', fk_field = 'entity_id')
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(blank = True, null = True)
value_enum = models.ForeignKey(
EnumValue,
blank = True,
null = True,
on_delete = models.PROTECT,
related_name = 'eav_values'
)
generic_value_id = models.IntegerField(blank=True, null=True)
generic_value_ct = models.ForeignKey(
ContentType,
blank = True,
null = True,
on_delete = models.PROTECT,
related_name ='value_values'
)
value_object = generic.GenericForeignKey(
ct_field = 'generic_value_ct',
fk_field = 'generic_value_id'
)
created = models.DateTimeField(_('Created'), default = timezone.now)
modified = models.DateTimeField(_('Modified'), auto_now = True)
attribute = models.ForeignKey(
Attribute,
db_index = True,
on_delete = models.PROTECT,
verbose_name = _('attribute')
)
def save(self, *args, **kwargs):
"""
Validate and save this value.
"""
self.full_clean()
super(Value, self).save(*args, **kwargs)
def clean(self):
"""
Raises ``ValidationError`` if this value's attribute is *TYPE_ENUM*
and value_enum is not a valid choice for this value's attribute.
"""
if self.attribute.datatype == Attribute.TYPE_ENUM and self.value_enum:
if self.value_enum not in self.attribute.enum_group.values.all():
raise ValidationError(
_('%(enum)s is not a valid choice for %(attr)s')
% dict(enum = self.value_enum, attr = self.attribute)
)
def _get_value(self):
"""
Return the python object this value is holding
"""
return getattr(self, 'value_%s' % self.attribute.datatype)
def _set_value(self, new_value):
"""
Set the object this value is holding
"""
setattr(self, 'value_%s' % self.attribute.datatype, new_value)
value = property(_get_value, _set_value)
def __str__(self):
return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity)
def __repr__(self):
return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity.pk)
class Entity(object):
"""
The helper class that will be attached to any entity
registered with eav.
"""
@staticmethod
def pre_save_handler(sender, *args, **kwargs):
"""
Pre save handler attached to self.instance. Called before the
model instance we are attached to is saved. This allows us to call
:meth:`validate_attributes` before the entity is saved.
"""
instance = kwargs['instance']
entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr)
entity.validate_attributes()
@staticmethod
def post_save_handler(sender, *args, **kwargs):
"""
Post save handler attached to self.instance. Calls :meth:`save` when
the model instance we are attached to is saved.
"""
instance = kwargs['instance']
entity = getattr(instance, instance._eav_config_cls.eav_attr)
entity.save()
def __init__(self, instance):
"""
Set self.instance equal to the instance of the model that we're attached
to. Also, store the content type of that instance.
"""
self.instance = instance
self.ct = ContentType.objects.get_for_model(instance)
def __getattr__(self, name):
"""
Tha magic getattr helper. This is called whenever user invokes::
instance.<attribute>
Checks if *name* is a valid slug for attributes available to this
instances. If it is, tries to lookup the :class:`Value` with that
attribute slug. If there is one, it returns the value of the
class:`Value` object, otherwise it hasn't been set, so it returns
None.
"""
if not name.startswith('_'):
try:
attribute = self.get_attribute_by_slug(name)
except Attribute.DoesNotExist:
raise AttributeError(
_('%(obj)s has no EAV attribute named %(attr)s')
% dict(obj = self.instance, attr = name)
)
try:
return self.get_value_by_attribute(attribute).value
except Value.DoesNotExist:
return None
return getattr(super(Entity, self), name)
def get_all_attributes(self):
"""
Return a query set of all :class:`Attribute` objects that can be set
for this entity.
"""
return self.instance._eav_config_cls.get_attributes().order_by('display_order')
def _hasattr(self, attribute_slug):
"""
Since we override __getattr__ with a backdown to the database, this
exists as a way of checking whether a user has set a real attribute on
ourselves, without going to the db if not.
"""
return attribute_slug in self.__dict__
def _getattr(self, attribute_slug):
"""
Since we override __getattr__ with a backdown to the database, this
exists as a way of getting the value a user set for one of our
attributes, without going to the db to check.
"""
return self.__dict__[attribute_slug]
def save(self):
"""
Saves all the EAV values that have been set on this entity.
"""
for attribute in self.get_all_attributes():
if self._hasattr(attribute.slug):
attribute_value = self._getattr(attribute.slug)
attribute.save_value(self.instance, attribute_value)
def validate_attributes(self):
"""
Called before :meth:`save`, first validate all the entity values to
make sure they can be created / saved cleanly.
Raises ``ValidationError`` if they can't be.
"""
values_dict = self.get_values_dict()
for attribute in self.get_all_attributes():
value = None
# Value was assigned to this instance.
if self._hasattr(attribute.slug):
value = self._getattr(attribute.slug)
values_dict.pop(attribute.slug, None)
# Otherwise try pre-loaded from DB.
else:
value = values_dict.pop(attribute.slug, None)
if value is None:
if attribute.required:
raise ValidationError(
_('{} EAV field cannot be blank'.format(attribute.slug))
)
else:
try:
attribute.validate_value(value)
except ValidationError as e:
raise ValidationError(
_('%(attr)s EAV field %(err)s')
% dict(attr = attribute.slug, err = e)
)
illegal = values_dict or (
self.get_object_attributes() - self.get_all_attribute_slugs())
if illegal:
raise IllegalAssignmentException(
'Instance of the class {} cannot have values for attributes: {}.'
.format(self.instance.__class__, ', '.join(illegal))
)
def get_values_dict(self):
return {v.attribute.slug: v.value for v in self.get_values()}
def get_values(self):
"""
Get all set :class:`Value` objects for self.instance
"""
return Value.objects.filter(
entity_ct = self.ct,
entity_id = self.instance.pk
).select_related()
def get_all_attribute_slugs(self):
"""
Returns a list of slugs for all attributes available to this entity.
"""
return set(self.get_all_attributes().values_list('slug', flat=True))
def get_attribute_by_slug(self, slug):
"""
Returns a single :class:`Attribute` with *slug*.
"""
return self.get_all_attributes().get(slug=slug)
def get_value_by_attribute(self, attribute):
"""
Returns a single :class:`Value` for *attribute*.
"""
return self.get_values().get(attribute=attribute)
def get_object_attributes(self):
"""
Returns entity instance attributes, except for
``instance`` and ``ct`` which are used internally.
"""
return set(copy(self.__dict__).keys()) - set(['instance', 'ct'])
def __iter__(self):
"""
Iterate over set eav values. This would allow you to do::
for i in m.eav: print(i)
"""
return iter(self.get_values())
class EAVModelMeta(ModelBase):
def __new__(cls, name, bases, namespace, **kwds):
result = super(EAVModelMeta, cls).__new__(cls, name, bases, dict(namespace))
register(result)
return result

View file

@ -1,25 +0,0 @@
"""
This module defines the four concrete, non-abstract models:
* :class:`Value`
* :class:`Attribute`
* :class:`EnumValue`
* :class:`EnumGroup`.
Along with the :class:`Entity` helper class and :class:`EAVModelMeta`
optional metaclass for each eav model class.
"""
from .attribute import Attribute
from .entity import EAVModelMeta, Entity
from .enum_group import EnumGroup
from .enum_value import EnumValue
from .value import Value
__all__ = [
"Attribute",
"EAVModelMeta",
"Entity",
"EnumGroup",
"EnumValue",
"Value",
]

View file

@ -1,367 +0,0 @@
# ruff: noqa: UP007
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import ForeignKey
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from eav.fields import EavDatatypeField
from eav.logic.entity_pk import get_entity_pk_type
from eav.logic.managers import AttributeManager
from eav.logic.object_pk import get_pk_format
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
from eav.settings import CHARFIELD_LENGTH
from eav.validators import (
validate_bool,
validate_csv,
validate_date,
validate_enum,
validate_float,
validate_int,
validate_json,
validate_object,
validate_text,
)
from .enum_value import EnumValue
from .value import Value
if TYPE_CHECKING:
from .enum_group import EnumGroup
class Attribute(models.Model):
"""
Putting the **A** in *EAV*. This holds the attributes, or concepts.
Examples of possible *Attributes*: color, height, weight, number of
children, number of patients, has fever?, etc...
Each attribute has a name, and a description, along with a slug that must
be unique. If you don't provide a slug, a default slug (derived from
name), will be created.
The *required* field is a boolean that indicates whether this EAV attribute
is required for entities to which it applies. It defaults to *False*.
.. warning::
Just like a normal model field that is required, you will not be able
to save or create any entity object for which this attribute applies,
without first setting this EAV attribute.
There are 7 possible values for datatype:
* int (TYPE_INT)
* float (TYPE_FLOAT)
* text (TYPE_TEXT)
* date (TYPE_DATE)
* bool (TYPE_BOOLEAN)
* object (TYPE_OBJECT)
* enum (TYPE_ENUM)
* json (TYPE_JSON)
* csv (TYPE_CSV)
Examples::
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
# = <Attribute: Height (Integer)>
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
# = <Attribute: Color (Text)>
yes = EnumValue.objects.create(value='yes')
no = EnumValue.objects.create(value='no')
unknown = EnumValue.objects.create(value='unknown')
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu.values.add(yes, no, unknown)
Attribute.objects.create(name='has fever?',
datatype=Attribute.TYPE_ENUM,
enum_group=ynu
)
# = <Attribute: has fever? (Multiple Choice)>
.. warning:: Once an Attribute has been used by an entity, you can not
change it's datatype.
"""
TYPE_TEXT = "text"
TYPE_FLOAT = "float"
TYPE_INT = "int"
TYPE_DATE = "date"
TYPE_BOOLEAN = "bool"
TYPE_OBJECT = "object"
TYPE_ENUM = "enum"
TYPE_JSON = "json"
TYPE_CSV = "csv"
DATATYPE_CHOICES = (
(TYPE_TEXT, _("Text")),
(TYPE_DATE, _("Date")),
(TYPE_FLOAT, _("Float")),
(TYPE_INT, _("Integer")),
(TYPE_BOOLEAN, _("True / False")),
(TYPE_OBJECT, _("Django Object")),
(TYPE_ENUM, _("Multiple Choice")),
(TYPE_JSON, _("JSON Object")),
(TYPE_CSV, _("Comma-Separated-Value")),
)
# Core attributes
id = get_pk_format()
datatype = EavDatatypeField(
choices=DATATYPE_CHOICES,
max_length=6,
verbose_name=_("Data Type"),
)
name = models.CharField(
max_length=CHARFIELD_LENGTH,
help_text=_("User-friendly attribute name"),
verbose_name=_("Name"),
)
"""
Main identifier for the attribute.
Upon creation, slug is autogenerated from the name.
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
"""
slug = models.SlugField(
max_length=SLUGFIELD_MAX_LENGTH,
db_index=True,
unique=True,
help_text=_("Short unique attribute label"),
verbose_name=_("Slug"),
)
"""
.. warning::
This attribute should be used with caution. Setting this to *True*
means that *all* entities that *can* have this attribute will
be required to have a value for it.
"""
required = models.BooleanField(
default=False,
verbose_name=_("Required"),
)
entity_ct = models.ManyToManyField(
ContentType,
blank=True,
verbose_name=_("Entity content type"),
)
"""
This field allows you to specify a relationship with any number of content types.
This would be useful, for example, if you wanted an attribute to apply only to
a subset of entities. In that case, you could filter by content type in the
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
"""
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
"eav.EnumGroup",
on_delete=models.PROTECT,
blank=True,
null=True,
verbose_name=_("Choice Group"),
)
description = models.CharField(
max_length=256,
blank=True,
default="",
help_text=_("Short description"),
verbose_name=_("Description"),
)
# Useful meta-information
display_order = models.PositiveIntegerField(
default=1,
verbose_name=_("Display order"),
)
modified = models.DateTimeField(
auto_now=True,
verbose_name=_("Modified"),
)
created = models.DateTimeField(
default=timezone.now,
editable=False,
verbose_name=_("Created"),
)
objects = AttributeManager()
class Meta:
ordering = ("name",)
verbose_name = _("Attribute")
verbose_name_plural = _("Attributes")
def __str__(self) -> str:
return f"{self.name} ({self.get_datatype_display()})"
def save(self, *args, **kwargs):
"""
Saves the Attribute and auto-generates a slug field
if one wasn't provided.
"""
if not self.slug:
self.slug = generate_slug(self.name)
self.full_clean()
super().save(*args, **kwargs)
def natural_key(self) -> tuple[str, str]:
"""
Retrieve the natural key for the Attribute instance.
The natural key for an Attribute is defined by its `name` and `slug`. This
method returns a tuple containing these two attributes of the instance.
Returns
-------
tuple: A tuple containing the name and slug of the Attribute instance.
"""
return (
self.name,
self.slug,
)
@property
def help_text(self):
return self.description
def get_validators(self):
"""
Returns the appropriate validator function from :mod:`~eav.validators`
as a list (of length one) for the datatype.
.. note::
The reason it returns it as a list, is eventually we may want this
method to look elsewhere for additional attribute specific
validators to return as well as the default, built-in one.
"""
datatype_validators = {
"text": validate_text,
"float": validate_float,
"int": validate_int,
"date": validate_date,
"bool": validate_bool,
"object": validate_object,
"enum": validate_enum,
"json": validate_json,
"csv": validate_csv,
}
return [datatype_validators[self.datatype]]
def validate_value(self, value):
"""
Check *value* against the validators returned by
:meth:`get_validators` for this attribute.
"""
for validator in self.get_validators():
validator(value)
if self.datatype == self.TYPE_ENUM:
if isinstance(value, EnumValue):
value = value.value
if not self.enum_group.values.filter(value=value).exists():
raise ValidationError(
_("%(val)s is not a valid choice for %(attr)s")
% {"val": value, "attr": self},
)
def clean(self):
"""
Validates the attribute. Will raise ``ValidationError`` if the
attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if
the attribute is not *TYPE_ENUM* and the enum group is set.
"""
if self.datatype == self.TYPE_ENUM and not self.enum_group:
raise ValidationError(
_("You must set the choice group for multiple choice attributes"),
)
if self.datatype != self.TYPE_ENUM and self.enum_group:
raise ValidationError(
_("You can only assign a choice group to multiple choice attributes"),
)
def clean_fields(self, exclude=None):
"""Perform field-specific validation on the model's fields.
This method extends the default field cleaning process to include
custom validation for the slug field.
Args:
exclude (list): Fields to exclude from cleaning.
Raises:
ValidationError: If the slug is not a valid Python identifier.
"""
super().clean_fields(exclude=exclude)
if not self.slug.isidentifier():
warnings.warn(
f"Slug '{self.slug}' is not a valid Python identifier. "
+ "Consider updating it.",
stacklevel=3,
)
def get_choices(self):
"""
Returns a query set of :class:`EnumValue` objects for this attribute.
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
"""
return (
self.enum_group.values.all()
if self.datatype == Attribute.TYPE_ENUM
else None
)
def save_value(self, entity, value):
"""
Called with *entity*, any Django object registered with eav, and
*value*, the :class:`Value` this attribute for *entity* should
be set to.
If a :class:`Value` object for this *entity* and attribute doesn't
exist, one will be created.
.. note::
If *value* is None and a :class:`Value` object exists for this
Attribute and *entity*, it will delete that :class:`Value` object.
"""
ct = ContentType.objects.get_for_model(entity)
entity_filter = {
"entity_ct": ct,
"attribute": self,
f"{get_entity_pk_type(entity)}": entity.pk,
}
try:
value_obj = self.value_set.get(**entity_filter)
except Value.DoesNotExist:
if value is None or value == "":
return
value_obj = Value.objects.create(**entity_filter)
if value is None or value == "":
value_obj.delete()
return
if value != value_obj.value:
value_obj.value = value
value_obj.save()

View file

@ -1,208 +0,0 @@
from copy import copy
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.base import ModelBase
from django.utils.translation import gettext_lazy as _
from eav import register
from eav.exceptions import IllegalAssignmentException
from eav.logic.entity_pk import get_entity_pk_type
from .attribute import Attribute
from .enum_value import EnumValue
from .value import Value
class Entity:
"""Helper class that will be attached to entities registered with eav."""
@staticmethod
def pre_save_handler(sender, *args, **kwargs):
"""
Pre save handler attached to self.instance. Called before the
model instance we are attached to is saved. This allows us to call
:meth:`validate_attributes` before the entity is saved.
"""
instance = kwargs["instance"]
entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001
entity.validate_attributes()
@staticmethod
def post_save_handler(sender, *args, **kwargs):
"""
Post save handler attached to self.instance. Calls :meth:`save` when
the model instance we are attached to is saved.
"""
instance = kwargs["instance"]
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
entity.save()
def __init__(self, instance) -> None:
"""
Set self.instance equal to the instance of the model that we're attached
to. Also, store the content type of that instance.
"""
self.instance = instance
self.ct = ContentType.objects.get_for_model(instance)
def __getattr__(self, name):
"""
The magic getattr helper. This is called whenever user invokes::
instance.<attribute>
Checks if *name* is a valid slug for attributes available to this
instances. If it is, tries to lookup the :class:`Value` with that
attribute slug. If there is one, it returns the value of the
class:`Value` object, otherwise it hasn't been set, so it returns
None.
"""
if not name.startswith("_"):
try:
attribute = self.get_attribute_by_slug(name)
except Attribute.DoesNotExist as err:
raise AttributeError(
_("%(obj)s has no EAV attribute named %(attr)s")
% {"obj": self.instance, "attr": name},
) from err
try:
return self.get_value_by_attribute(attribute).value
except Value.DoesNotExist:
return None
return getattr(super(), name)
def get_all_attributes(self):
"""
Return a query set of all :class:`Attribute` objects that can be set
for this entity.
"""
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
instance=self.instance,
).order_by("display_order")
def _hasattr(self, attribute_slug):
"""
Since we override __getattr__ with a backdown to the database, this
exists as a way of checking whether a user has set a real attribute on
ourselves, without going to the db if not.
"""
return attribute_slug in self.__dict__
def _getattr(self, attribute_slug):
"""
Since we override __getattr__ with a backdown to the database, this
exists as a way of getting the value a user set for one of our
attributes, without going to the db to check.
"""
return self.__dict__[attribute_slug]
def save(self):
"""Saves all the EAV values that have been set on this entity."""
for attribute in self.get_all_attributes():
if self._hasattr(attribute.slug):
attribute_value = self._getattr(attribute.slug)
if (
attribute.datatype == Attribute.TYPE_ENUM
and not isinstance(
attribute_value,
EnumValue,
)
and attribute_value is not None
):
attribute_value = EnumValue.objects.get(value=attribute_value)
attribute.save_value(self.instance, attribute_value)
def validate_attributes(self):
"""
Called before :meth:`save`, first validate all the entity values to
make sure they can be created / saved cleanly.
Raises ``ValidationError`` if they can't be.
"""
values_dict = self.get_values_dict()
for attribute in self.get_all_attributes():
value = None
# Value was assigned to this instance.
if self._hasattr(attribute.slug):
value = self._getattr(attribute.slug)
values_dict.pop(attribute.slug, None)
# Otherwise try pre-loaded from DB.
else:
value = values_dict.pop(attribute.slug, None)
if value is None:
if attribute.required:
raise ValidationError(
_("%s EAV field cannot be blank") % attribute.slug,
)
else:
try:
attribute.validate_value(value)
except ValidationError as err:
raise ValidationError(
_("%(attr)s EAV field %(err)s")
% {"attr": attribute.slug, "err": err},
) from err
illegal = values_dict or (
self.get_object_attributes() - self.get_all_attribute_slugs()
)
if illegal:
message = (
"Instance of the class {} cannot have values for attributes: {}."
).format(
self.instance.__class__,
", ".join(illegal),
)
raise IllegalAssignmentException(message)
def get_values_dict(self):
return {v.attribute.slug: v.value for v in self.get_values()}
def get_values(self):
"""Get all set :class:`Value` objects for self.instance."""
entity_filter = {
"entity_ct": self.ct,
f"{get_entity_pk_type(self.instance)}": self.instance.pk,
}
return Value.objects.filter(**entity_filter).select_related()
def get_all_attribute_slugs(self):
"""Returns a list of slugs for all attributes available to this entity."""
return set(self.get_all_attributes().values_list("slug", flat=True))
def get_attribute_by_slug(self, slug):
"""Returns a single :class:`Attribute` with *slug*."""
return self.get_all_attributes().get(slug=slug)
def get_value_by_attribute(self, attribute):
"""Returns a single :class:`Value` for *attribute*."""
return self.get_values().get(attribute=attribute)
def get_object_attributes(self):
"""
Returns entity instance attributes, except for
``instance`` and ``ct`` which are used internally.
"""
return set(copy(self.__dict__).keys()) - {"instance", "ct"}
def __iter__(self):
"""
Iterate over set eav values. This would allow you to do::
for i in m.eav: print(i)
"""
return iter(self.get_values())
class EAVModelMeta(ModelBase):
def __new__(cls, name, bases, namespace, **kwds):
result = super().__new__(cls, name, bases, dict(namespace))
register(result)
return result

View file

@ -1,63 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from django.db import models
from django.db.models import ManyToManyField
from django.utils.translation import gettext_lazy as _
from eav.logic.managers import EnumGroupManager
from eav.logic.object_pk import get_pk_format
from eav.settings import CHARFIELD_LENGTH
if TYPE_CHECKING:
from .enum_value import EnumValue
class EnumGroup(models.Model):
"""
*EnumGroup* objects have two fields - a *name* ``CharField`` and *values*,
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
See :class:`EnumValue` for an example.
"""
id = get_pk_format()
name = models.CharField(
unique=True,
max_length=CHARFIELD_LENGTH,
verbose_name=_("Name"),
)
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
"eav.EnumValue",
verbose_name=_("Enum group"),
)
objects = EnumGroupManager()
class Meta:
verbose_name = _("EnumGroup")
verbose_name_plural = _("EnumGroups")
def __str__(self) -> str:
"""String representation of `EnumGroup` instance."""
return str(self.name)
def __repr__(self) -> str:
"""String representation of `EnumGroup` object."""
return f"<EnumGroup {self.name}>"
def natural_key(self) -> tuple[str]:
"""
Retrieve the natural key for the EnumGroup instance.
The natural key for an EnumGroup is defined by its `name`. This method
returns the name of the instance as a single-element tuple.
Returns
-------
tuple: A tuple containing the name of the EnumGroup instance.
"""
return (self.name,)

View file

@ -1,74 +0,0 @@
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
from eav.logic.managers import EnumValueManager
from eav.logic.object_pk import get_pk_format
from eav.logic.slug import SLUGFIELD_MAX_LENGTH
class EnumValue(models.Model):
"""
*EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM*
:class:`Attribute` objects. They have only one field, *value*, a
``CharField`` that must be unique.
For example::
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
no = EnumValue.objects.create(value='No')
unknown = EnumValue.objects.create(value='Unknown')
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
ynu.values.add(yes, no, unknown)
Attribute.objects.create(name='has fever?',
datatype=Attribute.TYPE_ENUM, enum_group=ynu)
# = <Attribute: has fever? (Multiple Choice)>
.. note::
The same *EnumValue* objects should be reused within multiple
*EnumGroups*. For example, if you have one *EnumGroup* called: *Yes /
No / Unknown* and another called *Yes / No / Not applicable*, you should
only have a total of four *EnumValues* objects, as you should have used
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
"""
id = get_pk_format()
value = models.CharField(
_("Value"),
db_index=True,
unique=True,
max_length=SLUGFIELD_MAX_LENGTH,
)
objects = EnumValueManager()
class Meta:
verbose_name = _("EnumValue")
verbose_name_plural = _("EnumValues")
def __str__(self) -> str:
"""String representation of `EnumValue` instance."""
return str(
self.value,
)
def __repr__(self) -> str:
"""String representation of `EnumValue` object."""
return f"<EnumValue {self.value}>"
def natural_key(self) -> tuple[str]:
"""
Retrieve the natural key for the EnumValue instance.
The natural key for an EnumValue is defined by its `value`. This method returns
the value of the instance as a single-element tuple.
Returns
-------
tuple: A tuple containing the value of the EnumValue instance.
"""
return (self.value,)

View file

@ -1,232 +0,0 @@
# ruff: noqa: UP007
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Optional
from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.db.models import ForeignKey
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from eav.fields import CSVField
from eav.logic.managers import ValueManager
from eav.logic.object_pk import get_pk_format
if TYPE_CHECKING:
from .attribute import Attribute
from .enum_value import EnumValue
class Value(models.Model):
"""
Putting the **V** in *EAV*.
This model stores the value for one particular :class:`Attribute` for
some entity.
As with most EAV implementations, most of the columns of this model will
be blank, as onle one *value_* field will be used.
Example::
import eav
from django.contrib.auth.models import User
eav.register(User)
u = User.objects.create(username='crazy_dev_user')
a = Attribute.objects.create(name='Fav Drink', datatype='text')
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
# = <Value: crazy_dev_user - Fav Drink: "red bull">
"""
id = get_pk_format()
# Direct foreign keys
attribute: ForeignKey[Attribute] = ForeignKey(
"eav.Attribute",
db_index=True,
on_delete=models.PROTECT,
verbose_name=_("Attribute"),
)
# Entity generic relationships. Rather than rely on database casting,
# this will instead use a separate ForeignKey field attribute that matches
# the FK type of the entity.
entity_id = models.IntegerField(
blank=True,
null=True,
verbose_name=_("Entity id"),
)
entity_uuid = models.UUIDField(
blank=True,
null=True,
verbose_name=_("Entity uuid"),
)
entity_ct = ForeignKey(
ContentType,
on_delete=models.PROTECT,
related_name="value_entities",
verbose_name=_("Entity ct"),
)
entity_pk_int = generic.GenericForeignKey(
ct_field="entity_ct",
fk_field="entity_id",
)
entity_pk_uuid = generic.GenericForeignKey(
ct_field="entity_ct",
fk_field="entity_uuid",
)
# Model attributes
created = models.DateTimeField(
default=timezone.now,
verbose_name=_("Created"),
)
modified = models.DateTimeField(
auto_now=True,
verbose_name=_("Modified"),
)
# Value attributes
value_bool = models.BooleanField(
blank=True,
null=True,
verbose_name=_("Value bool"),
)
value_csv = CSVField(
blank=True,
null=True,
verbose_name=_("Value CSV"),
)
value_date = models.DateTimeField(
blank=True,
null=True,
verbose_name=_("Value date"),
)
value_float = models.FloatField(
blank=True,
null=True,
verbose_name=_("Value float"),
)
value_int = models.BigIntegerField(
blank=True,
null=True,
verbose_name=_("Value int"),
)
value_text = models.TextField(
blank=True,
default="",
verbose_name=_("Value text"),
)
value_json = models.JSONField(
default=dict,
encoder=DjangoJSONEncoder,
blank=True,
null=True,
verbose_name=_("Value JSON"),
)
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
"eav.EnumValue",
blank=True,
null=True,
on_delete=models.PROTECT,
related_name="eav_values",
verbose_name=_("Value enum"),
)
# Value object relationship
generic_value_id = models.IntegerField(
blank=True,
null=True,
verbose_name=_("Generic value id"),
)
generic_value_ct = ForeignKey(
ContentType,
blank=True,
null=True,
on_delete=models.PROTECT,
related_name="value_values",
verbose_name=_("Generic value content type"),
)
value_object = generic.GenericForeignKey(
ct_field="generic_value_ct",
fk_field="generic_value_id",
)
objects = ValueManager()
class Meta:
verbose_name = _("Value")
verbose_name_plural = _("Values")
constraints: ClassVar[list[models.Constraint]] = [
models.UniqueConstraint(
fields=["entity_ct", "attribute", "entity_uuid"],
name="unique_entity_uuid_per_attribute",
),
models.UniqueConstraint(
fields=["entity_ct", "attribute", "entity_id"],
name="unique_entity_id_per_attribute",
),
models.CheckConstraint(
check=(
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
),
name="ensure_entity_id_xor_entity_uuid",
),
]
def __str__(self) -> str:
"""String representation of a Value."""
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
return f'{self.attribute.name}: "{self.value}" ({entity})'
def __repr__(self) -> str:
"""Representation of Value object."""
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
return f'{self.attribute.name}: "{self.value}" ({entity})'
def save(self, *args, **kwargs):
"""Validate and save this value."""
self.full_clean()
super().save(*args, **kwargs)
def natural_key(self) -> tuple[tuple[str, str], int, str]:
"""
Retrieve the natural key for the Value instance.
The natural key for a Value is a combination of its `attribute` natural key,
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
three elements.
Returns
-------
tuple: A tuple containing the natural key of the attribute, entity ID,
and entity UUID of the Value instance.
"""
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
def _get_value(self):
"""Return the python object this value is holding."""
return getattr(self, f"value_{self.attribute.datatype}")
def _set_value(self, new_value):
"""Set the object this value is holding."""
setattr(self, f"value_{self.attribute.datatype}", new_value)
value = property(_get_value, _set_value)

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""
This module contains custom :class:`EavQuerySet` class used for overriding
relational operators and pure functions for rewriting Q-expressions.
@ -20,14 +21,12 @@ Q-expressions need to be rewritten for two reasons:
"""
from functools import wraps
from itertools import count
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Case, IntegerField, Q, When
from django.db import models
from django.db.models import Q
from django.db.models.query import QuerySet
from django.db.utils import NotSupportedError
from eav.models import Attribute, EnumValue, Value
from .models import Attribute, Value
def is_eav_and_leaf(expr, gr_name):
@ -42,65 +41,65 @@ def is_eav_and_leaf(expr, gr_name):
bool
"""
return (
getattr(expr, "connector", None) == "AND"
and len(expr.children) == 1
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
getattr(expr, 'connector', None) == 'AND' and
len(expr.children) == 1 and
expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)]
)
def rewrite_q_expr(model_cls, expr):
"""
Rewrites Q-expression to safe form, in order to ensure that
generated SQL is valid.
Rewrites Q-expression to safe form, in order to ensure that
generated SQL is valid.
IGNORE:
Suppose we have the following Q-expression:
IGNORE:
Suppose we have the following Q-expression:
OR
AND
eav_values__in [1, 2, 3]
AND (1)
AND
eav_values__in [4, 5]
AND
eav_values__in [6, 7, 8]
IGNORE
OR
AND
eav_values__in [1, 2, 3]
AND (1)
AND
eav_values__in [4, 5]
AND
eav_values__in [6, 7, 8]
IGNORE
All EAV values are stored in a single table. Therefore, INNER JOIN
generated for the AND-expression (1) will always fail, i.e.
single row in a eav_values table cannot be both in two disjoint sets at
the same time (and the whole point of using AND, usually, is two have
two different sets). Therefore, we must paritially rewrite the
expression so that the generated SQL is valid.
All EAV values are stored in a single table. Therefore, INNER JOIN
generated for the AND-expression (1) will always fail, i.e.
single row in a eav_values table cannot be both in two disjoint sets at
the same time (and the whole point of using AND, usually, is two have
two different sets). Therefore, we must paritially rewrite the
expression so that the generated SQL is valid::
IGNORE:
OR
AND
eav_values__in [1, 2, 3]
AND
pk__in [1, 2]
IGNORE
IGNORE:
OR
AND
eav_values__in [1, 2, 3]
AND
pk__in [1, 2]
IGNORE
This is done by merging dangerous AND's and substituting them with
explicit ``pk__in`` filter, where pks are taken from evaluated
Q-expr branch.
This is done by merging dangerous AND's and substituting them with
explicit ``pk__in`` filter, where pks are taken from evaluted
Q-expr branch.
Args:
model_cls (TypeVar): model class used to construct :meth:`QuerySet`
from leaf attribute-value expression.
expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten.
Args:
model_cls (TypeVar): model class used to construct :meth:`QuerySet`
from leaf attribute-value expression.
expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten.
Returns:
Union[Q, tuple]
Returns:
Union[Q, tuple]
"""
# Node in a Q-expr can be a Q or an attribute-value tuple (leaf).
# 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.
# Recurively check child nodes.
expr.children = [rewrite_q_expr(model_cls, c) for c in expr.children]
# Check which ones need a rewrite.
rewritable = [c for c in expr.children if is_eav_and_leaf(c, gr_name)]
@ -111,18 +110,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 c not in rewritable]
other = [c for c in expr.children if not c 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 TypeError("Attribute-value must be a tuple")
raise AssertionError('Attribute-value must be a tuple')
fname = f"{gr_name}__in"
fname = '{}__in'.format(gr_name)
# Child can be either a 'eav_values__in' or 'pk__in' query.
# If it's the former then transform it into the latter.
@ -130,7 +129,7 @@ def rewrite_q_expr(model_cls, expr):
# If so, reverse it back to QuerySet so that set operators
# can be applied.
if attrval[0] == fname or hasattr(attrval[1], "__contains__"):
if attrval[0] == fname or hasattr(attrval[1], '__contains__'):
# Create model queryset.
_q = model_cls.objects.filter(**{fname: attrval[1]})
else:
@ -139,17 +138,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 is not None else _q
q = q if q != 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 is not None:
expr.children = [*other, ("pk__in", q)]
if q != None:
expr.children = other + [('pk__in', q)]
return expr
@ -160,7 +159,6 @@ def eav_filter(func):
:func:`expand_q_filters` and kwargs through :func:`expand_eav_filter`. Returns the
called function (filter or exclude).
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
nargs = []
@ -169,9 +167,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) # noqa: PLW2901
arg = expand_q_filters(arg, self.model)
# Rewrite Q-expression to safeform.
arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901
arg = rewrite_q_expr(self.model, arg)
nargs.append(arg)
for key, value in kwargs.items():
@ -179,11 +177,8 @@ 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
nkwargs[nkey] = nval.filter(
entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True),
).distinct()
# Apply AND to both querysets.
nkwargs[nkey] = (nkwargs[nkey] & nval).distinct()
else:
nkwargs.update({nkey: nval})
@ -229,30 +224,34 @@ 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 = ""
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
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"
else:
lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004
value_key = f"value_{datatype}{lookup}"
kwargs = {value_key: value, "attribute__slug": slug}
lookup = '__%s' % fields[2] if len(fields) > 2 else ''
kwargs = {
'value_{}{}'.format(datatype, lookup): value,
'attribute__slug': slug
}
value = Value.objects.filter(**kwargs)
return f"{gr_name}__in", value
return '%s__in' % gr_name, value
# Not an eav field, so keep as is
return key, value
try:
field = model_cls._meta.get_field(fields[0])
except models.FieldDoesNotExist:
return key, value
if not field.auto_created or field.concrete:
return key, value
else:
sub_key = '__'.join(fields[1:])
key, value = expand_eav_filter(field.model, sub_key, value)
return '{}__{}'.format(fields[0], key), value
class EavQuerySet(QuerySet):
@ -266,7 +265,7 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` filter method.
"""
return super().filter(*args, **kwargs)
return super(EavQuerySet, self).filter(*args, **kwargs)
@eav_filter
def exclude(self, *args, **kwargs):
@ -274,7 +273,7 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` exclude method.
"""
return super().exclude(*args, **kwargs)
return super(EavQuerySet, self).exclude(*args, **kwargs)
@eav_filter
def get(self, *args, **kwargs):
@ -282,89 +281,4 @@ class EavQuerySet(QuerySet):
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
the ``Manager`` get method.
"""
return super().get(*args, **kwargs)
def order_by(self, *fields):
# Django only allows to order querysets by direct fields and
# foreign-key chains. In order to bypass this behaviour and order
# by EAV attributes, it is required to construct custom order-by
# clause manually using Django's conditional expressions.
# This will be slow, of course.
order_clauses = []
query_clause = self
config_cls = self.model._eav_config_cls # noqa: SLF001
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: # noqa: PLR2004
# Retrieve Attribute over which the ordering is performed.
try:
attr = Attribute.objects.get(slug=term[1])
except ObjectDoesNotExist as err:
raise ObjectDoesNotExist(
f'Cannot find EAV attribute "{term[1]}"',
) from err
field_name = f"value_{attr.datatype}"
pks_values = (
Value.objects.filter(
# Retrieve pk-values pairs of the related values
# (i.e. values for the specified attribute and
# belonging to entities in the queryset).
attribute__slug=attr.slug,
entity_id__in=self,
)
.order_by(
# Order values by their value-field of
# appropriate attribute data-type.
field_name,
)
.values_list(
# Retrieve only primary-keys of the entities
# in the current queryset.
"entity_id",
field_name,
)
)
# Retrieve ordered values from pk-value list.
_, ordered_values = zip(*pks_values)
# Add explicit ordering and turn
# list of pairs into look-up table.
val2ind = dict(zip(ordered_values, count()))
# Finally, zip ordered pks with their grouped orderings.
entities_pk = [(pk, val2ind[val]) for pk, val in pks_values]
# Using ordered primary-keys, construct
# CASE clause of the form:
#
# CASE
# WHEN id = 2 THEN 1
# WHEN id = 5 THEN 2
# WHEN id = 9 THEN 2
# WHEN id = 4 THEN 3
# END
#
when_clauses = [When(id=pk, then=i) for pk, i in entities_pk]
order_clause = Case(*when_clauses, output_field=IntegerField())
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: # noqa: PLR2004
raise NotSupportedError(
"EAV does not support ordering through foreign-key chains",
)
else:
order_clauses.append(term[0])
return QuerySet.order_by(query_clause, *order_clauses)
return super(EavQuerySet, self).get(*args, **kwargs)

View file

@ -3,14 +3,13 @@
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 .managers import EntityManager
from .models import Attribute, Entity, Value
class EavConfig:
class EavConfig(object):
"""
The default ``EavConfig`` class used if it is not overridden on registration.
The default ``EavConfig`` class used if it is not overriden on registration.
This is where all the default eav attribute names are defined.
Available options are as follows:
@ -27,15 +26,14 @@ class EavConfig:
GenericRelation from Entity to Value. None by default. Therefore,
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
def get_attributes(cls, instance=None):
def get_attributes(cls):
"""
By default, all :class:`~eav.models.Attribute` object apply to an
entity, unless you provide a custom EavConfig class overriding this.
@ -43,7 +41,7 @@ class EavConfig:
return Attribute.objects.all()
class Registry:
class Registry(object):
"""
Handles registration through the
:meth:`register` and :meth:`unregister` methods.
@ -58,14 +56,15 @@ class Registry:
.. 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(f"{model_cls.__name__}Config", (EavConfig,), {})
config_cls = type("%sConfig" % model_cls.__name__,
(EavConfig,), {})
# set _eav_config_cls on the model so we can access it there
model_cls._eav_config_cls = config_cls
setattr(model_cls, '_eav_config_cls', config_cls)
reg = Registry(model_cls)
reg._register_self()
@ -78,19 +77,19 @@ class Registry:
.. 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))
@ -101,41 +100,20 @@ class Registry:
self.model_cls = model_cls
self.config_cls = model_cls._eav_config_cls
def _attach_manager(self) -> None:
def _attach_manager(self):
"""
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.
Attach the manager to *manager_attr* specified in *config_cls*
"""
manager_attr = self.config_cls.manager_attr
model_meta = self.model_cls._meta
current_manager = getattr(self.model_cls, manager_attr, None)
# 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)
self.config_cls.old_mgr = mgr
self.model_cls._meta.local_managers.remove(mgr)
self.model_cls._meta._expire_cache()
if isinstance(current_manager, EntityManager):
# EntityManager is already attached, no need to proceed
return
# Create a new EntityManager
new_manager = EntityManager()
# 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)
# Attach the new manager to the model.
mgr = EntityManager()
mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr)
def _detach_manager(self):
"""
@ -146,11 +124,10 @@ class Registry:
self.model_cls._meta._expire_cache()
delattr(self.model_cls, self.config_cls.manager_attr)
if hasattr(self.config_cls, "old_mgr"):
self.config_cls.old_mgr.contribute_to_class(
self.model_cls,
self.config_cls.manager_attr,
)
if hasattr(self.config_cls, 'old_mgr'):
self.config_cls.old_mgr \
.contribute_to_class(self.model_cls,
self.config_cls.manager_attr)
def _attach_signals(self):
"""
@ -159,31 +136,31 @@ class Registry:
able to prepare and clean-up before and after creation /
update of the user's model class instance.
"""
post_init.connect(Registry.attach_eav_attr, sender=self.model_cls)
pre_save.connect(Entity.pre_save_handler, sender=self.model_cls)
post_save.connect(Entity.post_save_handler, sender=self.model_cls)
post_init.connect(Registry.attach_eav_attr, sender = self.model_cls)
pre_save.connect(Entity.pre_save_handler, sender = self.model_cls)
post_save.connect(Entity.post_save_handler, sender = self.model_cls)
def _detach_signals(self):
"""
Detach all signals for eav.
"""
post_init.disconnect(Registry.attach_eav_attr, sender=self.model_cls)
pre_save.disconnect(Entity.pre_save_handler, sender=self.model_cls)
post_save.disconnect(Entity.post_save_handler, sender=self.model_cls)
post_init.disconnect(Registry.attach_eav_attr, sender = self.model_cls)
pre_save.disconnect(Entity.pre_save_handler, sender = self.model_cls)
post_save.disconnect(Entity.post_save_handler, sender = self.model_cls)
def _attach_generic_relation(self):
"""Set up the generic relation for the entity."""
rel_name = (
self.config_cls.generic_relation_related_name or self.model_cls.__name__
)
"""
Set up the generic relation for the entity
"""
rel_name = self.config_cls.generic_relation_related_name or \
self.model_cls.__name__
gr_name = self.config_cls.generic_relation_attr.lower()
generic_relation = generic.GenericRelation(
Value,
object_id_field=get_entity_pk_type(self.model_cls),
content_type_field="entity_ct",
related_query_name=rel_name,
)
generic_relation = \
generic.GenericRelation(Value,
object_id_field='entity_id',
content_type_field='entity_ct',
related_query_name=rel_name)
generic_relation.contribute_to_class(self.model_cls, gr_name)
def _detach_generic_relation(self):

View file

@ -1,3 +0,0 @@
from typing import Final
CHARFIELD_LENGTH: Final = 100

View file

@ -3,7 +3,7 @@ This module contains a validator for each :class:`~eav.models.Attribute` datatyp
A validator is a callable that takes a value and raises a ``ValidationError``
if it doesn't meet some criteria (see `Django validators
<https://docs.djangoproject.com/en/dev/ref/validators/>`_).
<http://docs.djangoproject.com/en/dev/ref/validators/>`_).
These validators are called by the
:meth:`~eav.models.Attribute.validate_value` method in the
@ -11,11 +11,10 @@ These validators are called by the
"""
import datetime
import json
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ugettext_lazy as _
def validate_text(value):
@ -23,7 +22,7 @@ def validate_text(value):
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
"""
if not isinstance(value, str):
raise ValidationError(_("Must be str or unicode"))
raise ValidationError(_(u"Must be str or unicode"))
def validate_float(value):
@ -32,8 +31,8 @@ def validate_float(value):
"""
try:
float(value)
except ValueError as err:
raise ValidationError(_("Must be a float")) from err
except ValueError:
raise ValidationError(_(u"Must be a float"))
def validate_int(value):
@ -42,8 +41,8 @@ def validate_int(value):
"""
try:
int(value)
except ValueError as err:
raise ValidationError(_("Must be an integer")) from err
except ValueError:
raise ValidationError(_(u"Must be an integer"))
def validate_date(value):
@ -51,11 +50,8 @@ def validate_date(value):
Raises ``ValidationError`` unless *value* is an instance of ``datetime``
or ``date``
"""
if not isinstance(value, datetime.datetime) and not isinstance(
value,
datetime.date,
):
raise ValidationError(_("Must be a date or datetime"))
if not isinstance(value, datetime.datetime) and not isinstance(value, datetime.date):
raise ValidationError(_(u"Must be a date or datetime"))
def validate_bool(value):
@ -63,7 +59,7 @@ def validate_bool(value):
Raises ``ValidationError`` unless *value* type is ``bool``
"""
if not isinstance(value, bool):
raise ValidationError(_("Must be a boolean"))
raise ValidationError(_(u"Must be a boolean"))
def validate_object(value):
@ -72,10 +68,10 @@ def validate_object(value):
django model instance.
"""
if not isinstance(value, models.Model):
raise ValidationError(_("Must be a django model object instance"))
raise ValidationError(_(u"Must be a django model object instance"))
if not value.pk:
raise ValidationError(_("Model has not been saved yet"))
raise ValidationError(_(u"Model has not been saved yet"))
def validate_enum(value):
@ -83,30 +79,10 @@ def validate_enum(value):
Raises ``ValidationError`` unless *value* is a saved
:class:`~eav.models.EnumValue` model instance.
"""
from eav.models import EnumValue
from .models import EnumValue
if isinstance(value, EnumValue) and not value.pk:
raise ValidationError(_("EnumValue has not been saved yet"))
if not isinstance(value, EnumValue):
raise ValidationError(_(u"Must be an EnumValue model object instance"))
def validate_json(value):
"""
Raises ``ValidationError`` unless *value* can be cast as an ``json object`` (a dict)
"""
try:
if isinstance(value, str):
value = json.loads(value)
if not isinstance(value, dict):
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):
"""
Raises ``ValidationError`` unless *value* is a c-s-v value.
"""
if isinstance(value, str):
value = value.split(";")
if not isinstance(value, list):
raise ValidationError(_("Must be Comma-Separated-Value."))
if not value.pk:
raise ValidationError(_(u"EnumValue has not been saved yet"))

View file

@ -1,39 +0,0 @@
from django.core import validators
from django.core.exceptions import ValidationError
from django.forms.widgets import Textarea
EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]")
class CSVWidget(Textarea):
is_hidden = False
def prep_value(self, value):
"""Prepare value before effectively render widget"""
if value in EMPTY_VALUES:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return ";".join(value)
raise ValidationError("Invalid format.")
def render(self, name, value, **kwargs):
value = self.prep_value(value)
return super().render(name, value, **kwargs)
def value_from_datadict(self, data, files, name):
"""
Return the value of this widget or None.
Since we're only given the value of the entity name and the data dict
contains the '_eav_config_cls' (which we don't have access to) as the
key, we need to loop through each field checking if the eav attribute
exists with the given 'name'.
"""
for data_value in data.values():
widget_value = getattr(data_value, name, None)
if widget_value is not None:
return widget_value
return None

View file

@ -1,31 +0,0 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
"""
Main function.
It does several things:
1. Sets default settings module, if it is not set
2. Warns if Django is not installed
3. Executes any given command
"""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
try:
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?",
) from err
management.execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

2254
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,134 +0,0 @@
[build-system]
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.8.1"
license = "GNU Lesser General Public License (LGPL), Version 3"
packages = [
{ include = "eav" }
]
authors = [
"Mauro Lizaur <mauro@sdf.org>",
]
readme = "README.md"
repository = "https://github.com/jazzband/django-eav2"
keywords = [
"django",
"django-eav2",
"database",
"eav",
"sql",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Database",
"Topic :: Software Development :: Libraries :: Python Modules",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
]
[tool.semantic_release]
version_variable = [
"pyproject.toml:version"
]
branch = "master"
upload_to_pypi = false
upload_to_release = false
build_command = "pip install poetry && poetry build"
[tool.poetry.dependencies]
python = "^3.9"
django = ">=4.2,<5.3"
[tool.poetry.group.test.dependencies]
mypy = "^1.6"
ruff = ">=0.6.3,<0.13.0"
safety = ">=2.3,<4.0"
pytest = ">=7.4.3,<9.0.0"
pytest-cov = ">=4.1,<7.0"
pytest-randomly = "^3.15"
pytest-django = "^4.5.2"
hypothesis = "^6.87.1"
doc8 = ">=0.11.2,<1.2.0"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
sphinx = ">=5.0,<8.0"
sphinx-rtd-theme = ">=1.3,<4.0"
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
m2r2 = "^0.3"
tomlkit = ">=0.13.0,<0.14"
[tool.ruff]
line-length = 88
target-version = "py38"
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"ANN", # Type hints related, let mypy handle these.
"ARG", # Unused arguments
"D", # Docstrings related
"EM101", # "Exception must not use a string literal, assign to variable first"
"EM102", # "Exception must not use an f-string literal, assign to variable first"
"PD", # Pandas related
"Q000", # For now
"SIM105", # "Use contextlib.suppress({exception}) instead of try-except-pass"
"TRY003", # "Avoid specifying long messages outside the exception class"
]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false
[tool.ruff.lint.per-file-ignores]
# Allow private member access for Registry
"eav/registry.py" = ["SLF001"]
# Migrations are special
"**/migrations/*" = ["RUF012"]
# Sphinx specific
"docs/source/conf.py" = ["INP001"]
# pytest is even more special
"tests/*" = [
"INP001", # "Add an __init__.py"
"PLR2004", # "Magic value used in comparison"
"PT009", # "Use a regular assert instead of unittest-style"
"PT027", # "Use pytest.raises instead of unittest-style"
"S101", # "Use of assert detected"
"SLF001" # "Private member accessed"
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
Django>=1.11

29
runtests Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env python
import os
import sys
import django
from django.conf import settings
from django.test.utils import get_runner
if __name__ == "__main__":
os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings'
django.setup()
TestRunner = get_runner(settings)
test_runner = TestRunner()
if len(sys.argv) == 1 or sys.argv[1] in ['-a', '--all']:
tests = [
'tests.queries',
'tests.registry',
'tests.data_validation',
'tests.attributes',
'tests.misc_models',
'tests.set_and_get',
'tests.forms'
]
else:
tests = ['tests.{}'.format(arg) for arg in sys.argv[1:]]
result = test_runner.run_tests(tests)
sys.exit(bool(result))

View file

@ -1,75 +0,0 @@
# All configuration for plugins and other utils is defined here.
# Read more about `setup.cfg`:
# https://docs.python.org/3/distutils/configfile.html
[tool:pytest]
# Django options:
# https://pytest-django.readthedocs.io/en/latest/
DJANGO_SETTINGS_MODULE = test_project.settings
# PYTHONPATH configuration:
# https://docs.pytest.org/en/7.0.x/reference/reference.html#confval-pythonpath
pythonpath = ./eav
# py.test options:
norecursedirs =
*.egg
.eggs
dist
build
docs
.tox
.git
__pycache__
# You will need to measure your tests speed with `-n auto` and without it,
# so you can see whether it gives you any performance gain, or just gives
# you an overhead. See `docs/template/development-process.rst`.
addopts =
-p no:randomly
--strict-markers
--strict-config
--doctest-modules
--cov=eav
--cov-report=term-missing:skip-covered
--cov-report=html
--cov-report=xml
--cov-branch
--cov-fail-under=90
[coverage:run]
# Exclude tox output from coverage calculation
omit = */.tox/*
[coverage:report]
skip_covered = True
show_missing = True
sort = Cover
exclude_lines =
pragma: no cover
# type hinting related code
if TYPE_CHECKING:
[mypy]
# mypy configurations: https://bit.ly/2zEl9WI
allow_redefinition = False
check_untyped_defs = True
disallow_any_explicit = True
disallow_any_generics = True
disallow_untyped_calls = True
ignore_errors = False
ignore_missing_imports = True
implicit_reexport = False
strict_optional = True
strict_equality = True
local_partial_types = True
no_implicit_optional = True
warn_no_return = True
warn_unused_ignores = True
warn_redundant_casts = True
warn_unused_configs = True
warn_unreachable = True

25
setup.py Executable file
View file

@ -0,0 +1,25 @@
from setuptools import setup, find_packages
setup(
name = 'django-eav2',
version = __import__('eav').__version__,
license = 'GNU Lesser General Public License (LGPL), Version 3',
requires = ['python (>= 3.5)', 'django (>= 1.11.14)'],
provides = ['eav'],
description = 'Entity-Attribute-Value storage for Django',
url = 'http://github.com/makimo/django-eav2',
packages = find_packages(),
maintainer = 'Iwo Herka',
maintainer_email = 'hi@iwoherka.eu',
classifiers = [
'Development Status :: 3 - Alpha',
'Environment :: Web Environment',
'Framework :: Django',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)',
'Programming Language :: Python',
'Topic :: Database',
'Topic :: Software Development :: Libraries :: Python Modules',
],
)

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class TestAppConfig(AppConfig):
name = "test_project"

View file

@ -1,160 +0,0 @@
import uuid
from django.db import migrations, models
from test_project.models import MAX_CHARFIELD_LEN
class Migration(migrations.Migration):
"""Initial migration for test_project."""
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ExampleMetaclassModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ExampleModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RegisterTestModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Patient",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
("email", models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
(
"example",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.PROTECT,
to="test_project.examplemodel",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="M2MModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
("models", models.ManyToManyField(to="test_project.ExampleModel")),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Encounter",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("num", models.PositiveSmallIntegerField()),
(
"patient",
models.ForeignKey(
on_delete=models.deletion.PROTECT,
to="test_project.patient",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Doctor",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
],
options={
"abstract": False,
},
),
]

View file

@ -1,142 +0,0 @@
import uuid
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
MAX_CHARFIELD_LEN: Final = 254
class TestBase(models.Model):
"""Base class for test models."""
class Meta:
"""Define common options."""
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):
"""Test model using UUID as primary key."""
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",
null=True,
blank=True,
on_delete=models.PROTECT,
)
def __str__(self):
return self.name
def __repr__(self):
return self.name
class Encounter(TestBase):
num = models.PositiveSmallIntegerField()
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
def __str__(self):
return f"{self.patient}: encounter num {self.num}"
def __repr__(self):
return self.name
@register_eav()
@final
class ExampleModel(TestBase):
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
def __str__(self):
return self.name
@register_eav()
@final
class M2MModel(TestBase):
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
models = models.ManyToManyField(ExampleModel)
def __str__(self):
return self.name
@final
class ExampleMetaclassModel(TestBase, metaclass=EAVModelMeta):
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
def __str__(self):
return self.name
@final
class RegisterTestModel(TestBase, metaclass=EAVModelMeta):
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
def __str__(self):
return self.name

View file

@ -1,102 +0,0 @@
from __future__ import annotations
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).parent.parent
# Quick-start development settings - unsuitable for production
# 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
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
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",
# Test Project:
"test_project.apps.TestAppConfig",
# Our app:
"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",
]
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",
],
},
},
]
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = []
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"

89
tests/attributes.py Normal file
View file

@ -0,0 +1,89 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
import sys
import eav
from eav.exceptions import IllegalAssignmentException
from eav.models import Attribute, Value
from eav.registry import EavConfig
from .models import Encounter, Patient
if sys.version_info[0] > 2:
from .metaclass_models3 import RegisterTestModel
else:
from .metaclass_models2 import RegisterTestModel
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'
@classmethod
def get_attributes(cls):
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)
def tearDown(self):
eav.unregister(Encounter)
eav.unregister(Patient)
def test_get_attribute_querysets(self):
self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4)
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)
def test_setting_attributes(self):
p = Patient.objects.create(name='Jon')
e = Encounter.objects.create(patient=p, num=1)
p.eav.age = 3
p.eav.height = 2.3
p.save()
e.eav_field.age = 4
e.save()
self.assertEqual(Value.objects.count(), 3)
t = RegisterTestModel.objects.create(name="test")
t.eav.age = 6
t.eav.height = 10
t.save()
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)
self.assertEqual(e.eav_field.age, 4)
t = RegisterTestModel.objects.get(name="test")
self.assertEqual(t.eav.age, 6)
self.assertEqual(t.eav.height, 10)
def test_illegal_assignemnt(self):
class EncounterEavConfig(EavConfig):
@classmethod
def get_attributes(cls):
return Attribute.objects.filter(datatype=Attribute.TYPE_INT)
eav.unregister(Encounter)
eav.register(Encounter, EncounterEavConfig)
p = Patient.objects.create(name='Jon')
e = Encounter.objects.create(patient=p, num=1)
with self.assertRaises(IllegalAssignmentException):
e.eav.color = 'red'
e.save()

View file

@ -1,96 +1,92 @@
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
import eav
from eav.models import Attribute, EnumGroup, EnumValue, Value
from test_project.models import Patient
from eav.models import Attribute, Value, EnumValue, EnumGroup
from .models import Patient
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)
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,
)
Attribute.objects.create(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,
)
self.assertRaises(
ValidationError,
Patient.objects.create,
name="Joe",
eav__age=5,
)
Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True)
self.assertRaises(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",
)
self.assertRaises(ValidationError,
Patient.objects.create,
name='Joe', eav__age='df')
self.assertEqual(Patient.objects.count(), 0)
self.assertEqual(Value.objects.count(), 0)
def test_bad_slug(self):
a = Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
a.slug = 'Color'
self.assertRaises(ValidationError, a.save)
a.slug = '1st'
self.assertRaises(ValidationError, a.save)
a.slug = '_st'
self.assertRaises(ValidationError, a.save)
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())
@ -104,26 +100,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
@ -131,86 +127,64 @@ 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,
)
Attribute.objects.create(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 = 'yes'
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.eav.extra = 5
self.assertRaises(ValidationError, p.save)
p.eav.extra = {"eyes": "blue", "hair": "brown"}
p.save()
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")
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"],
)

96
tests/forms.py Normal file
View file

@ -0,0 +1,96 @@
from django.test import TestCase
from django.contrib.admin.sites import AdminSite
import eav
import sys
from eav.admin import *
from .models import Patient, M2MModel, ExampleModel
from eav.models import Attribute
from eav.forms import BaseDynamicEntityForm
from django.contrib import admin
from django.core.handlers.base import BaseHandler
from django.test.client import RequestFactory
from django.forms import ModelForm
class MockRequest(RequestFactory):
def request(self, **request):
"Construct a generic request object."
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
class MockSuperUser:
def __init__(self):
self.is_active = True
self.is_staff = True
def has_perm(self, perm):
return True
request = MockRequest().request()
request.user = MockSuperUser()
class PatientForm(ModelForm):
class Meta:
model = Patient
fields = '__all__'
class M2MModelForm(ModelForm):
class Meta:
model = M2MModel
fields = '__all__'
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)
self.instance = Patient.objects.create(name='Jim Morrison')
self.site = AdminSite()
def test_fields(self):
admin = BaseEntityAdmin(Patient, self.site)
admin.form = BaseDynamicEntityForm
view = admin.change_view(request, str(self.instance.pk))
own_fields = 1
adminform = view.context_data['adminform']
self.assertEqual(
len(adminform.form.fields), Attribute.objects.count() + own_fields
)
def test_valid_submit(self):
self.instance.eav.color = 'Blue'
form = PatientForm(self.instance.__dict__, instance=self.instance)
jim = form.save()
self.assertEqual(jim.eav.color, 'Blue')
def test_invalid_submit(self):
form = PatientForm(dict(color='Blue'), instance=self.instance)
with self.assertRaises(ValueError):
jim = form.save()
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)
form.save()
self.assertEqual(len(m2mmodel.models.all()), 1)

View file

@ -0,0 +1,18 @@
from django.db import models
from eav.models import EAVModelMeta
class ExampleMetaclassModel(models.Model):
__metaclass__ = EAVModelMeta
name = models.CharField(max_length=12)
def __unicode__(self):
return self.name
class RegisterTestModel(models.Model):
__metaclass__ = EAVModelMeta
name = models.CharField(max_length=12)
def __unicode__(self):
return self.name

View file

@ -0,0 +1,16 @@
from django.db import models
from eav.models import EAVModelMeta
class ExampleMetaclassModel(models.Model, metaclass=EAVModelMeta):
name = models.CharField(max_length=12)
def __str__(self):
return self.name
class RegisterTestModel(models.Model, metaclass=EAVModelMeta):
name = models.CharField(max_length=12)
def __str__(self):
return self.name

27
tests/misc_models.py Normal file
View file

@ -0,0 +1,27 @@
from django.test import TestCase
from eav.models import EnumGroup, Attribute, Value
import eav
from .models import Patient
class MiscModels(TestCase):
def test_enumgroup_str(self):
name = 'Yes / No'
e = EnumGroup.objects.create(name=name)
self.assertEqual('<EnumGroup Yes / No>', str(e))
def test_attribute_help_text(self):
desc = 'Patient Age'
a = Attribute.objects.create(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)
self.assertEqual(Value.objects.count(), 1)
p.eav.age = None
p.save()
self.assertEqual(Value.objects.count(), 0)

40
tests/models.py Normal file
View file

@ -0,0 +1,40 @@
from django.db import models
from eav.decorators import register_eav
class Patient(models.Model):
name = models.CharField(max_length=12)
def __str__(self):
return self.name
def __repr__(self):
return self.name
class Encounter(models.Model):
num = models.PositiveSmallIntegerField()
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
def __str__(self):
return '%s: encounter num %d' % (self.patient, self.num)
def __repr__(self):
return self.name
@register_eav()
class ExampleModel(models.Model):
name = models.CharField(max_length=12)
def __unicode__(self):
return self.name
@register_eav()
class M2MModel(models.Model):
name = models.CharField(max_length=12)
models = models.ManyToManyField(ExampleModel)
def __unicode__(self):
return self.name

139
tests/queries.py Normal file
View file

@ -0,0 +1,139 @@
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Q
from django.test import TestCase
import eav
from eav.models import Attribute, EnumGroup, EnumValue, Value
from .models import Encounter, Patient
class Queries(TestCase):
def setUp(self):
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)
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.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)
def tearDown(self):
eav.unregister(Encounter)
eav.unregister(Patient)
def test_get_or_create_with_eav(self):
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)
self.assertEqual(Patient.objects.count(), 1)
self.assertEqual(Value.objects.count(), 1)
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_with_eav(self):
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)
self.assertRaises(MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6))
def test_filtering_on_normal_and_eav_fields(self):
yes = self.yes
no = self.no
data = [
# Name, age, fever, city, country.
['Anne', 3, no, 'New York', 'USA'],
['Bob', 15, no, 'Bamako', 'Mali'],
['Cyrill', 15, yes, 'Kisumu', 'Kenya'],
['Daniel', 3, no, 'Nice', 'France'],
['Eugene', 2, yes, 'France', 'Nice']
]
for row in data:
Patient.objects.create(
name=row[0],
eav__age=row[1],
eav__fever=row[2],
eav__city=row[3],
eav__country=row[4]
)
# Check number of objects in DB.
self.assertEqual(Patient.objects.count(), 5)
self.assertEqual(Value.objects.count(), 20)
# Nobody
q1 = Q(eav__fever=yes) & Q(eav__fever=no)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 0)
# Anne, Daniel
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)
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=no)
q2 = Q(eav__city='Nice')
q3 = Q(eav__age=3)
p = Patient.objects.filter((q1 | q2) & q3)
self.assertEqual(p.count(), 2)
# Everyone
q1 = Q(eav__fever=no) | Q(eav__fever=yes)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 5)
# Anne, Bob, Daniel
q1 = Q(eav__fever=no) # Anne, Bob, Daniel
q2 = Q(eav__fever=yes) # Cyrill, 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')
p = Patient.objects.exclude(q1)
self.assertEqual(p.count(), 4)
# Anne, Bob, Daniel
q1 = Q(eav__city__contains='Y')
q2 = Q(eav__fever=no)
q3 = q1 | q2
p = Patient.objects.filter(q3)
self.assertEqual(p.count(), 3)
# Anne, Daniel
q1 = Q(eav__age=3)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 2)
# Eugene
q1 = Q(name__contains='E', eav__fever=yes)
p = Patient.objects.filter(q1)
self.assertEqual(p.count(), 1)

99
tests/registry.py Normal file
View file

@ -0,0 +1,99 @@
from django.test import TestCase
import sys
import eav
from eav.registry import EavConfig
from .models import Encounter, ExampleModel, Patient
if sys.version_info[0] > 2:
from .metaclass_models3 import ExampleMetaclassModel
else:
from .metaclass_models2 import ExampleMetaclassModel
class RegistryTests(TestCase):
def setUp(self):
pass
def tearDown(self):
pass
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'
@classmethod
def get_attributes(cls):
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.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.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(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')
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')
def test_unregistering(self):
old_mgr = Patient.objects
eav.register(Patient)
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
eav.unregister(Patient)
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
self.assertEqual(Patient.objects, old_mgr)
self.assertFalse(hasattr(Patient, '_eav_config_cls'))
def test_unregistering_via_decorator(self):
self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager')
eav.unregister(ExampleModel)
self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager')
def test_unregistering_via_metaclass(self):
self.assertTrue(ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager')
eav.unregister(ExampleMetaclassModel)
self.assertFalse(ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager')
def test_unregistering_unregistered_model_proceeds_silently(self):
eav.unregister(Patient)
def test_double_registering_model_is_harmless(self):
eav.register(Patient)
eav.register(Patient)
def test_doesnt_register_nonmodel(self):
with self.assertRaises(ValueError):
@eav.decorators.register_eav()
class Foo(object):
pass

View file

@ -2,7 +2,8 @@ from django.test import TestCase
import eav
from eav.registry import EavConfig
from test_project.models import Encounter, Patient
from .models import Patient, Encounter
class RegistryTests(TestCase):
@ -14,44 +15,46 @@ 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.generic_relation_related_name, None)
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)

View file

@ -1,183 +0,0 @@
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 import strategies as st
from hypothesis.extra import django
from hypothesis.strategies import just
import eav
from eav.exceptions import IllegalAssignmentException
from eav.models import Attribute, Value
from eav.registry import EavConfig
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
auto_field_strategy = st.text(min_size=1, max_size=255)
else:
auto_field_strategy = st.integers(min_value=1, max_value=32)
class Attributes(TestCase):
def setUp(self):
class EncounterEavConfig(EavConfig):
manager_attr = "eav_objects"
eav_attr = "eav_field"
generic_relation_attr = "encounter_eav_values"
generic_relation_related_name = "encounters"
@classmethod
def get_attributes(cls, instance=None):
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)
def tearDown(self):
eav.unregister(Encounter)
eav.unregister(Patient)
def test_get_attribute_querysets(self):
self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4)
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)
def test_setting_attributes(self):
p = Patient.objects.create(name="Jon")
e = Encounter.objects.create(patient=p, num=1)
p.eav.age = 3
p.eav.height = 2.3
p.save()
e.eav_field.age = 4
e.save()
self.assertEqual(Value.objects.count(), 3)
t = RegisterTestModel.objects.create(name="test")
t.eav.age = 6
t.eav.height = 10
t.save()
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)
self.assertEqual(e.eav_field.age, 4)
t = RegisterTestModel.objects.get(name="test")
self.assertEqual(t.eav.age, 6)
self.assertEqual(t.eav.height, 10)
# Validate repr of Value for an entity with an INT PK
v1 = Value.objects.filter(entity_id=p.pk).first()
assert isinstance(repr(v1), str)
assert isinstance(str(v1), str)
def test_illegal_assignemnt(self):
class EncounterEavConfig(EavConfig):
@classmethod
def get_attributes(cls, instance=None):
return Attribute.objects.filter(datatype=Attribute.TYPE_INT)
eav.unregister(Encounter)
eav.register(Encounter, EncounterEavConfig)
p = Patient.objects.create(name="Jon")
e = Encounter.objects.create(patient=p, num=1)
with self.assertRaises(IllegalAssignmentException):
e.eav.color = "red"
e.save()
def test_uuid_pk(self):
"""Tests for when model pk is UUID."""
expected_age = 10
d1 = Doctor.objects.create(name="Lu")
d1.eav.age = expected_age
d1.save()
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()
assert isinstance(repr(v1), str)
assert isinstance(str(v1), str)
def test_big_integer(self):
"""Tests an integer larger than 32-bit a value."""
big_num = 3147483647
patient = Patient.objects.create(name="Jon")
patient.eav.age = big_num
patient.save()
assert patient.eav.age == big_num
class TestAttributeModel(django.TestCase):
"""This is a property-based test that ensures model correctness."""
@given(
django.from_model(
Attribute,
id=auto_field_strategy,
datatype=just(Attribute.TYPE_TEXT),
enum_group=just(None),
slug=just(None), # Let Attribute.save() handle
),
)
@settings(deadline=None)
def test_model_properties(self, attribute: Attribute) -> None:
"""Tests that instance can be saved and has correct representation."""
attribute.full_clean()
attribute.save()
assert attribute
@given(
st.text(
alphabet=st.sampled_from(string.ascii_letters + string.digits),
min_size=50,
max_size=100,
),
)
def test_large_name_input(self, name_value) -> None:
"""Ensure proper slug is generated from large name fields."""
instance = Attribute.objects.create(
name=name_value,
datatype=Attribute.TYPE_TEXT,
enum_group=None,
)
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

@ -1,251 +0,0 @@
import pytest
from django.contrib.admin.sites import AdminSite
from django.core.handlers.base import BaseHandler
from django.forms import ModelForm
from django.test import TestCase
from django.test.client import RequestFactory
import eav
from eav.admin import BaseEntityAdmin
from eav.forms import BaseDynamicEntityForm
from eav.models import Attribute, EnumGroup, EnumValue
from test_project.models import ExampleModel, M2MModel, Patient
class MockRequest(RequestFactory):
def request(self, **request):
"Construct a generic request object."
request = RequestFactory.request(self, **request)
handler = BaseHandler()
handler.load_middleware()
return request
class MockSuperUser:
def __init__(self):
self.is_active = True
self.is_staff = True
def has_perm(self, perm):
return True
request = MockRequest().request()
request.user = MockSuperUser()
class PatientForm(ModelForm):
class Meta:
model = Patient
fields = ("name", "email", "example")
class PatientDynamicForm(BaseDynamicEntityForm):
class Meta:
model = Patient
fields = ("name", "email", "example")
class M2MModelForm(ModelForm):
class Meta:
model = M2MModel
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)
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,
)
self.instance = Patient.objects.create(name="Jim Morrison")
def test_valid_submit(self):
self.instance.eav.color = "Blue"
form = PatientForm(self.instance.__dict__, instance=self.instance)
jim = form.save()
self.assertEqual(jim.eav.color, "Blue")
def test_invalid_submit(self):
form = PatientForm({"color": "Blue"}, instance=self.instance)
with self.assertRaises(ValueError):
form.save()
def test_valid_enums(self):
self.instance.eav.gender = self.female
form = PatientForm(self.instance.__dict__, instance=self.instance)
rose = form.save()
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({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel)
form.save()
self.assertEqual(len(m2mmodel.models.all()), 1)
@pytest.fixture
def patient() -> Patient:
"""Return an eav enabled Patient instance."""
eav.register(Patient)
return Patient.objects.create(name="Jim Morrison")
@pytest.fixture
def create_attributes() -> None:
"""Create some Attributes to use for testing."""
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
@pytest.mark.django_db
@pytest.mark.parametrize(
("csv_data", "separator"),
[
("", ";"),
("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)
patient.eav.csv = csv_data
patient.save()
patient.refresh_from_db()
form = PatientDynamicForm(
patient.__dict__,
instance=patient,
)
form.fields["csv"].separator = separator
assert form.is_valid()
jim = form.save()
expected_result = str(csv_data).split(separator) if csv_data else []
assert jim.eav.csv == expected_result
@pytest.mark.django_db
def test_csvdynamicform_empty(patient) -> None:
"""Test to ensure an instance with no eav values is correct."""
form = PatientDynamicForm(
patient.__dict__,
instance=patient,
)
assert form.is_valid()
assert form.save()
@pytest.mark.django_db
@pytest.mark.usefixtures("create_attributes")
@pytest.mark.parametrize("define_fieldsets", [True, False])
def test_entity_admin_form(patient, define_fieldsets):
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
admin = BaseEntityAdmin(Patient, AdminSite())
admin.readonly_fields = ("email",)
admin.form = BaseDynamicEntityForm
expected_fieldsets = 2
if define_fieldsets:
# Use all fields in Patient model
admin.fieldsets = (
(None, {"fields": ["name", "example"]}),
("Contact Info", {"fields": ["email"]}),
)
expected_fieldsets = 3
view = admin.change_view(request, str(patient.pk))
adminform = view.context_data["adminform"]
# Count the total fields in fieldsets
total_fields = sum(
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
)
# 3 for 'name', 'email', 'example'
expected_fields_count = Attribute.objects.count() + 3
assert total_fields == expected_fields_count
# Ensure our fieldset count is correct
assert len(adminform.fieldsets) == expected_fieldsets
@pytest.mark.django_db
def test_entity_admin_form_no_attributes(patient):
"""Test the BaseEntityAdmin form with no Attributes created."""
admin = BaseEntityAdmin(Patient, AdminSite())
admin.readonly_fields = ("email",)
admin.form = BaseDynamicEntityForm
# Only fields defined in Patient model
expected_fields = 3
view = admin.change_view(request, str(patient.pk))
adminform = view.context_data["adminform"]
# Count the total fields in fieldsets
total_fields = sum(
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
)
# 3 for 'name', 'email', 'example'
assert total_fields == expected_fields
@pytest.mark.django_db
def test_dynamic_form_renders_enum_choices():
"""
Test that enum choices render correctly in BaseDynamicEntityForm.
This test verifies the fix for issue #648 where enum choices weren't
rendering correctly in Django 4.2.17 due to QuerySet unpacking issues.
"""
# Setup
eav.register(Patient)
# Create enum values and group
female = EnumValue.objects.create(value="Female")
male = EnumValue.objects.create(value="Male")
gender_group = EnumGroup.objects.create(name="Gender")
gender_group.values.add(female, male)
Attribute.objects.create(
name="gender",
datatype=Attribute.TYPE_ENUM,
enum_group=gender_group,
)
# Create a patient
patient = Patient.objects.create(name="Test Patient")
# Initialize the dynamic form
form = PatientDynamicForm(instance=patient)
# Test rendering - should not raise any exceptions
rendered_form = form.as_p()
# Verify the form rendered and contains the enum choices
assert 'name="gender"' in rendered_form
assert f'value="{female.pk}">{female.value}' in rendered_form
assert f'value="{male.pk}">{male.value}' in rendered_form

View file

@ -1,76 +0,0 @@
import pytest
from hypothesis import given
from hypothesis import strategies as st
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
@given(st.text())
def test_generate_slug(name: str) -> None:
"""Ensures slug generation works properly."""
slug = generate_slug(name)
assert slug
@given(st.text(min_size=SLUGFIELD_MAX_LENGTH))
def test_generate_long_slug_text(name: str) -> None:
"""Ensures a slug isn't generated longer than maximum allowed length."""
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

@ -1,70 +0,0 @@
import pytest
from django.test import TestCase
import eav
from eav.models import Attribute, EnumGroup, EnumValue, Value
from test_project.models import Patient
@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.values.add(value_yes)
test_group.values.add(value_no)
return test_group
def test_enumgroup_display(enumgroup):
"""Test repr() and str() of 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 f"<EnumValue {test_value.value}>" == repr(test_value)
assert str(test_value) == test_value.value
class MiscModels(TestCase):
"""Miscellaneous tests on models."""
def test_attribute_help_text(self):
desc = "Patient Age"
a = Attribute.objects.create(
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)
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")
ynu.values.add(yes)
ynu.values.add(no)
Attribute.objects.create(
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.save()
p = Patient.objects.get(name="Joe") # get from DB again
self.assertEqual(p.eav.is_patient, yes)

View file

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

View file

@ -1,32 +0,0 @@
import uuid
from django.db import models
from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format
def test_get_uuid_primary_key(settings) -> None:
settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField"
primary_field = get_pk_format()
assert isinstance(primary_field, models.UUIDField)
assert primary_field.primary_key
assert not primary_field.editable
assert primary_field.default == uuid.uuid4
def test_get_char_primary_key(settings) -> None:
settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField"
primary_field = get_pk_format()
assert isinstance(primary_field, models.CharField)
assert primary_field.primary_key
assert not primary_field.editable
assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN
def test_get_default_primary_key(settings) -> None:
# This test covers the default case for "BigAutoField"
settings.EAV2_PRIMARY_KEY_FIELD = "AnyOtherField"
primary_field = get_pk_format()
assert isinstance(primary_field, models.BigAutoField)
assert primary_field.primary_key
assert not primary_field.editable

View file

@ -1,409 +0,0 @@
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
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, ExampleModel, Patient
class Queries(TestCase):
def setUp(self):
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)
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.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,
)
def tearDown(self):
eav.unregister(Encounter)
eav.unregister(Patient)
def init_data(self) -> None:
yes = self.yes
no = self.no
data = [
# Name, age, fever,
# city, country, extras
# possible illness
["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"],
["Bob", 15, no, "Bamako", "Mali", {}, ""],
[
"Cyrill",
15,
yes,
"Kisumu",
"Kenya",
{"chills": "yes", "headache": "no"},
"flu",
],
["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"],
[
"Eugene",
2,
yes,
"France",
"Nice",
{"chills": "no", "headache": "yes"},
"flu;cold",
],
]
for row in data:
Patient.objects.create(
name=row[0],
eav__age=row[1],
eav__fever=row[2],
eav__city=row[3],
eav__country=row[4],
eav__extras=row[5],
eav__illness=row[6],
)
def test_get_or_create_with_eav(self):
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)
self.assertEqual(Patient.objects.count(), 1)
self.assertEqual(Value.objects.count(), 1)
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"
p1, _ = Patient.objects.get_or_create(
name="Mari",
eav__age=27,
defaults={
"email": email,
"eav__city": city_name,
},
)
assert Patient.objects.count() == 1
assert p1.email == email
assert p1.eav.city == city_name
def test_get_with_eav(self):
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)
self.assertRaises(
MultipleObjectsReturned,
lambda: Patient.objects.get(eav__age=6),
)
def test_no_results_for_contradictory_conditions(self) -> None:
"""Test that contradictory conditions return no results."""
self.init_data()
q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no)
p = Patient.objects.filter(q1)
# 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)
# 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)
# 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)
# 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)
# 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)
# 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
q4 = q2 & q3 # Cyrill, Daniel, Eugene
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
p = Patient.objects.filter(q5)
# 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)
# 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)
# 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)
# 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)
# 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)
# Should return patients without 'chills' in extras
assert p.count() == 2
q1 = Q(eav__extras__has_key="chills")
p = Patient.objects.filter(q1)
# Should return patients with 'chills' in extras
assert p.count() == 3
q1 = Q(eav__extras__chills="no")
p = Patient.objects.filter(q1)
# Should return patients with 'chills' set to 'no'
assert p.count() == 1
q1 = Q(eav__extras__chills="yes")
p = Patient.objects.filter(q1)
# 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)
# Should return patients with empty extras
assert p.count() == 1
q1 = Q(eav__extras={})
p = Patient.objects.exclude(q1)
# 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)
# Should return patients without 'cold' in illness
assert p.count() == 2
q1 = Q(eav__illness__icontains="flu")
p = Patient.objects.exclude(q1)
# 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)
# 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))
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",
]
# Ordering by multiple EAV attributes
assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [
"Eugene",
"Anne",
"Daniel",
"Bob",
"Cyrill",
]
# Ordering by EAV attributes with different data types
assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [
"Eugene",
"Cyrill",
"Anne",
"Daniel",
"Bob",
]
# Combining EAV and regular model field ordering
assert self._order([f"{eav_attr}__fever", "-name"]) == [
"Eugene",
"Cyrill",
"Daniel",
"Bob",
"Anne",
]
# Mixing regular and EAV field ordering
assert self._order(["-name", f"{eav_attr}__age"]) == [
"Eugene",
"Daniel",
"Cyrill",
"Bob",
"Anne",
]
# Ordering by a related model field
assert self._order(["example__name"]) == [
"Anne",
"Bob",
"Cyrill",
"Daniel",
"Eugene",
]
# Error handling for unsupported nested EAV attributes
with pytest.raises(NotSupportedError):
Patient.objects.all().order_by(f"{eav_attr}__first__second")
# 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()
self.assert_order_by_results()
def test_order_by_with_custom_config(self):
class CustomConfig(EavConfig):
eav_attr = "data"
generic_relation_attr = "data_values"
self.init_data()
eav.unregister(Patient)
eav.register(Patient, config_cls=CustomConfig)
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]
c = ExampleModel.objects.filter(patient=p)
self.assertEqual(c.count(), 1)
def test_filter_with_multiple_eav_attributes(self):
"""
Test filtering a model using both regular and multiple EAV attributes.
This test initializes test data and then filters the Patient test model
based on a combination of a regular attribute and multiple EAV attributes.
"""
self.init_data()
# Use the filter method with 3 EAV attribute conditions
patients = Patient.objects.filter(
name="Anne",
eav__age=3,
eav__illness="cold",
eav__fever="no",
)
# Assert that the expected patient is returned
self.assertEqual(len(patients), 1)
self.assertEqual(patients[0].name, "Anne")

View file

@ -1,136 +0,0 @@
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,
Patient,
)
class RegistryTests(TestCase):
def setUp(self):
pass
def tearDown(self):
pass
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"
@classmethod
def get_attributes(cls):
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.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.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(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")
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")
def test_unregistering(self):
old_mgr = Patient.objects
eav.register(Patient)
self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager")
eav.unregister(Patient)
self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager")
self.assertEqual(Patient.objects, old_mgr)
self.assertFalse(hasattr(Patient, "_eav_config_cls"))
def test_unregistering_via_decorator(self):
self.assertTrue(ExampleModel.objects.__class__.__name__ == "EntityManager")
eav.unregister(ExampleModel)
self.assertFalse(ExampleModel.objects.__class__.__name__ == "EntityManager")
def test_unregistering_via_metaclass(self):
self.assertTrue(
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
)
eav.unregister(ExampleMetaclassModel)
self.assertFalse(
ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager",
)
def test_unregistering_unregistered_model_proceeds_silently(self):
eav.unregister(Patient)
def test_double_registering_model_is_harmless(self):
eav.register(Patient)
eav.register(Patient)
def test_doesnt_register_nonmodel(self):
with self.assertRaises(TypeError):
@eav.decorators.register_eav()
class Foo:
pass
def test_model_without_local_managers(self):
"""Test when a model doesn't have local_managers."""
# Check just in case test model changes in the future
assert bool(User._meta.local_managers) is False
eav.register(User)
assert isinstance(User.objects, eav.managers.EntityManager)
# 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

23
tests/test_settings.py Normal file
View file

@ -0,0 +1,23 @@
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = 'fake-key'
SITE_ID = 1
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.sites',
'django.contrib.admin',
'django.contrib.contenttypes',
"tests",
"eav"
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'TEST_NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}

Some files were not shown because too many files have changed in this diff Show more