diff --git a/README.md b/README.md index 0325978..fdd417e 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ A collection of fearures used in our Django-based web applications [Changelog](CHANGELOG.md) -## Installation +# Installation ``` bash pip install ftw-django-features ``` -## Usage +# Usage Add desired app to `INSTALLED_APPS` in your Django project. @@ -19,7 +19,7 @@ django_features.system_message django_features.custom_fields ``` -## Configuration +# Configuration If you want to use `django_features`, your base configuration class should inherit from `django_features.settings.BaseConfiguration`. @@ -31,7 +31,7 @@ class Base(BaseConfiguration): ... ``` -### Custom Fields +## Custom Fields To use all features of the `django_features.custom_fields` app, the following steps are required: @@ -41,24 +41,32 @@ Add the `django_features.custom_fields.routers.custom_field_router` to your `ROO path("api/", include(custom_field_router.urls)), ``` -#### Models +### Create your own custom field and value models -Your models should inherit from `django_features.custom_fields.models.CustomFieldBaseModel`. +1. You need to create a custom field model and a custom value model. +2. Your custom field model should inherit from `django_features.custom_fields.models.field.AbstractBaseCustomField`. +3. Your custom value model should inherit from `django_features.custom_fields.models.value.AbstractBaseCustomValue`. -#### Swappable +### Configuration -You can swap the models used by the `django_features.custom_fields` app by setting the `CUSTOM_FIELD_MODEL` or `CUSTOM_FIELD_VALUE_MODEL` setting. -The swapped models should inherit from `django_features.custom_fields.models.field.AbstractBaseCustomField` or `django_features.custom_fields.models.value.AbstractBaseCustomValue`. +- You can configure the models used by the `django_features.custom_fields` app by setting the `CUSTOM_FIELD_MODEL` or `CUSTOM_FIELD_VALUE_MODEL` setting. +- The swapped models should inherit from `django_features.custom_fields.models.field.AbstractBaseCustomField` or `django_features.custom_fields.models.value.AbstractBaseCustomValue`. + +### Models with custom values + +1. Your models with custom values should inherit from `django_features.custom_fields.models.CustomFieldBaseModel`. +2. Your models should have a relation to the custom value model. For example: + - `custom_values = models.ManyToManyField(blank=True, to=CustomValue, verbose_name=_("Benutzerdefinierte Werte"))` #### Querysets -Your querysets should inherit from `django_features.custom_fields.models.CustomFieldModelBaseManager`. +Your querysets for the models with custom values should inherit from `django_features.custom_fields.models.CustomFieldModelBaseManager`. #### Serializers -Your serializers should inherit from `django_features.custom_fields.serializers.CustomFieldBaseModelSerializer`. +Your serializers for the models with custom values should inherit from `django_features.custom_fields.serializers.CustomFieldBaseModelSerializer`. -### System Message +## System Message If you want to use `django_features.system_message`, your base configuration class should inherit from `django_features.system_message.settings.SystemMessageConfigurationMixin`. @@ -85,7 +93,7 @@ Add the `django_features.system_message.routers.system_message_router` to your ` path("api/", include(system_message_router.urls)), ``` -## Development +# Development Installing dependencies, assuming you have poetry installed: @@ -93,7 +101,7 @@ Installing dependencies, assuming you have poetry installed: poetry install ``` -## Release +# Release This package uses towncrier to manage the changelog, and to introduce new changes, a file with a concise title and a brief explanation of what the change accomplishes should be created in the `changes` directory, with a suffix indicating whether the change is a feature, bugfix, or other. diff --git a/app/tests/custom_fields/__init__.py b/app/custom_field/__init__.py similarity index 100% rename from app/tests/custom_fields/__init__.py rename to app/custom_field/__init__.py diff --git a/app/custom_field/admin.py b/app/custom_field/admin.py new file mode 100644 index 0000000..0976b6b --- /dev/null +++ b/app/custom_field/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from modeltranslation.admin import TranslationAdmin + +from app.custom_field import models +from django_features.custom_fields.admin import BaseAdmin +from django_features.custom_fields.admin import CustomFieldBaseAdmin + + +@admin.register(models.CustomField) +class CustomFieldAdmin(BaseAdmin, CustomFieldBaseAdmin, TranslationAdmin): + list_display = ["id", "identifier", "__str__", "field_type", "filterable"] + list_display_links = ( + "id", + "identifier", + "__str__", + ) + list_filter = ( + "choice_field", + "content_type", + "editable", + "field_type", + "filterable", + ) + search_fields = ("label", "identifier") + + +@admin.register(models.CustomValue) +class ValueAdmin(BaseAdmin, TranslationAdmin): + list_display = ["id", "__str__"] + search_fields = ("label", "value", "field__label", "field__identifier") diff --git a/app/custom_field/apps.py b/app/custom_field/apps.py new file mode 100644 index 0000000..10059b9 --- /dev/null +++ b/app/custom_field/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CustomFieldsConfig(AppConfig): + name = "app.custom_field" + label = "custom_field" diff --git a/app/custom_field/migrations/0001_inherit_from_custom_base_model.py b/app/custom_field/migrations/0001_inherit_from_custom_base_model.py new file mode 100644 index 0000000..f67b03d --- /dev/null +++ b/app/custom_field/migrations/0001_inherit_from_custom_base_model.py @@ -0,0 +1,200 @@ +# Generated by Django 4.2.23 on 2026-02-24 14:37 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="CustomField", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "allow_blank", + models.BooleanField( + default=True, verbose_name="Leeren String erlauben" + ), + ), + ( + "allow_null", + models.BooleanField( + default=True, verbose_name="Leere Werte erlauben" + ), + ), + ( + "choice_field", + models.BooleanField(default=False, verbose_name="Auswahlfeld"), + ), + ( + "default", + models.JSONField( + blank=True, null=True, verbose_name="Standardwert" + ), + ), + ( + "editable", + models.BooleanField(default=True, verbose_name="Editierbar"), + ), + ( + "external_key", + models.CharField( + blank=True, null=True, verbose_name="Externer Key" + ), + ), + ( + "field_type", + models.CharField( + choices=[ + ("CHAR", "Text (einzeilig)"), + ("TEXT", "Text (mehrzeilig)"), + ("DATE", "Datum"), + ("DATETIME", "Datum und Zeit"), + ("INTEGER", "Zahl (Ganzzahl)"), + ("BOOLEAN", "Checkbox"), + ], + verbose_name="Feldtyp", + ), + ), + ( + "hidden", + models.BooleanField(default=False, verbose_name="Ausblenden"), + ), + ("identifier", models.SlugField(max_length=64, unique=True)), + ( + "filterable", + models.BooleanField( + default=False, verbose_name="Als Filter anbieten" + ), + ), + ("label", models.CharField(verbose_name="Name")), + ("label_de", models.CharField(null=True, verbose_name="Name")), + ("label_en", models.CharField(null=True, verbose_name="Name")), + ("label_fr", models.CharField(null=True, verbose_name="Name")), + ("multiple", models.BooleanField(default=False, verbose_name="Liste")), + ( + "order", + models.PositiveSmallIntegerField( + default=0, verbose_name="Reihenfolge" + ), + ), + ( + "required", + models.BooleanField(default=False, verbose_name="Erforderlich"), + ), + ("type_id", models.PositiveIntegerField(blank=True, null=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "type_content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="customfield_set_for_type", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "verbose_name": "Benutzerdefiniertes Feld", + "verbose_name_plural": "Benutzerdefinierte Felder", + "ordering": ["order", "created"], + }, + ), + migrations.CreateModel( + name="CustomValue", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "order", + models.PositiveIntegerField(default=0, verbose_name="Reihenfolge"), + ), + ( + "label", + models.CharField(blank=True, null=True, verbose_name="Label"), + ), + ( + "label_de", + models.CharField(blank=True, null=True, verbose_name="Label"), + ), + ( + "label_en", + models.CharField(blank=True, null=True, verbose_name="Label"), + ), + ( + "label_fr", + models.CharField(blank=True, null=True, verbose_name="Label"), + ), + ("value", models.JSONField(blank=True, null=True, verbose_name="Wert")), + ( + "field", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="values", + to="custom_field.customfield", + verbose_name="Feld", + ), + ), + ], + options={ + "verbose_name": "Benutzerdefinierter Wert", + "verbose_name_plural": "Benutzerdefinierte Werte", + "ordering": ["order", "created"], + }, + ), + ] diff --git a/app/custom_field/migrations/__init__.py b/app/custom_field/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/custom_field/migrations/max_migration.txt b/app/custom_field/migrations/max_migration.txt new file mode 100644 index 0000000..0ccc4f9 --- /dev/null +++ b/app/custom_field/migrations/max_migration.txt @@ -0,0 +1 @@ +0001_inherit_from_custom_base_model diff --git a/app/custom_field/models/__init__.py b/app/custom_field/models/__init__.py new file mode 100644 index 0000000..c6aa807 --- /dev/null +++ b/app/custom_field/models/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["CustomField", "CustomValue"] + + +from .field import CustomField +from .value import CustomValue diff --git a/app/custom_field/models/base.py b/app/custom_field/models/base.py new file mode 100644 index 0000000..fc75c8a --- /dev/null +++ b/app/custom_field/models/base.py @@ -0,0 +1,30 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from app.custom_field.models import CustomField +from app.custom_field.models import CustomValue +from django_features.custom_fields.models import CustomFieldBaseModel +from django_features.custom_fields.models import CustomFieldTypeBaseModel + + +class CustomTypeBaseModel(CustomFieldTypeBaseModel): + custom_fields = GenericRelation( + CustomField, + object_id_field="type_id", + content_type_field="type_content_type", + ) + + class Meta: + abstract = True + + +class CustomBaseModel(CustomFieldBaseModel): + custom_values = models.ManyToManyField( + blank=True, + to=CustomValue, + verbose_name=_("Benutzerdefinierte Werte"), + ) + + class Meta: + abstract = True diff --git a/app/custom_field/models/field.py b/app/custom_field/models/field.py new file mode 100644 index 0000000..50e2401 --- /dev/null +++ b/app/custom_field/models/field.py @@ -0,0 +1,10 @@ +from django.utils.translation import gettext_lazy as _ + +from django_features.custom_fields.models.field import AbstractBaseCustomField + + +class CustomField(AbstractBaseCustomField): + class Meta: + verbose_name = _("Benutzerdefiniertes Feld") + verbose_name_plural = _("Benutzerdefinierte Felder") + ordering = ["order", "created"] diff --git a/app/custom_field/models/value.py b/app/custom_field/models/value.py new file mode 100644 index 0000000..1f4026b --- /dev/null +++ b/app/custom_field/models/value.py @@ -0,0 +1,19 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from app.custom_field.models.field import CustomField +from django_features.custom_fields.models.value import AbstractBaseCustomValue + + +class CustomValue(AbstractBaseCustomValue): + field = models.ForeignKey( + CustomField, + related_name="values", + verbose_name=_("Feld"), + on_delete=models.CASCADE, + ) + + class Meta: + ordering = ["order", "created"] + verbose_name = _("Benutzerdefinierter Wert") + verbose_name_plural = _("Benutzerdefinierte Werte") diff --git a/app/custom_field/tests/__init__.py b/app/custom_field/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_features/custom_fields/factories.py b/app/custom_field/tests/factories.py similarity index 97% rename from django_features/custom_fields/factories.py rename to app/custom_field/tests/factories.py index 62e414c..c858bdc 100644 --- a/django_features/custom_fields/factories.py +++ b/app/custom_field/tests/factories.py @@ -3,7 +3,7 @@ from factory import SubFactory # type: ignore from factory.django import DjangoModelFactory -from django_features.custom_fields import models +from app.custom_field import models class CustomFieldFactory(DjangoModelFactory): diff --git a/app/tests/custom_fields/test_custom_field_base_manager.py b/app/custom_field/tests/test_custom_field_base_manager.py similarity index 97% rename from app/tests/custom_fields/test_custom_field_base_manager.py rename to app/custom_field/tests/test_custom_field_base_manager.py index 2129f80..5551cb7 100644 --- a/app/tests/custom_fields/test_custom_field_base_manager.py +++ b/app/custom_field/tests/test_custom_field_base_manager.py @@ -4,14 +4,14 @@ from django.contrib.contenttypes.models import ContentType +from app.custom_field.models import CustomField +from app.custom_field.tests.factories import CustomFieldFactory +from app.custom_field.tests.factories import CustomValueFactory from app.models import Person from app.models import PersonType from app.tests import APITestCase from app.tests.factories import PersonFactory from app.tests.factories import PersonTypeFactory -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.factories import CustomValueFactory -from django_features.custom_fields.models import CustomField class CustomFieldBaseModelManagerTest(APITestCase): diff --git a/app/tests/custom_fields/test_custom_field_base_model.py b/app/custom_field/tests/test_custom_field_base_model.py similarity index 98% rename from app/tests/custom_fields/test_custom_field_base_model.py rename to app/custom_field/tests/test_custom_field_base_model.py index 5ab5bf5..911aa6e 100644 --- a/app/tests/custom_fields/test_custom_field_base_model.py +++ b/app/custom_field/tests/test_custom_field_base_model.py @@ -4,15 +4,15 @@ from django.contrib.contenttypes.models import ContentType +from app.custom_field.models import CustomField +from app.custom_field.models import CustomValue +from app.custom_field.tests.factories import CustomFieldFactory +from app.custom_field.tests.factories import CustomValueFactory from app.models import Person from app.models import PersonType from app.tests import APITestCase from app.tests.factories import PersonFactory from app.tests.factories import PersonTypeFactory -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.factories import CustomValueFactory -from django_features.custom_fields.models import CustomField -from django_features.custom_fields.models import CustomValue class CustomFieldBaseModelTest(APITestCase): diff --git a/app/tests/custom_fields/test_custom_field_model_base_serializer.py b/app/custom_field/tests/test_custom_field_model_base_serializer.py similarity index 98% rename from app/tests/custom_fields/test_custom_field_model_base_serializer.py rename to app/custom_field/tests/test_custom_field_model_base_serializer.py index 61c8b2b..c6fcfc3 100644 --- a/app/tests/custom_fields/test_custom_field_model_base_serializer.py +++ b/app/custom_field/tests/test_custom_field_model_base_serializer.py @@ -5,14 +5,14 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.exceptions import ValidationError +from app.custom_field.models import CustomField +from app.custom_field.models import CustomValue +from app.custom_field.tests.factories import CustomFieldFactory +from app.custom_field.tests.factories import CustomValueFactory from app.models import Person from app.serializers.person import PersonSerializer from app.tests import APITestCase from app.tests.factories import PersonFactory -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.factories import CustomValueFactory -from django_features.custom_fields.models import CustomField -from django_features.custom_fields.models import CustomValue class CustomFieldBaseModelSerializerTest(APITestCase): diff --git a/app/tests/custom_fields/test_custom_field_serializer.py b/app/custom_field/tests/test_custom_field_serializer.py similarity index 93% rename from app/tests/custom_fields/test_custom_field_serializer.py rename to app/custom_field/tests/test_custom_field_serializer.py index 3eea233..e6d2981 100644 --- a/app/tests/custom_fields/test_custom_field_serializer.py +++ b/app/custom_field/tests/test_custom_field_serializer.py @@ -2,11 +2,11 @@ from django.contrib.contenttypes.models import ContentType +from app.custom_field.models import CustomField +from app.custom_field.tests.factories import CustomFieldFactory +from app.custom_field.tests.factories import CustomValueFactory from app.models import Person from app.tests import APITestCase -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.factories import CustomValueFactory -from django_features.custom_fields.models import CustomField from django_features.custom_fields.serializers import CustomFieldSerializer diff --git a/app/tests/custom_fields/test_custom_field_viewset.py b/app/custom_field/tests/test_custom_field_viewset.py similarity index 95% rename from app/tests/custom_fields/test_custom_field_viewset.py rename to app/custom_field/tests/test_custom_field_viewset.py index 37d6992..aca1c1f 100644 --- a/app/tests/custom_fields/test_custom_field_viewset.py +++ b/app/custom_field/tests/test_custom_field_viewset.py @@ -1,10 +1,10 @@ from django.contrib.contenttypes.models import ContentType from pluck import pluck +from app.custom_field.tests.factories import CustomFieldFactory from app.models import Address from app.models import Person from app.tests import APITestCase -from django_features.custom_fields.factories import CustomFieldFactory class CustomFieldViewSetTest(APITestCase): diff --git a/django_features/custom_fields/translation.py b/app/custom_field/translation.py similarity index 87% rename from django_features/custom_fields/translation.py rename to app/custom_field/translation.py index 7a8afd3..c835d44 100644 --- a/django_features/custom_fields/translation.py +++ b/app/custom_field/translation.py @@ -1,7 +1,7 @@ from modeltranslation.decorators import register from modeltranslation.translator import TranslationOptions -from django_features.custom_fields import models +from app.custom_field import models @register(models.CustomField) diff --git a/app/management/commands/create_dummy_data.py b/app/management/commands/create_dummy_data.py index bfe07ff..8531721 100644 --- a/app/management/commands/create_dummy_data.py +++ b/app/management/commands/create_dummy_data.py @@ -4,9 +4,9 @@ from django.contrib.contenttypes.models import ContentType from django.core.management import BaseCommand +from app.custom_field.models import CustomField +from app.custom_field.models import CustomValue from app.models import Person -from django_features.custom_fields.models import CustomField -from django_features.custom_fields.models import CustomValue class Command(BaseCommand): diff --git a/app/migrations/0006_remove_custom_models.py b/app/migrations/0006_remove_custom_models.py new file mode 100644 index 0000000..23f3e38 --- /dev/null +++ b/app/migrations/0006_remove_custom_models.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.23 on 2026-02-24 11:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0005_electiondistrict_person_election_district"), + ] + + operations = [ + migrations.RemoveField( + model_name="address", + name="custom_values", + ), + migrations.RemoveField( + model_name="person", + name="custom_values", + ), + ] diff --git a/app/migrations/0007_inherit_from_custom_base_model.py b/app/migrations/0007_inherit_from_custom_base_model.py new file mode 100644 index 0000000..2afeb98 --- /dev/null +++ b/app/migrations/0007_inherit_from_custom_base_model.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.23 on 2026-02-24 14:37 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("custom_field", "0001_inherit_from_custom_base_model"), + ("app", "0006_remove_custom_models"), + ] + + operations = [ + migrations.AddField( + model_name="address", + name="custom_values", + field=models.ManyToManyField( + blank=True, + to="custom_field.customvalue", + verbose_name="Benutzerdefinierte Werte", + ), + ), + migrations.AddField( + model_name="person", + name="custom_values", + field=models.ManyToManyField( + blank=True, + to="custom_field.customvalue", + verbose_name="Benutzerdefinierte Werte", + ), + ), + ] diff --git a/app/migrations/max_migration.txt b/app/migrations/max_migration.txt index 4b586a8..8c1d157 100644 --- a/app/migrations/max_migration.txt +++ b/app/migrations/max_migration.txt @@ -1 +1 @@ -0005_electiondistrict_person_election_district +0007_inherit_from_custom_base_model diff --git a/app/models/address.py b/app/models/address.py index 0ddc22f..48b3833 100644 --- a/app/models/address.py +++ b/app/models/address.py @@ -2,10 +2,10 @@ from django.contrib.contenttypes.models import ContentType from django.db import models -from django_features.custom_fields.models import CustomFieldBaseModel +from app.custom_field.models.base import CustomBaseModel -class Address(CustomFieldBaseModel): +class Address(CustomBaseModel): city = models.CharField(verbose_name="city", blank=True) country = models.CharField(verbose_name="country", blank=True) street = models.CharField(verbose_name="street", blank=True) diff --git a/app/models/person.py b/app/models/person.py index d006724..bb2e06b 100644 --- a/app/models/person.py +++ b/app/models/person.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from django_features.custom_fields.models import CustomFieldBaseModel -from django_features.custom_fields.models.base import CustomFieldTypeBaseModel +from app.custom_field.models.base import CustomBaseModel +from app.custom_field.models.base import CustomTypeBaseModel -class PersonType(CustomFieldTypeBaseModel): +class PersonType(CustomTypeBaseModel): title = models.CharField(verbose_name="title", max_length=255) class Meta: @@ -13,7 +13,7 @@ class Meta: verbose_name_plural = "Person types" -class Person(CustomFieldBaseModel): +class Person(CustomBaseModel): _custom_field_type_attr = "person_type" addresses = GenericRelation("Address", "target_id", "target_type") diff --git a/app/settings/base.py b/app/settings/base.py index b8afa09..bb162bc 100644 --- a/app/settings/base.py +++ b/app/settings/base.py @@ -27,6 +27,7 @@ def INSTALLED_APPS(self) -> list[str]: "rest_framework", "django_features.custom_fields", "django_features.system_message", + "app.custom_field", "app", ] return installed_apps @@ -160,3 +161,6 @@ def CONSTANCE_CONFIG_FIELDSETS(self) -> dict: } SECRET_KEY = values.SecretValue() + + CUSTOM_FIELD_MODEL = values.Value("custom_field.CustomField") + CUSTOM_FIELD_VALUE_MODEL = values.Value("custom_field.CustomValue") diff --git a/app/tests/__init__.py b/app/tests/__init__.py index dfadcc6..62bb681 100644 --- a/app/tests/__init__.py +++ b/app/tests/__init__.py @@ -4,6 +4,8 @@ from django.test import TransactionTestCase from rest_framework.test import APIClient +from django_features.custom_fields.helpers import clear_custom_field_model_cache + User = get_user_model() @@ -18,6 +20,10 @@ def setUp(self) -> None: self.login("kathi.barfuss") self.session = self.client.session + def tearDown(self) -> None: + super().tearDown() + clear_custom_field_model_cache() + def get_or_create_user(self, username: str) -> tuple[Any, bool]: user, created = User.objects.get_or_create(username=username) if created: diff --git a/app/tests/test_mapping_serializer.py b/app/tests/test_mapping_serializer.py index 42ccf0b..71ec3df 100644 --- a/app/tests/test_mapping_serializer.py +++ b/app/tests/test_mapping_serializer.py @@ -5,6 +5,10 @@ from constance.test import override_config from django.contrib.contenttypes.models import ContentType +from app.custom_field.models import CustomField +from app.custom_field.models import CustomValue +from app.custom_field.tests.factories import CustomFieldFactory +from app.custom_field.tests.factories import CustomValueFactory from app.models import ElectionDistrict from app.models import Municipality from app.models import Person @@ -13,10 +17,6 @@ from app.tests.factories import AddressFactory from app.tests.factories import ElectionDistrictFactory from app.tests.factories import PersonFactory -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.factories import CustomValueFactory -from django_features.custom_fields.models import CustomField -from django_features.custom_fields.models import CustomValue MODEL_MAPPING_FIELD = { diff --git a/app/tests/test_model_field_mapping.py b/app/tests/test_model_field_mapping.py index d72127b..bc57cee 100644 --- a/app/tests/test_model_field_mapping.py +++ b/app/tests/test_model_field_mapping.py @@ -1,11 +1,11 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from app.custom_field.models import CustomField +from app.custom_field.tests.factories import CustomFieldFactory from app.models import Address from app.models import Person from app.tests import APITestCase -from django_features.custom_fields.factories import CustomFieldFactory -from django_features.custom_fields.models import CustomField from django_features.settings.fields import ModelFieldMapping diff --git a/changes/TI-3652.feature b/changes/TI-3652.feature new file mode 100644 index 0000000..eee18df --- /dev/null +++ b/changes/TI-3652.feature @@ -0,0 +1 @@ +Removes the predefined custom field and value models and reverts the swappable feature. (`TI-3652 `_) diff --git a/django_features/custom_fields/admin.py b/django_features/custom_fields/admin.py index 1ceda30..ba03003 100644 --- a/django_features/custom_fields/admin.py +++ b/django_features/custom_fields/admin.py @@ -4,9 +4,7 @@ from django.contrib import admin from django.contrib.contenttypes.models import ContentType from django.http import HttpRequest -from modeltranslation.admin import TranslationAdmin -from django_features.custom_fields import models from django_features.custom_fields.models import CustomFieldBaseModel from django_features.custom_fields.models import CustomFieldTypeBaseModel @@ -41,27 +39,3 @@ def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> list[str if obj: return [*self.readonly_fields, "field_type"] return self.readonly_fields - - -@admin.register(models.CustomField) -class CustomFieldAdmin(BaseAdmin, CustomFieldBaseAdmin, TranslationAdmin): - list_display = ["id", "identifier", "__str__", "field_type", "filterable"] - list_display_links = ( - "id", - "identifier", - "__str__", - ) - list_filter = ( - "choice_field", - "editable", - "field_type", - "content_type", - "filterable", - ) - search_fields = ("label", "identifier") - - -@admin.register(models.CustomValue) -class ValueAdmin(BaseAdmin, TranslationAdmin): - list_display = ["id", "__str__"] - search_fields = ("label", "value", "field__label", "field__identifier") diff --git a/django_features/custom_fields/fields.py b/django_features/custom_fields/fields.py index ae5d72c..cb4f331 100644 --- a/django_features/custom_fields/fields.py +++ b/django_features/custom_fields/fields.py @@ -7,8 +7,8 @@ from rest_framework.utils.model_meta import get_field_info from django_features.custom_fields.helpers import get_custom_value_model -from django_features.custom_fields.models import CustomField -from django_features.custom_fields.models import CustomValue +from django_features.custom_fields.models.field import AbstractBaseCustomField +from django_features.custom_fields.models.value import AbstractBaseCustomValue from django_features.custom_fields.models.value import CustomValueQuerySet from django_features.custom_fields.serializers import CustomChoiceSerializer @@ -17,7 +17,10 @@ class ChoiceIdField(serializers.Field): _unique_field: str | None = None def __init__( - self, field: CustomField, unique_field: str | None = None, **kwargs: Any + self, + field: AbstractBaseCustomField, + unique_field: str | None = None, + **kwargs: Any, ) -> None: super().__init__(**kwargs) self.field = field @@ -37,11 +40,11 @@ def get_queryset(self) -> CustomValueQuerySet: return get_custom_value_model().objects.filter(field_id=self.field.id) def to_representation( - self, value: CustomValue | CustomValueQuerySet + self, value: AbstractBaseCustomValue | CustomValueQuerySet ) -> int | list[int]: return CustomChoiceSerializer(value, many=self.field.multiple).data - def _choice_field(self, data: int | str | dict) -> CustomValue: + def _choice_field(self, data: int | str | dict) -> AbstractBaseCustomValue: if isinstance(data, dict): value = data.get("id") else: @@ -66,7 +69,9 @@ def _multiple_choice(self, data: list[int | str | dict]) -> CustomValueQuerySet: ) return values - def to_internal_value(self, data: Any) -> CustomValue | CustomValueQuerySet: + def to_internal_value( + self, data: Any + ) -> AbstractBaseCustomValue | CustomValueQuerySet: if self.field.multiple and isinstance(data, list): return self._multiple_choice(data) elif self.field.choice_field and isinstance(data, (int, str, dict)): diff --git a/django_features/custom_fields/helpers.py b/django_features/custom_fields/helpers.py index 734fbde..f195c3e 100644 --- a/django_features/custom_fields/helpers.py +++ b/django_features/custom_fields/helpers.py @@ -1,16 +1,30 @@ -__all__ = ["get_custom_field_model", "get_custom_value_model"] +__all__ = [ + "get_custom_field_model", + "get_custom_value_model", + "clear_custom_field_model_cache", +] +from functools import lru_cache + from django.apps import apps as django_apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.db.models import Model + +from django_features.custom_fields.models.field import AbstractBaseCustomField +from django_features.custom_fields.models.value import AbstractBaseCustomValue -def get_custom_field_model() -> type[Model]: +@lru_cache(maxsize=1) +def get_custom_field_model() -> type[AbstractBaseCustomField]: """ Return the CustomField model that is active in this project. """ + if settings.CUSTOM_FIELD_MODEL is None: + raise ImproperlyConfigured( + "CUSTOM_FIELD_MODEL and CUSTOM_FIELD_VALUE_MODEL must be defined in settings to use the custom fields app." + ) + try: return django_apps.get_model(settings.CUSTOM_FIELD_MODEL, require_ready=False) # type: ignore[unused-ignore] except ValueError: @@ -24,13 +38,16 @@ def get_custom_field_model() -> type[Model]: ) -from django_features.custom_fields.models.value import CustomValue # noqa - - -def get_custom_value_model() -> type[Model]: +@lru_cache(maxsize=1) +def get_custom_value_model() -> type[AbstractBaseCustomValue]: """ Return the CustomValue model that is active in this project. """ + if settings.CUSTOM_FIELD_VALUE_MODEL is None: + raise ImproperlyConfigured( + "CUSTOM_FIELD_MODEL and CUSTOM_FIELD_VALUE_MODEL must be defined in settings to use the custom fields app." + ) + try: return django_apps.get_model( # type: ignore[unused-ignore] settings.CUSTOM_FIELD_VALUE_MODEL, require_ready=False @@ -44,3 +61,11 @@ def get_custom_value_model() -> type[Model]: "CUSTOM_FIELD_VALUE_MODEL refers to model '%s' that has not been installed" % settings.CUSTOM_FIELD_VALUE_MODEL ) + + +def clear_custom_field_model_cache() -> None: + """ + Clear cached model lookups for custom fields. + """ + get_custom_field_model.cache_clear() + get_custom_value_model.cache_clear() diff --git a/django_features/custom_fields/migrations/0001_initial.py b/django_features/custom_fields/migrations/0001_initial.py index 4a407b1..345b286 100644 --- a/django_features/custom_fields/migrations/0001_initial.py +++ b/django_features/custom_fields/migrations/0001_initial.py @@ -2,20 +2,15 @@ import django.db.models.deletion import django_extensions.db.fields -from django.conf import settings from django.db import migrations from django.db import models -from django.db.migrations import swappable_dependency class Migration(migrations.Migration): initial = True - dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), - swappable_dependency(settings.CUSTOM_FIELD_MODEL), - ] + dependencies = [("contenttypes", "0002_remove_content_type_name")] operations = [ migrations.CreateModel( @@ -160,7 +155,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="values", - to=settings.CUSTOM_FIELD_MODEL, + to="custom_fields.customfield", verbose_name="Feld", ), ), diff --git a/django_features/custom_fields/migrations/0007_remove_custom_models.py b/django_features/custom_fields/migrations/0007_remove_custom_models.py new file mode 100644 index 0000000..04783c6 --- /dev/null +++ b/django_features/custom_fields/migrations/0007_remove_custom_models.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.23 on 2026-02-24 11:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("custom_fields", "0006_add_filterable_to_customfield"), + ] + + operations = [ + migrations.RemoveField( + model_name="customvalue", + name="field", + ), + migrations.DeleteModel( + name="CustomField", + ), + migrations.DeleteModel( + name="CustomValue", + ), + ] diff --git a/django_features/custom_fields/migrations/max_migration.txt b/django_features/custom_fields/migrations/max_migration.txt index e8b3ce6..66ed4b9 100644 --- a/django_features/custom_fields/migrations/max_migration.txt +++ b/django_features/custom_fields/migrations/max_migration.txt @@ -1 +1 @@ -0006_add_filterable_to_customfield +0007_remove_custom_models diff --git a/django_features/custom_fields/models/__init__.py b/django_features/custom_fields/models/__init__.py index e61b175..5ef9535 100644 --- a/django_features/custom_fields/models/__init__.py +++ b/django_features/custom_fields/models/__init__.py @@ -1,12 +1,5 @@ -__all__ = [ - "CustomFieldBaseModel", - "CustomFieldTypeBaseModel", - "CustomField", - "CustomValue", -] +__all__ = ["CustomFieldBaseModel", "CustomFieldTypeBaseModel"] from .base import CustomFieldBaseModel from .base import CustomFieldTypeBaseModel -from .field import CustomField -from .value import CustomValue diff --git a/django_features/custom_fields/models/base.py b/django_features/custom_fields/models/base.py index 65260da..e3f5277 100644 --- a/django_features/custom_fields/models/base.py +++ b/django_features/custom_fields/models/base.py @@ -1,7 +1,5 @@ from typing import Any -from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.expressions import ArraySubquery from django.db import IntegrityError from django.db import models @@ -13,18 +11,13 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Cast from django.db.models.functions import JSONObject -from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from django_features.custom_fields.helpers import get_custom_field_model from django_features.custom_fields.helpers import get_custom_value_model -from django_features.custom_fields.models.field import CustomField +from django_features.custom_fields.models.field import AbstractBaseCustomField from django_features.custom_fields.models.field import CustomFieldQuerySet -from django_features.custom_fields.models.value import CustomValue - - -CustomFieldModel: CustomField = get_custom_field_model() # type: ignore -CustomValueModel: CustomValue = get_custom_value_model() # type: ignore +from django_features.custom_fields.models.value import AbstractBaseCustomValue class CustomFieldModelBaseManager(models.Manager): @@ -48,7 +41,7 @@ def get_type_filter(self) -> Q: return Q(**type_filter) | Q(type_id__isnull=True) return Q() - def _subquery(self, field: CustomField) -> Subquery: + def _subquery(self, field: AbstractBaseCustomField) -> Subquery: pk_filter = { f"{self.model._meta.model_name}__id": OuterRef("pk"), } @@ -123,25 +116,12 @@ def get_queryset(self) -> QuerySet: class CustomFieldTypeBaseModel(TimeStampedModel): - custom_fields = GenericRelation( - settings.CUSTOM_FIELD_MODEL, - object_id_field="type_id", - content_type_field="type_content_type", - swappable=True, - ) - class Meta: abstract = True class CustomFieldBaseModel(TimeStampedModel): _custom_field_type_attr: str | None = None - - custom_values = models.ManyToManyField( - blank=True, - to=settings.CUSTOM_FIELD_VALUE_MODEL, - verbose_name=_("Benutzerdefinierte Werte"), - ) objects = CustomFieldModelBaseManager() class Meta: @@ -152,8 +132,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.handle_custom_values = True self._custom_values_to_delete: list[int] = [] - self._custom_values_to_remove: list[CustomValue] = [] - self._custom_values_to_save: list[CustomValue] = [] + self._custom_values_to_remove: list[AbstractBaseCustomValue] = [] + self._custom_values_to_save: list[AbstractBaseCustomValue] = [] def _save_custom_values(self) -> None: if self._custom_values_to_remove: @@ -162,7 +142,7 @@ def _save_custom_values(self) -> None: if self._custom_values_to_delete: self.custom_values.filter(id__in=self._custom_values_to_delete).delete() - _custom_values_to_add: set[CustomValue] = set() + _custom_values_to_add: set[AbstractBaseCustomValue] = set() existing_custom_values = self.custom_values.all() for value in self._custom_values_to_save: value.save() # type: ignore @@ -189,16 +169,16 @@ def _create_or_update_custom_value(self, field: str, value: Any) -> None: if value is None: self._custom_values_to_delete.append(value_object.id) return - except CustomValueModel.DoesNotExist: - value_object = CustomValueModel(field=field) + except get_custom_value_model().DoesNotExist: + value_object = get_custom_value_model()(field=field) serializer_field = value_object.field.serializer_field serializer_field.run_validators(value) value_object.value = serializer_field.to_representation(value) self._custom_values_to_save.append(value_object) - def _set_choice_value(self, field: CustomField, value: Any) -> None: + def _set_choice_value(self, field: AbstractBaseCustomField, value: Any) -> None: self._custom_values_to_remove.extend( - CustomValueModel.objects.filter(field=field) + get_custom_value_model().objects.filter(field=field) ) if value is None: return @@ -220,7 +200,7 @@ def get_custom_attr(self, name: str) -> Any: def __setattr__(self, name: str, value: Any) -> None: if hasattr(self, "custom_field_keys") and name in self.custom_field_keys: - field = CustomFieldModel.objects.get(identifier=name) + field = get_custom_field_model().objects.get(identifier=name) if field.choice_field: self._set_choice_value(field, value) else: @@ -239,7 +219,7 @@ def delete( @property def custom_fields(self) -> CustomFieldQuerySet: - return CustomFieldModel.objects.for_model(self.__class__) + return get_custom_field_model().objects.for_model(self.__class__) @property def custom_field_type(self) -> CustomFieldTypeBaseModel | None: @@ -249,10 +229,10 @@ def custom_field_type(self) -> CustomFieldTypeBaseModel | None: @property def default_custom_fields(self) -> CustomFieldQuerySet: - return CustomFieldModel.objects.default_for(self.__class__) + return get_custom_field_model().objects.default_for(self.__class__) @property def type_custom_fields(self) -> CustomFieldQuerySet: if self.custom_field_type: return self.custom_field_type.custom_fields.all() - return CustomFieldModel.objects.none() + return get_custom_field_model().objects.none() diff --git a/django_features/custom_fields/models/field.py b/django_features/custom_fields/models/field.py index 602309f..40edef2 100644 --- a/django_features/custom_fields/models/field.py +++ b/django_features/custom_fields/models/field.py @@ -6,7 +6,6 @@ from django_extensions.db.models import TimeStampedModel from rest_framework import serializers -from django_features.custom_fields.helpers import get_custom_value_model from django_features.custom_fields.models.value import CustomValueQuerySet @@ -126,15 +125,18 @@ def __str__(self) -> str: @property def choices(self) -> CustomValueQuerySet: - custom_value_model = get_custom_value_model() + from django_features.custom_fields.helpers import get_custom_value_model + custom_value_model = get_custom_value_model() if not self.choice_field: return custom_value_model.objects.none() return custom_value_model.objects.filter(field=self) @property def output_field(self) -> models.Field: - output_field = CustomField.TYPE_FIELD_MAP.get(self.field_type) + from django_features.custom_fields.helpers import get_custom_field_model + + output_field = get_custom_field_model().TYPE_FIELD_MAP.get(self.field_type) if not output_field: raise ValueError(f"Unknown field type: {self.field_type}") @@ -174,11 +176,3 @@ def sql_field(self) -> str: if not sql_field: raise ValueError(f"Unknown field type: {self.field_type}") return sql_field - - -class CustomField(AbstractBaseCustomField): - class Meta: - verbose_name = _("Benutzerdefiniertes Feld") - verbose_name_plural = _("Benutzerdefinierte Felder") - ordering = ["order", "created"] - swappable = "CUSTOM_FIELD_MODEL" diff --git a/django_features/custom_fields/models/value.py b/django_features/custom_fields/models/value.py index dcc9f08..8c73dfc 100644 --- a/django_features/custom_fields/models/value.py +++ b/django_features/custom_fields/models/value.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel @@ -25,12 +24,6 @@ def default_for(self, model: type[models.Model]) -> "CustomValueQuerySet": class AbstractBaseCustomValue(TimeStampedModel): - field = models.ForeignKey( - settings.CUSTOM_FIELD_MODEL, - related_name="values", - verbose_name=_("Feld"), - on_delete=models.CASCADE, - ) order = models.PositiveIntegerField(_("Reihenfolge"), default=0) label = models.CharField(verbose_name=_("Label"), null=True, blank=True) value = models.JSONField(verbose_name=_("Wert"), null=True, blank=True) @@ -46,12 +39,3 @@ def __str__(self) -> str: @property def text(self) -> str: return self.label or str(self.value) - - -class CustomValue(AbstractBaseCustomValue): - - class Meta: - ordering = ["order", "created"] - verbose_name = _("Benutzerdefinierter Wert") - verbose_name_plural = _("Benutzerdefinierte Werte") - swappable = "CUSTOM_FIELD_VALUE_MODEL" diff --git a/django_features/custom_fields/serializers.py b/django_features/custom_fields/serializers.py index fcc451a..dd502a7 100644 --- a/django_features/custom_fields/serializers.py +++ b/django_features/custom_fields/serializers.py @@ -7,26 +7,23 @@ from django_features.custom_fields.helpers import get_custom_field_model from django_features.custom_fields.helpers import get_custom_value_model -from django_features.custom_fields.models import CustomField from django_features.custom_fields.models import CustomFieldBaseModel -from django_features.custom_fields.models import CustomValue - - -CustomValueModel = get_custom_value_model() +from django_features.custom_fields.models.field import AbstractBaseCustomField +from django_features.custom_fields.models.value import AbstractBaseCustomValue class CustomChoiceSerializer(serializers.ModelSerializer): class Meta: - model = CustomValueModel + model = get_custom_value_model() fields = ["id", "label", "value"] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - if isinstance(self.instance, CustomValueModel): + if isinstance(self.instance, AbstractBaseCustomValue): field = self.instance.field - self.fields["value"] = CustomField.TYPE_SERIALIZER_MAP[field.field_type]( - allow_null=True, read_only=True, required=False - ) + self.fields["value"] = get_custom_field_model().TYPE_SERIALIZER_MAP[ + field.field_type + ](allow_null=True, read_only=True, required=False) class CustomFieldSerializer(serializers.ModelSerializer): @@ -51,7 +48,7 @@ class Meta: "filterable", ] - def get_choices(self, obj: CustomField) -> list: + def get_choices(self, obj: AbstractBaseCustomField) -> list: return CustomChoiceSerializer(obj.choices, many=True).data @@ -150,15 +147,15 @@ def collect_custom_fields(self) -> dict: } def create(self, validated_data: dict) -> Any: - custom_value_instances: list[CustomValue] = [] - choices: list[CustomValue] = [] + custom_value_instances: list[AbstractBaseCustomValue] = [] + choices: list[AbstractBaseCustomValue] = [] for field in self._custom_fields: value = validated_data.pop(field.identifier, None) if value is None: continue if not field.choice_field: custom_value_instances.append( - CustomValueModel( + get_custom_value_model()( field_id=field.id, value=self.fields[field.identifier].to_representation(value), ) @@ -170,7 +167,9 @@ def create(self, validated_data: dict) -> Any: choices.append(value) instance = super().create(validated_data) if custom_value_instances or choices: - custom_values = CustomValueModel.objects.bulk_create(custom_value_instances) + custom_values = get_custom_value_model().objects.bulk_create( + custom_value_instances + ) custom_values.extend(choices) instance.custom_values.set(custom_values) return instance @@ -191,9 +190,9 @@ def _create_or_update_custom_value( else: value_object.value = value value_object.save() - except CustomValueModel.DoesNotExist: + except get_custom_value_model().DoesNotExist: if value is not None: - value_object = CustomValueModel.objects.create( + value_object = get_custom_value_model().objects.create( field_id=field.id, value=value ) instance.custom_values.add(value_object) diff --git a/django_features/custom_fields/viewsets.py b/django_features/custom_fields/viewsets.py index 53187ac..f209757 100644 --- a/django_features/custom_fields/viewsets.py +++ b/django_features/custom_fields/viewsets.py @@ -3,18 +3,16 @@ from django_features.custom_fields import serializers from django_features.custom_fields.helpers import get_custom_field_model - - -CustomFieldModel = get_custom_field_model() +from django_features.custom_fields.models.field import AbstractBaseCustomField class CustomFieldViewSet(ReadOnlyModelViewSet): - queryset = CustomFieldModel.objects.all() + queryset = get_custom_field_model().objects.all() serializer_class = serializers.CustomFieldSerializer valid_content_type_filter_fields = ["app_label", "model"] - def get_queryset(self) -> QuerySet[CustomFieldModel]: + def get_queryset(self) -> QuerySet[AbstractBaseCustomField]: qs = super().get_queryset() for field in self.valid_content_type_filter_fields: value = self.request.GET.get(field) diff --git a/django_features/settings/__init__.py b/django_features/settings/__init__.py index e166bd5..2004bb9 100644 --- a/django_features/settings/__init__.py +++ b/django_features/settings/__init__.py @@ -13,8 +13,8 @@ def INSTALLED_APPS(self) -> list[str]: CUSTOM_FIELD_ADMIN = values.BooleanValue(default=True) CUSTOM_FIELD_APP = values.Value("django_features.custom_fields") - CUSTOM_FIELD_MODEL = values.Value("custom_fields.CustomField") - CUSTOM_FIELD_VALUE_MODEL = values.Value("custom_fields.CustomValue") + CUSTOM_FIELD_MODEL = values.Value() + CUSTOM_FIELD_VALUE_MODEL = values.Value() @property def CUSTOM_FIELDS_FEATURE(self) -> bool: diff --git a/poetry.lock b/poetry.lock index 640b258..584e106 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1742,4 +1742,4 @@ towncrier = ">=19.9.0" [metadata] lock-version = "2.1" python-versions = ">=3.12, <4" -content-hash = "6e8b4bc9ef672b6855d3f0235cbd63adc65c0cd20156d6e4a0cb9c0fd4eb147d" +content-hash = "01aac60b934ae6f9321d2f7692a8be19fc184e90e20f9bb05456ca688e6058ed" diff --git a/pyproject.toml b/pyproject.toml index be6ffc7..60adb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ django-filter = "^25" django-linear-migrations = "^2" django-modeltranslation = "^0.19" djangorestframework = "^3" -django = "^4.2" +django = ">=4.2, <6" pluck = "^0.2" python-dotenv = "^1" pytz = "^2025.2"