Skip to content

Commit

Permalink
Merge branch 'jazzband:master' into master
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiasag7 authored Mar 8, 2024
2 parents 2f1957f + 792fc65 commit ae01a4c
Show file tree
Hide file tree
Showing 17 changed files with 699 additions and 244 deletions.
21 changes: 15 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
name: test

"on": [push, pull_request, workflow_dispatch]
Expand All @@ -7,9 +8,17 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
django-version: ["3.2", "4.1", "4.2"]

python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["3.2", "4.2", "5.0"]
exclude:
- django-version: "3.2"
python-version: "3.11"
- django-version: "3.2"
python-version: "3.12"
- django-version: "5.0"
python-version: "3.8"
- django-version: "5.0"
python-version: "3.9"
steps:
- uses: actions/checkout@v4

Expand All @@ -26,16 +35,16 @@ jobs:
installer-parallel: true

- name: Set up cache
uses: actions/cache@v3
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 -U pip
poetry run pip install -U "django==${{ matrix.django-version }}.*"
poetry run pip install --upgrade pip
poetry run pip install --upgrade "django==${{ matrix.django-version }}.*"
- name: Run tests
run: |
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
- id: mixed-line-ending

- repo: https://github.com/psf/black
rev: 23.12.0
rev: 24.2.0
hooks:
- id: black
language_version: python3
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` rele
## {{ Next Version }}

### Bug Fixes

- Corrects `BaseEntityAdmin` integration into Django Admin site

### Features

## 1.5.1 (2023-12-04)
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ You will find detailed description of the EAV here:

## 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 maintainance 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.
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?

Expand All @@ -55,7 +55,7 @@ As a rule of thumb, EAV can be used when:
- 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 appriopriate use-cases see:
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)
Expand Down Expand Up @@ -106,7 +106,7 @@ INSTALLED_APPS = [
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 exemple
EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" # as example
```

### Note: Primary key mandatory modification field
Expand All @@ -131,7 +131,7 @@ If the primary key of eav models are to be modified (UUIDField -> BigAutoField,
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 exemple
EAV2_PRIMARY_KEY_FIELD = "django.db.models.BigAutoField" # as example
```

Run again the migrations.
Expand Down
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'eav',
],
SECRET_KEY=os.environ.get('DJANGO_SECRET_KEY', 'this-is-not-s3cur3'),
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
)

# Call django.setup to load installed apps and other stuff.
Expand Down
18 changes: 14 additions & 4 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ or with decorators:
class Supplier(models.Model):
...
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
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
model definition.

Advanced Registration
Expand Down Expand Up @@ -286,8 +286,10 @@ You can use ``Q`` expressions too:
Admin Integration
-----------------
Django EAV 2 includes integration for Django's admin. As usual, you need to
register your model first:
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.
.. code-block:: python
Expand All @@ -302,3 +304,11 @@ register your model first:
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.
105 changes: 89 additions & 16 deletions eav/admin.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,109 @@
"""This module contains classes used for admin integration."""

from typing import Any, Dict, List, Optional, 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

_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]


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: Optional[str] = None

def render_change_form(self, request, context, *args, **kwargs):
"""
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.
"""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
media = context["media"]

# 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(media + adminform.media)

# Identify EAV fields based on the form instance's configuration.
eav_fields = self._get_eav_fields(form.instance)

# # 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)
context.update(adminform=adminform, media=media)

return super(BaseEntityAdmin, self).render_change_form(
request, context, *args, **kwargs
)
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)
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},
]


class BaseEntityInlineFormSet(BaseInlineFormSet):
Expand Down
6 changes: 1 addition & 5 deletions eav/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,14 @@
Field,
FloatField,
IntegerField,
JSONField,
ModelForm,
SplitDateTimeField,
)
from django.utils.translation import gettext_lazy as _

from eav.widgets import CSVWidget

try:
from django.forms import JSONField
except:
JSONField = CharField


class CSVFormField(Field):
message = _('Enter comma-separated-values. eg: one;two;three.')
Expand Down
6 changes: 1 addition & 5 deletions eav/logic/slug.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import secrets
import string
from typing import Final

from django.utils.text import slugify

try:
from typing import Final
except ImportError:
from typing_extensions import Final

SLUGFIELD_MAX_LENGTH: Final = 50


Expand Down
2 changes: 1 addition & 1 deletion eav/models/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class Meta:
)

"""
Main identifer for the attribute.
Main identifier for the attribute.
Upon creation, slug is autogenerated from the name.
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
"""
Expand Down
2 changes: 1 addition & 1 deletion eav/models/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def __init__(self, instance) -> None:

def __getattr__(self, name):
"""
Tha magic getattr helper. This is called whenever user invokes::
The magic getattr helper. This is called whenever user invokes::
instance.<attribute>
Expand Down
8 changes: 4 additions & 4 deletions eav/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def rewrite_q_expr(model_cls, expr):
IGNORE
This is done by merging dangerous AND's and substituting them with
explicit ``pk__in`` filter, where pks are taken from evaluted
explicit ``pk__in`` filter, where pks are taken from evaluated
Q-expr branch.
Args:
Expand All @@ -100,7 +100,7 @@ def rewrite_q_expr(model_cls, expr):
config_cls = getattr(model_cls, '_eav_config_cls', None)
gr_name = config_cls.generic_relation_attr

# Recurively check child nodes.
# Recursively 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)]
Expand Down Expand Up @@ -316,7 +316,7 @@ def order_by(self, *fields):
)
.order_by(
# Order values by their value-field of
# appriopriate attribute data-type.
# appropriate attribute data-type.
field_name
)
.values_list(
Expand All @@ -327,7 +327,7 @@ def order_by(self, *fields):
)
)

# Retrive ordered values from pk-value list.
# Retrieve ordered values from pk-value list.
_, ordered_values = zip(*pks_values)

# Add explicit ordering and turn
Expand Down
2 changes: 1 addition & 1 deletion eav/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class EavConfig(object):
"""
The default ``EavConfig`` class used if it is not overriden on registration.
The default ``EavConfig`` class used if it is not overridden on registration.
This is where all the default eav attribute names are defined.
Available options are as follows:
Expand Down
6 changes: 1 addition & 5 deletions eav/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
try:
from typing import Final
except ImportError:
from typing_extensions import Final # noqa: UP035

from typing import Final

CHARFIELD_LENGTH: Final = 100
Loading

0 comments on commit ae01a4c

Please sign in to comment.