From c7d1a16cb313f9e8f9f1e55d01a9f0794f82fa2e Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:04:17 +0200 Subject: [PATCH 01/15] feat(config): implement field customization in settings.toml --- jdav_web/contrib/admin.py | 58 +++++++++++++++++++++++++++++ jdav_web/jdav_web/settings/local.py | 5 +++ jdav_web/utils.py | 26 +++++++++++++ 3 files changed, 89 insertions(+) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index 43460292..fa762d76 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -8,8 +8,10 @@ from django.urls import path, reverse from django.db import models from django.contrib.admin import helpers, widgets +from django.conf import settings import rules.contrib.admin from rules.permissions import perm_exists +from utils import OrderedSet class FieldPermissionsAdminMixin: @@ -174,6 +176,62 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): # For any other type of field, just call its formfield() method. return db_field.formfield(**kwargs) + + @property + def field_key(self): + return f"{self.model._meta.app_label}_{self.model.__name__}".lower() + + def get_excluded_fields(self): + return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('exclude', [])) + + def get_included_fields(self): + return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('fields', [])) + + + def get_fieldsets(self, request, obj=None): + included = self.get_included_fields() + excluded = self.get_excluded_fields() + original_fieldsets = super().get_fieldsets(request, obj) + if original_fieldsets: + print(f"get_fieldsets called for {self.field_key}") + print(f"Original fieldsets: {original_fieldsets}") + new_fieldsets = [] + + for title, attrs in original_fieldsets: + fields = attrs.get("fields", []) + + # Flatten groupings like tuples if needed + filtered_fields = [ + f for f in fields + if ( + (not included or f in included) + and f not in excluded + ) + ] + + if filtered_fields: + new_fieldsets.append((title, dict(attrs, **{"fields": filtered_fields}))) + + if new_fieldsets: + print(f"Filtered fieldsets: {new_fieldsets}") + return new_fieldsets + + + def get_fields(self, request, obj=None): + fields = OrderedSet(super().get_fields(request, obj) or []) + custom_fields = self.get_included_fields() - self.get_excluded_fields() + if custom_fields: + print(f"get_fields called for {self.field_key}, fields: {fields}, custom_fields: {custom_fields}") + return list(custom_fields) + return list(fields) + + def get_exclude(self, request, obj=None): + excluded = OrderedSet(super().get_exclude(request, obj) or []) + custom_excluded = self.get_excluded_fields() - self.get_included_fields() + if custom_excluded: + print(f"get_exclude called for {self.field_key}, excluded: {excluded}, custom_excluded: {custom_excluded}") + return list(excluded | custom_excluded) + return list(excluded) class CommonAdminInlineMixin(CommonAdminMixin): diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index 71e260ca..6da4a84d 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -76,3 +76,8 @@ # testing TEST_MAIL = get_var('testing', 'mail', default='test@localhost') + + +# excluded and included model fields in admin and admin forms +CUSTOM_MODELS = list(get_var('custom_model_fields', default={}).keys()) +CUSTOM_MODEL_FIELDS = {model.lower(): get_var('model_fields', model, default=[]) for model in CUSTOM_MODELS} \ No newline at end of file diff --git a/jdav_web/utils.py b/jdav_web/utils.py index 129e495b..439b0e92 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from decimal import Decimal, ROUND_HALF_DOWN import unicodedata +from collections import OrderedDict def file_size_validator(max_upload_size): @@ -88,3 +89,28 @@ def coming_midnight(): return timezone.datetime(year=base.year, month=base.month, day=base.day, hour=0, minute=0, second=0, microsecond=0, tzinfo=base.tzinfo) + + +class OrderedSet(OrderedDict): + def __init__(self, iterable=None): + super().__init__() + if iterable: + for item in iterable: + self[item] = None + + def __sub__(self, other): + if not isinstance(other, OrderedSet): + return NotImplemented + return OrderedSet(k for k in self if k not in other) + + def add(self, item): + self[item] = None + + def discard(self, item): + self.pop(item, None) + + def __contains__(self, item): + return item in self.keys() + + def __iter__(self): + return iter(self.keys()) \ No newline at end of file From 2ef737f751a0e5f723d38a60bcd9d1b28a5e2024 Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 13:37:04 +0200 Subject: [PATCH 02/15] documentation, fixed variable name --- jdav_web/contrib/admin.py | 10 +++++++++- jdav_web/jdav_web/settings/local.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index fa762d76..d37b3589 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -179,19 +179,25 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): @property def field_key(self): + """returns the key to look if model has custom fields in settings""" return f"{self.model._meta.app_label}_{self.model.__name__}".lower() def get_excluded_fields(self): + """if model has custom excluded fields in settings, return them as a set""" return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('exclude', [])) def get_included_fields(self): + """if model has an entire custom fieldset in settings, return it as a set""" return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('fields', [])) def get_fieldsets(self, request, obj=None): + """filter fieldsets according to included and excluded fields in settings""" + + # get original fields and user-defined included and excluded fields + original_fieldsets = super().get_fieldsets(request, obj) included = self.get_included_fields() excluded = self.get_excluded_fields() - original_fieldsets = super().get_fieldsets(request, obj) if original_fieldsets: print(f"get_fieldsets called for {self.field_key}") print(f"Original fieldsets: {original_fieldsets}") @@ -218,6 +224,7 @@ def get_fieldsets(self, request, obj=None): def get_fields(self, request, obj=None): + """filter fields according to included and excluded fields in settings""" fields = OrderedSet(super().get_fields(request, obj) or []) custom_fields = self.get_included_fields() - self.get_excluded_fields() if custom_fields: @@ -226,6 +233,7 @@ def get_fields(self, request, obj=None): return list(fields) def get_exclude(self, request, obj=None): + """filter excluded fields according to included and excluded fields in settings""" excluded = OrderedSet(super().get_exclude(request, obj) or []) custom_excluded = self.get_excluded_fields() - self.get_included_fields() if custom_excluded: diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index 6da4a84d..38f76836 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -80,4 +80,4 @@ # excluded and included model fields in admin and admin forms CUSTOM_MODELS = list(get_var('custom_model_fields', default={}).keys()) -CUSTOM_MODEL_FIELDS = {model.lower(): get_var('model_fields', model, default=[]) for model in CUSTOM_MODELS} \ No newline at end of file +CUSTOM_MODEL_FIELDS = {model.lower(): get_var('custom_model_fields', model, default={}) for model in CUSTOM_MODELS} \ No newline at end of file From 817e3a516c1bb28c7ab91769f129cd1449e74353 Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 22:57:38 +0200 Subject: [PATCH 03/15] fix logic --- jdav_web/contrib/admin.py | 50 ++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index d37b3589..af3a401d 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -183,14 +183,13 @@ def field_key(self): return f"{self.model._meta.app_label}_{self.model.__name__}".lower() def get_excluded_fields(self): - """if model has custom excluded fields in settings, return them as a set""" - return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('exclude', [])) + """if model has custom excluded fields in settings, return them as list""" + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('exclude', []) def get_included_fields(self): - """if model has an entire custom fieldset in settings, return it as a set""" - return OrderedSet(settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('fields', [])) + """if model has an entire fieldset in settings, return them as list""" + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('fields', []) - def get_fieldsets(self, request, obj=None): """filter fieldsets according to included and excluded fields in settings""" @@ -198,48 +197,51 @@ def get_fieldsets(self, request, obj=None): original_fieldsets = super().get_fieldsets(request, obj) included = self.get_included_fields() excluded = self.get_excluded_fields() - if original_fieldsets: - print(f"get_fieldsets called for {self.field_key}") - print(f"Original fieldsets: {original_fieldsets}") + new_fieldsets = [] for title, attrs in original_fieldsets: fields = attrs.get("fields", []) - # Flatten groupings like tuples if needed + # custom fields take precedence over exclude filtered_fields = [ f for f in fields if ( (not included or f in included) - and f not in excluded + and (included or f not in excluded) ) ] if filtered_fields: + # only add fieldset if it has any fields left new_fieldsets.append((title, dict(attrs, **{"fields": filtered_fields}))) - if new_fieldsets: - print(f"Filtered fieldsets: {new_fieldsets}") return new_fieldsets def get_fields(self, request, obj=None): """filter fields according to included and excluded fields in settings""" - fields = OrderedSet(super().get_fields(request, obj) or []) - custom_fields = self.get_included_fields() - self.get_excluded_fields() - if custom_fields: - print(f"get_fields called for {self.field_key}, fields: {fields}, custom_fields: {custom_fields}") - return list(custom_fields) - return list(fields) + fields = super().get_fields(request, obj) or [] + excluded = super().get_exclude(request, obj) or [] + custom_included = self.get_included_fields() + custom_excluded = self.get_excluded_fields() + + if custom_included: + # custom included fields take precedence over exclude + return custom_included + return [f for f in fields if f not in custom_excluded and f not in excluded] def get_exclude(self, request, obj=None): """filter excluded fields according to included and excluded fields in settings""" - excluded = OrderedSet(super().get_exclude(request, obj) or []) - custom_excluded = self.get_excluded_fields() - self.get_included_fields() - if custom_excluded: - print(f"get_exclude called for {self.field_key}, excluded: {excluded}, custom_excluded: {custom_excluded}") - return list(excluded | custom_excluded) - return list(excluded) + excluded = super().get_exclude(request, obj) or [] + custom_included = self.get_included_fields() + custom_excluded = self.get_excluded_fields() + + if custom_included: + # custom included fields take precedence over exclude + return custom_included + return list(set(excluded) | set(custom_excluded)) + class CommonAdminInlineMixin(CommonAdminMixin): From 97b33fc513f060343abc2f9bde44fe1fc6b191ef Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 22:57:50 +0200 Subject: [PATCH 04/15] add logic tests --- jdav_web/contrib/tests.py | 236 +++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 5 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 99493e17..b485bb2f 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,13 +1,38 @@ -from django.test import TestCase +from django.test import TestCase, RequestFactory +from django.test.utils import override_settings from django.contrib.auth import get_user_model from django.contrib import admin +from django.contrib.admin.sites import AdminSite from django.db import models -from django.test import RequestFactory from unittest.mock import Mock -from rules.contrib.models import RulesModelMixin, RulesModelBase + +from contrib.admin import CommonAdminMixin from contrib.models import CommonModel from contrib.rules import has_global_perm -from contrib.admin import CommonAdminMixin +from rules.contrib.models import RulesModelMixin, RulesModelBase + +# Test model for admin customization +class DummyModel(models.Model): + field1 = models.CharField(max_length=100) + field2 = models.IntegerField() + field3 = models.BooleanField() + field4 = models.DateField() + field5 = models.TextField() + + class Meta: + app_label = 'contrib' + +class DummyAdmin(CommonAdminMixin, admin.ModelAdmin): + model = DummyModel + fieldsets = ( + ('Group1', {'fields': ('field1', 'field2')}), + ('Group2', {'fields': ('field3', 'field4', 'field5')}), + ) + fields = ['field1', 'field2', 'field3', 'field4', 'field5'] + + def __init__(self): + self.opts = self.model._meta + self.admin_site = AdminSite() User = get_user_model() @@ -61,8 +86,15 @@ def test_has_global_perm_with_regular_user(self): class CommonAdminMixinTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_superuser('admin', 'admin@test.com', 'password') + def setUp(self): - self.user = User.objects.create_user(username='testuser', password='testpass') + self.request = RequestFactory().get('/') + self.request.user = self.__class__.user + self.admin = DummyAdmin() + self.admin.admin_site = AdminSite() def test_formfield_for_dbfield_with_formfield_overrides(self): """Test formfield_for_dbfield when db_field class is in formfield_overrides""" @@ -91,3 +123,197 @@ class TestModel: # Verify that the formfield_overrides were used self.assertIsNotNone(result) + + def test_default_behavior(self): + """Test with no customization settings""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + + fieldsets = self.admin.get_fieldsets(self.request) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'fields': ['field1', 'field3', 'field5'] + } + }) + def test_included_fields_only(self): + """Test with only included fields specified""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field3', 'field5']) + + fieldsets = self.admin.get_fieldsets(self.request) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field2', 'field4'] + } + }) + def test_excluded_fields_only(self): + """Test with only excluded fields specified""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field3', 'field5']) + + fieldsets = self.admin.get_fieldsets(self.request) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'fields': ['field1', 'field3', 'field5'], + 'exclude': ['field3'] + } + }) + def test_included_and_excluded_fields(self): + """Test with both included and excluded fields""" + fields = self.admin.get_fields(self.request) + # custom fields should take precedence over exclude + self.assertEqual(fields, ['field1', 'field3', 'field5']) + + fieldsets = self.admin.get_fieldsets(self.request) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'fields': ['field5', 'field3', 'field1'] + } + }) + def test_field_order_preservation(self): + """Test that field order from settings is preserved""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field5', 'field3', 'field1']) + + + def test_nonexistent_fields(self): + """Test behavior with nonexistent fields in settings""" + with override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'fields': ['field1', 'nonexistent_field'] + } + }): + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field1', 'nonexistent_field']) + + def test_nonexistent_exclude(self): + """Test behavior with nonexistent fields in settings""" + with override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field1', 'nonexistent', 'field2'] + } + }): + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field3', 'field4', 'field5']) + + exclude = self.admin.get_exclude(self.request) + self.assertEqual(set(exclude), {'nonexistent', 'field1', 'field2'}) + + @override_settings(CUSTOM_MODEL_FIELDS={}) + def test_empty_settings(self): + """Test behavior with empty settings""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + + fieldsets = self.admin.get_fieldsets(self.request) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'fields': [] + } + }) + def test_empty_included_fields(self): + """Test behavior with empty included fields list""" + fields = self.admin.get_fields(self.request) + # empty custom fields is perceived as no restriction + self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field1', 'field2', 'field3', 'field4', 'field5'] + } + }) + def test_exclude_all_fields(self): + """Test behavior when all fields are excluded""" + fields = self.admin.get_fields(self.request) + self.assertEqual(fields, []) + + fieldsets = self.admin.get_fieldsets(self.request) + # as all fields from group2 are excluded, only group1 remains + self.assertEqual(len(fieldsets), 0) + + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field5'] + } + }) + def test_custom_fields_exclude_exclude(self): + """Test that custom excluded fields are respected""" + class OrderedAdmin(DummyAdmin): + exclude = ['field2', 'field4'] + + admin_instance = OrderedAdmin() + exclude = admin_instance.get_exclude(self.request) + # app and custom excludes should be additive + self.assertEqual(set(exclude), {'field2', 'field4', 'field5'}) + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field3']) + + fieldsets = admin_instance.get_fieldsets(self.request) + # for fieldsets, the app exclude is irrelevant, thus only field5 is excluded + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) + self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4']) + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field3', 'field4', 'field5'] + } + }) + def test_custom_fields_fields_exclude(self): + """Test that custom excluded fields are respected""" + class OrderedAdmin(DummyAdmin): + fields = ['field1', 'field2', 'field4'] + + admin_instance = OrderedAdmin() + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {'field3', 'field4', 'field5'}) + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ['field1', 'field2']) + + fieldsets = admin_instance.get_fieldsets(self.request) + # as all fields from group2 are excluded, only group1 remains + self.assertEqual(len(fieldsets), 1) + self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) + + + @override_settings(CUSTOM_MODEL_FIELDS={ + 'contrib_dummymodel': { + 'exclude': ['field2', 'field4'] + } + }) + def test_combined_admin_and_settings_exclude(self): + """Test that both admin and settings excludes are applied while maintaining order""" + class CombinedAdmin(DummyAdmin): + fields = ['field5', 'field4', 'field3', 'field2', 'field1'] + exclude = ['field1'] + + admin_instance = CombinedAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ['field5', 'field3']) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {'field1', 'field2', 'field4'}) From 0e0496e10418f289725073bbe955fac54d38b9ae Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:07:52 +0200 Subject: [PATCH 05/15] add documentation in the development manual --- .../development_manual/customization.rst | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/source/development_manual/customization.rst diff --git a/docs/source/development_manual/customization.rst b/docs/source/development_manual/customization.rst new file mode 100644 index 00000000..fe41db1e --- /dev/null +++ b/docs/source/development_manual/customization.rst @@ -0,0 +1,119 @@ +Customization Guide +================= + +This guide explains how to customize the Kompass application using configuration files and templates. + +Configuration Files +----------------- + +The application uses two main configuration files: + +* ``settings.toml``: Contains core application settings +* ``text.toml``: Contains customizable text content + +settings.toml +~~~~~~~~~~~~ + +The ``settings.toml`` file contains all core configuration settings organized in sections: + +.. code-block:: toml + + [section] + name = "Your Section Name" + street = "Street Address" + town = "12345 Town" + # ... other section details + + [LJP] + contribution_per_day = 25 + tax = 0.1 + + [finance] + allowance_per_day = 22 + max_night_cost = 11 + +Key sections include: + +* ``[section]``: Organization details +* ``[LJP]``: Youth leadership program settings +* ``[finance]``: Financial configurations +* ``[misc]``: Miscellaneous application settings +* ``[mail]``: Email configuration +* ``[database]``: Database connection details + +Customizing Model Fields +~~~~~~~~~~~~~~~~~~~~~~~ + +The ``[custom_model_fields]`` section in ``settings.toml`` allows you to customize which fields are visible in the admin interface: + +.. code-block:: toml + + [custom_model_fields] + # Format: applabel_modelname.fields = ['field1', 'field2'] + # applabel_modelname.exclude = ['field3', 'field4'] + + # Example: Show only specific fields + members_emergencycontact.fields = ['prename', 'lastname', 'phone_number'] + + # Example: Exclude specific fields + members_member.exclude = ['ticket_no', 'dav_badge_no'] + +There are two ways to customize fields: + +1. Using ``fields``: Explicitly specify which fields should be shown + - Only listed fields will be visible + - Overrides any existing field configuration + - Order of fields is preserved as specified + +2. Using ``exclude``: Specify which fields should be hidden + - All fields except the listed ones will be visible + - Adds to any existing exclusions + - Original field order is maintained + +Field customization applies to: + - Django admin views + - Admin forms + - Model admin fieldsets + +.. note:: + Custom forms must be modified manually as they are not affected by this configuration. + +Text Content +----------- + +The ``text.toml`` file allows customization of application text content: + +.. code-block:: toml + + [emails] + welcome_subject = "Welcome to {section_name}" + welcome_body = """ + Dear {name}, + Welcome to our organization... + """ + + [messages] + success_registration = "Registration successful!" + +Templates +--------- + +Template Customization +~~~~~~~~~~~~~~~~~~~~ + +You can override any template by placing a custom version in your project's templates directory: + +1. Create a directory structure matching the original template path +2. Place your custom template file with the same name +3. Django will use your custom template instead of the default + +Example directory structure:: + + templates/ + └── members/ + └── registration_form.tex + └── startpage/ + └── contact.html + └── impressum_content.html + + From 5b22435182ab7dddc153efc8ef7eed9c4cdfd33a Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:20:18 +0200 Subject: [PATCH 06/15] remove unnecessary class --- jdav_web/contrib/admin.py | 2 -- jdav_web/utils.py | 24 ------------------------ 2 files changed, 26 deletions(-) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index af3a401d..2141d9a5 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -11,8 +11,6 @@ from django.conf import settings import rules.contrib.admin from rules.permissions import perm_exists -from utils import OrderedSet - class FieldPermissionsAdminMixin: field_change_permissions = {} diff --git a/jdav_web/utils.py b/jdav_web/utils.py index 439b0e92..5697ef79 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -90,27 +90,3 @@ def coming_midnight(): hour=0, minute=0, second=0, microsecond=0, tzinfo=base.tzinfo) - -class OrderedSet(OrderedDict): - def __init__(self, iterable=None): - super().__init__() - if iterable: - for item in iterable: - self[item] = None - - def __sub__(self, other): - if not isinstance(other, OrderedSet): - return NotImplemented - return OrderedSet(k for k in self if k not in other) - - def add(self, item): - self[item] = None - - def discard(self, item): - self.pop(item, None) - - def __contains__(self, item): - return item in self.keys() - - def __iter__(self): - return iter(self.keys()) \ No newline at end of file From 61ae3a6f79236a0a46a7c44b37d679b64e1d1a89 Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:21:15 +0200 Subject: [PATCH 07/15] clean up utils --- jdav_web/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/jdav_web/utils.py b/jdav_web/utils.py index 5697ef79..129e495b 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _ from decimal import Decimal, ROUND_HALF_DOWN import unicodedata -from collections import OrderedDict def file_size_validator(max_upload_size): @@ -89,4 +88,3 @@ def coming_midnight(): return timezone.datetime(year=base.year, month=base.month, day=base.day, hour=0, minute=0, second=0, microsecond=0, tzinfo=base.tzinfo) - From 87fad2a1f7f38fa0e8f45d856b44b46d86e8b2b6 Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:19:13 +0200 Subject: [PATCH 08/15] wip --- jdav_web/members/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 9cf41bfe..d89b79db 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -1349,7 +1349,7 @@ class KlettertreffAttendeeInline(admin.TabularInline): #} -class KlettertreffAdmin(admin.ModelAdmin): +class KlettertreffAdmin(CommonAdminMixin, admin.ModelAdmin): form = KlettertreffAdminForm exclude = [] inlines = [KlettertreffAttendeeInline] From 682fd44c7f4f404043d83f397527000c71bee164 Mon Sep 17 00:00:00 2001 From: marius <47218379+mariusrklein@users.noreply.github.com> Date: Sat, 11 Oct 2025 19:12:18 +0200 Subject: [PATCH 09/15] add custom model fields to customization list Co-authored-by: Christian Merten --- docs/source/development_manual/customization.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/development_manual/customization.rst b/docs/source/development_manual/customization.rst index fe41db1e..f3336de1 100644 --- a/docs/source/development_manual/customization.rst +++ b/docs/source/development_manual/customization.rst @@ -40,6 +40,7 @@ Key sections include: * ``[misc]``: Miscellaneous application settings * ``[mail]``: Email configuration * ``[database]``: Database connection details +* ``[custom_model_fields]``: Customize visible model fields in admin interface (see below) Customizing Model Fields ~~~~~~~~~~~~~~~~~~~~~~~ From 38e24cb89ac8fd2862891932a8225875cb699e34 Mon Sep 17 00:00:00 2001 From: marius <47218379+mariusrklein@users.noreply.github.com> Date: Sat, 11 Oct 2025 19:12:42 +0200 Subject: [PATCH 10/15] mention example Co-authored-by: Christian Merten --- docs/source/development_manual/customization.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/development_manual/customization.rst b/docs/source/development_manual/customization.rst index f3336de1..74eaebb0 100644 --- a/docs/source/development_manual/customization.rst +++ b/docs/source/development_manual/customization.rst @@ -11,6 +11,7 @@ The application uses two main configuration files: * ``settings.toml``: Contains core application settings * ``text.toml``: Contains customizable text content +An example `settings.toml` can be found in `deploy/config/settings.toml`. For more details, see the following section. settings.toml ~~~~~~~~~~~~ From 6c43492fae5d1ce1a6cd9b1338d69741e60c2768 Mon Sep 17 00:00:00 2001 From: mariusrklein Date: Sat, 11 Oct 2025 19:13:18 +0200 Subject: [PATCH 11/15] fix merge conflict error --- jdav_web/contrib/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index f3f738e3..1a644488 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from decimal import Decimal from django.test import TestCase, RequestFactory -from django.test.utils import override_settings, RequestFactory +from django.test.utils import override_settings from django.contrib.auth import get_user_model from django.contrib import admin from django.contrib.admin.sites import AdminSite From 344060c371e1173c7e42413fa65539fe76cbb5c1 Mon Sep 17 00:00:00 2001 From: mariusrklein Date: Sat, 8 Nov 2025 22:46:12 +0100 Subject: [PATCH 12/15] fix: correct conditional fields logic and add more tests --- jdav_web/contrib/admin.py | 31 ++-- jdav_web/contrib/tests.py | 344 +++++++++++++++++++++++++------------- 2 files changed, 241 insertions(+), 134 deletions(-) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index f3cc0233..af87aca4 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -195,23 +195,23 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): # For any other type of field, just call its formfield() method. return db_field.formfield(**kwargs) - + @property def field_key(self): """returns the key to look if model has custom fields in settings""" return f"{self.model._meta.app_label}_{self.model.__name__}".lower() - + def get_excluded_fields(self): """if model has custom excluded fields in settings, return them as list""" - return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('exclude', []) - + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("exclude", []) + def get_included_fields(self): """if model has an entire fieldset in settings, return them as list""" - return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get('fields', []) + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("fields", []) def get_fieldsets(self, request, obj=None): """filter fieldsets according to included and excluded fields in settings""" - + # get original fields and user-defined included and excluded fields original_fieldsets = super().get_fieldsets(request, obj) included = self.get_included_fields() @@ -224,19 +224,18 @@ def get_fieldsets(self, request, obj=None): # custom fields take precedence over exclude filtered_fields = [ - f for f in fields - if ( - (not included or f in included) - and (included or f not in excluded) - ) + f + for f in fields + if ((not included or f in included) and (included or f not in excluded)) ] if filtered_fields: # only add fieldset if it has any fields left - new_fieldsets.append((title, dict(attrs, **{"fields": filtered_fields}))) + new_fieldsets.append( + (title, dict(attrs, **{"fields": filtered_fields})) + ) return new_fieldsets - def get_fields(self, request, obj=None): """filter fields according to included and excluded fields in settings""" @@ -244,12 +243,12 @@ def get_fields(self, request, obj=None): excluded = super().get_exclude(request, obj) or [] custom_included = self.get_included_fields() custom_excluded = self.get_excluded_fields() - + if custom_included: # custom included fields take precedence over exclude return custom_included return [f for f in fields if f not in custom_excluded and f not in excluded] - + def get_exclude(self, request, obj=None): """filter excluded fields according to included and excluded fields in settings""" excluded = super().get_exclude(request, obj) or [] @@ -258,7 +257,7 @@ def get_exclude(self, request, obj=None): if custom_included: # custom included fields take precedence over exclude - return custom_included + return list((set(excluded) | set(custom_excluded)) - set(custom_included)) return list(set(excluded) | set(custom_excluded)) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 1a644488..8f074785 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -31,10 +31,35 @@ class Meta: class DummyAdmin(CommonAdminMixin, admin.ModelAdmin): model = DummyModel fieldsets = ( - ('Group1', {'fields': ('field1', 'field2')}), - ('Group2', {'fields': ('field3', 'field4', 'field5')}), + ("Group1", {"fields": ("field1", "field2")}), + ("Group2", {"fields": ("field3", "field4", "field5")}), ) - fields = ['field1', 'field2', 'field3', 'field4', 'field5'] + fields = ["field1", "field2", "field3", "field4", "field5"] + + def __init__(self): + self.opts = self.model._meta + self.admin_site = AdminSite() + + +class DummyWithExcludeAdmin(CommonAdminMixin, admin.ModelAdmin): + model = DummyModel + fieldsets = ( + ("Group1", {"fields": ("field1", "field2", "field5")}), + ("Group2", {"fields": ("field3", "field4")}), + ) + exclude = ["field5"] + + def __init__(self): + self.opts = self.model._meta + self.admin_site = AdminSite() + +class DummyWithExcludeAdmin(CommonAdminMixin, admin.ModelAdmin): + model = DummyModel + fieldsets = ( + ("Group1", {"fields": ("field1", "field2", "field5")}), + ("Group2", {"fields": ("field3", "field4")}), + ) + exclude = ["field5"] def __init__(self): self.opts = self.model._meta @@ -42,13 +67,19 @@ def __init__(self): User = get_user_model() + class CommonModelTestCase(TestCase): def test_common_model_abstract_base(self): """Test that CommonModel provides the correct meta attributes""" meta = CommonModel._meta self.assertTrue(meta.abstract) expected_permissions = ( - 'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view', + "add_global", + "change_global", + "view_global", + "delete_global", + "list_global", + "view", ) self.assertEqual(meta.default_permissions, expected_permissions) @@ -64,15 +95,13 @@ def test_common_model_inheritance(self): class GlobalPermissionRulesTestCase(TestCase): def setUp(self): self.user = User.objects.create_user( - username='testuser', - email='test@example.com', - password='testpass123' + username="testuser", email="test@example.com", password="testpass123" ) def test_has_global_perm_predicate_creation(self): """Test that has_global_perm creates a predicate function""" # has_global_perm is a decorator factory, not a direct predicate - predicate = has_global_perm('auth.add_user') + predicate = has_global_perm("auth.add_user") self.assertTrue(callable(predicate)) def test_has_global_perm_with_superuser(self): @@ -80,13 +109,13 @@ def test_has_global_perm_with_superuser(self): self.user.is_superuser = True self.user.save() - predicate = has_global_perm('auth.add_user') + predicate = has_global_perm("auth.add_user") result = predicate(self.user, None) self.assertTrue(result) def test_has_global_perm_with_regular_user(self): """Test that regular users don't automatically have global permissions""" - predicate = has_global_perm('auth.add_user') + predicate = has_global_perm("auth.add_user") result = predicate(self.user, None) self.assertFalse(result) @@ -94,26 +123,25 @@ def test_has_global_perm_with_regular_user(self): class CommonAdminMixinTestCase(TestCase): @classmethod def setUpTestData(cls): - cls.user = User.objects.create_superuser('admin', 'admin@test.com', 'password') + cls.user = User.objects.create_superuser("admin", "admin@test.com", "password") def setUp(self): - self.request = RequestFactory().get('/') + self.request = RequestFactory().get("/") self.request.user = self.__class__.user self.admin = DummyAdmin() self.admin.admin_site = AdminSite() def test_formfield_for_dbfield_with_formfield_overrides(self): """Test formfield_for_dbfield when db_field class is in formfield_overrides""" + # Create a test admin instance that inherits from Django's ModelAdmin class TestAdmin(CommonAdminMixin, admin.ModelAdmin): - formfield_overrides = { - models.ForeignKey: {'widget': Mock()} - } + formfield_overrides = {models.ForeignKey: {"widget": Mock()}} # Create a mock model to use with the admin class TestModel: _meta = Mock() - _meta.app_label = 'test' + _meta.app_label = "test" admin_instance = TestAdmin(TestModel, admin.site) @@ -121,11 +149,13 @@ class TestModel: db_field = models.ForeignKey(User, on_delete=models.CASCADE) # Create a test request - request = RequestFactory().get('/') + request = RequestFactory().get("/") request.user = self.user # Call the method to test formfield_overrides usage - result = admin_instance.formfield_for_dbfield(db_field, request, help_text='Test help text') + result = admin_instance.formfield_for_dbfield( + db_field, request, help_text="Test help text" + ) # Verify that the formfield_overrides were used self.assertIsNotNone(result) @@ -133,196 +163,274 @@ class TestModel: def test_default_behavior(self): """Test with no customization settings""" fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + self.assertEqual(fields, ["field1", "field2", "field3", "field4", "field5"]) fieldsets = self.admin.get_fieldsets(self.request) self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4', 'field5']) + self.assertEqual(fieldsets[0][1]["fields"], ["field1", "field2"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field4", "field5"]) - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'fields': ['field1', 'field3', 'field5'] + @override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": {"fields": ["field1", "field3", "field5"]} } - }) + ) def test_included_fields_only(self): """Test with only included fields specified""" fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field3', 'field5']) + self.assertEqual(fields, ["field1", "field3", "field5"]) fieldsets = self.admin.get_fieldsets(self.request) self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) + self.assertEqual(fieldsets[0][1]["fields"], ["field1"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field5"]) - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field2', 'field4'] - } - }) + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field2", "field4"]}} + ) def test_excluded_fields_only(self): """Test with only excluded fields specified""" fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field3', 'field5']) + self.assertEqual(fields, ["field1", "field3", "field5"]) fieldsets = self.admin.get_fieldsets(self.request) self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) - - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'fields': ['field1', 'field3', 'field5'], - 'exclude': ['field3'] + self.assertEqual(fieldsets[0][1]["fields"], ["field1"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field5"]) + + @override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": { + "fields": ["field1", "field3", "field5"], + "exclude": ["field3"], + } } - }) + ) def test_included_and_excluded_fields(self): """Test with both included and excluded fields""" fields = self.admin.get_fields(self.request) # custom fields should take precedence over exclude - self.assertEqual(fields, ['field1', 'field3', 'field5']) + self.assertEqual(fields, ["field1", "field3", "field5"]) + + excluded = self.admin.get_exclude(self.request) + # custom fields should take precedence over exclude + self.assertEqual(excluded, []) fieldsets = self.admin.get_fieldsets(self.request) self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field5']) + self.assertEqual(fieldsets[0][1]["fields"], ["field1"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field5"]) - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'fields': ['field5', 'field3', 'field1'] + @override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": {"fields": ["field5", "field3", "field1"]} } - }) + ) def test_field_order_preservation(self): """Test that field order from settings is preserved""" fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field5', 'field3', 'field1']) + self.assertEqual(fields, ["field5", "field3", "field1"]) - def test_nonexistent_fields(self): - """Test behavior with nonexistent fields in settings""" - with override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'fields': ['field1', 'nonexistent_field'] + """Test behavior with nonexistent fields in settings. They should be included + so that the admin shows an error about missing fields.""" + with override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": {"fields": ["field1", "nonexistent_field"]} } - }): + ): fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field1', 'nonexistent_field']) - + self.assertEqual(fields, ["field1", "nonexistent_field"]) + + exclude = self.admin.get_exclude(self.request) + self.assertEqual(set(exclude), set()) + def test_nonexistent_exclude(self): - """Test behavior with nonexistent fields in settings""" - with override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field1', 'nonexistent', 'field2'] + """Test behavior with nonexistent fields in settings. They should be kept so that + the admin shows an error about missing fields.""" + with override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": {"exclude": ["field1", "nonexistent", "field2"]} } - }): + ): fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field3', 'field4', 'field5']) - + self.assertEqual(fields, ["field3", "field4", "field5"]) exclude = self.admin.get_exclude(self.request) - self.assertEqual(set(exclude), {'nonexistent', 'field1', 'field2'}) + self.assertEqual(set(exclude), {"nonexistent", "field1", "field2"}) @override_settings(CUSTOM_MODEL_FIELDS={}) def test_empty_settings(self): - """Test behavior with empty settings""" + """Test behavior with empty settings, there should be no effect""" + """Test behavior with empty settings, there should be no effect""" fields = self.admin.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + self.assertEqual(fields, ["field1", "field2", "field3", "field4", "field5"]) fieldsets = self.admin.get_fieldsets(self.request) self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4', 'field5']) + self.assertEqual(fieldsets[0][1]["fields"], ["field1", "field2"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field4", "field5"]) - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'fields': [] - } - }) + @override_settings(CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"fields": []}}) def test_empty_included_fields(self): """Test behavior with empty included fields list""" fields = self.admin.get_fields(self.request) # empty custom fields is perceived as no restriction - self.assertEqual(fields, ['field1', 'field2', 'field3', 'field4', 'field5']) + self.assertEqual(fields, ["field1", "field2", "field3", "field4", "field5"]) - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field1', 'field2', 'field3', 'field4', 'field5'] + @override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": { + "exclude": ["field1", "field2", "field3", "field4", "field5"] + } } - }) + ) def test_exclude_all_fields(self): """Test behavior when all fields are excluded""" fields = self.admin.get_fields(self.request) self.assertEqual(fields, []) - + fieldsets = self.admin.get_fieldsets(self.request) # as all fields from group2 are excluded, only group1 remains self.assertEqual(len(fieldsets), 0) - - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field5'] - } - }) + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field5"]}} + ) def test_custom_fields_exclude_exclude(self): """Test that custom excluded fields are respected""" + class OrderedAdmin(DummyAdmin): - exclude = ['field2', 'field4'] + exclude = ["field2", "field4"] admin_instance = OrderedAdmin() exclude = admin_instance.get_exclude(self.request) # app and custom excludes should be additive - self.assertEqual(set(exclude), {'field2', 'field4', 'field5'}) - + self.assertEqual(set(exclude), {"field2", "field4", "field5"}) + fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field3']) - + self.assertEqual(fields, ["field1", "field3"]) + fieldsets = admin_instance.get_fieldsets(self.request) # for fieldsets, the app exclude is irrelevant, thus only field5 is excluded self.assertEqual(len(fieldsets), 2) - self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) - self.assertEqual(fieldsets[1][1]['fields'], ['field3', 'field4']) - - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field3', 'field4', 'field5'] + self.assertEqual(fieldsets[0][1]["fields"], ["field1", "field2"]) + self.assertEqual(fieldsets[1][1]["fields"], ["field3", "field4"]) + + @override_settings( + CUSTOM_MODEL_FIELDS={ + "contrib_dummymodel": {"exclude": ["field3", "field4", "field5"]} } - }) + ) def test_custom_fields_fields_exclude(self): """Test that custom excluded fields are respected""" + class OrderedAdmin(DummyAdmin): - fields = ['field1', 'field2', 'field4'] + fields = ["field1", "field2", "field4"] admin_instance = OrderedAdmin() exclude = admin_instance.get_exclude(self.request) - self.assertEqual(set(exclude), {'field3', 'field4', 'field5'}) - + self.assertEqual(set(exclude), {"field3", "field4", "field5"}) + fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ['field1', 'field2']) - + self.assertEqual(fields, ["field1", "field2"]) + fieldsets = admin_instance.get_fieldsets(self.request) # as all fields from group2 are excluded, only group1 remains self.assertEqual(len(fieldsets), 1) - self.assertEqual(fieldsets[0][1]['fields'], ['field1', 'field2']) - - - @override_settings(CUSTOM_MODEL_FIELDS={ - 'contrib_dummymodel': { - 'exclude': ['field2', 'field4'] - } - }) + self.assertEqual(fieldsets[0][1]["fields"], ["field1", "field2"]) + + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field2", "field4"]}} + ) def test_combined_admin_and_settings_exclude(self): """Test that both admin and settings excludes are applied while maintaining order""" + class CombinedAdmin(DummyAdmin): - fields = ['field5', 'field4', 'field3', 'field2', 'field1'] - exclude = ['field1'] + fields = ["field5", "field4", "field3", "field2", "field1"] + exclude = ["field1"] admin_instance = CombinedAdmin() - + fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ['field5', 'field3']) - + self.assertEqual(fields, ["field5", "field3"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field1", "field2", "field4"}) + + def test_default_with_exclude(self): + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field1", "field2", "field3", "field4"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field5"}) + + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"fields": ["field2", "field4"]}} + ) + def test_override_exclude_with_fields(self): + """Test that custom included fields override admin exclude and + is reflected in get_exclude. + """ + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field2", "field4"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field5"}) + + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field2", "field4"]}} + ) + def test_override_exclude_with_exclude(self): + """Test that custom excluded fields are combined with admin exclude""" + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field1", "field3"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field1", "field2", "field4"}) + + def test_default_with_exclude(self): + """Test that admin exclude is respected by default""" + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field1", "field2", "field3", "field4"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field5"}) + + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"fields": ["field2", "field4"]}} + ) + def test_override_exclude_with_fields(self): + """Test that custom included fields override admin exclude and + is reflected in get_exclude. + """ + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field2", "field4"]) + + exclude = admin_instance.get_exclude(self.request) + self.assertEqual(set(exclude), {"field5"}) + + @override_settings( + CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field2", "field4"]}} + ) + def test_override_exclude_with_exclude(self): + """Test that custom excluded fields are combined with admin exclude""" + admin_instance = DummyWithExcludeAdmin() + + fields = admin_instance.get_fields(self.request) + self.assertEqual(fields, ["field1", "field3"]) + exclude = admin_instance.get_exclude(self.request) - self.assertEqual(set(exclude), {'field1', 'field2', 'field4'}) + self.assertEqual(set(exclude), {"field5", "field2", "field4"}) class UtilsTestCase(TestCase): From 144d4993e343a350b1562c961b818b7e32cb0034 Mon Sep 17 00:00:00 2001 From: mariusrklein Date: Sun, 9 Nov 2025 00:20:48 +0100 Subject: [PATCH 13/15] fix merge errors --- jdav_web/contrib/tests.py | 48 --------------------------------------- 1 file changed, 48 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 8f074785..b0bf1284 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -53,17 +53,6 @@ def __init__(self): self.opts = self.model._meta self.admin_site = AdminSite() -class DummyWithExcludeAdmin(CommonAdminMixin, admin.ModelAdmin): - model = DummyModel - fieldsets = ( - ("Group1", {"fields": ("field1", "field2", "field5")}), - ("Group2", {"fields": ("field3", "field4")}), - ) - exclude = ["field5"] - - def __init__(self): - self.opts = self.model._meta - self.admin_site = AdminSite() User = get_user_model() @@ -357,43 +346,6 @@ class CombinedAdmin(DummyAdmin): exclude = admin_instance.get_exclude(self.request) self.assertEqual(set(exclude), {"field1", "field2", "field4"}) - def test_default_with_exclude(self): - admin_instance = DummyWithExcludeAdmin() - - fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ["field1", "field2", "field3", "field4"]) - - exclude = admin_instance.get_exclude(self.request) - self.assertEqual(set(exclude), {"field5"}) - - @override_settings( - CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"fields": ["field2", "field4"]}} - ) - def test_override_exclude_with_fields(self): - """Test that custom included fields override admin exclude and - is reflected in get_exclude. - """ - admin_instance = DummyWithExcludeAdmin() - - fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ["field2", "field4"]) - - exclude = admin_instance.get_exclude(self.request) - self.assertEqual(set(exclude), {"field5"}) - - @override_settings( - CUSTOM_MODEL_FIELDS={"contrib_dummymodel": {"exclude": ["field2", "field4"]}} - ) - def test_override_exclude_with_exclude(self): - """Test that custom excluded fields are combined with admin exclude""" - admin_instance = DummyWithExcludeAdmin() - - fields = admin_instance.get_fields(self.request) - self.assertEqual(fields, ["field1", "field3"]) - - exclude = admin_instance.get_exclude(self.request) - self.assertEqual(set(exclude), {"field1", "field2", "field4"}) - def test_default_with_exclude(self): """Test that admin exclude is respected by default""" admin_instance = DummyWithExcludeAdmin() From fed9d6eaa080ce1df851affcd4bae3ea458216ac Mon Sep 17 00:00:00 2001 From: mariusrklein Date: Sun, 9 Nov 2025 22:35:44 +0100 Subject: [PATCH 14/15] move Field Customization to its own Mixin and inherit it in other Admins --- jdav_web/contrib/admin.py | 136 +++++++++++++++++++------------------- jdav_web/finance/admin.py | 8 +-- jdav_web/mailer/admin.py | 4 +- 3 files changed, 75 insertions(+), 73 deletions(-) diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index af87aca4..96a0837d 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -108,7 +108,74 @@ def get_queryset(self, request): #class ObjectPermissionsInlineModelAdminMixin(rules.contrib.admin.ObjectPermissionsInlineModelAdminMixin): -class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin): +class FieldCustomizationMixin: + @property + def field_key(self): + """returns the key to look if model has custom fields in settings""" + return f"{self.model._meta.app_label}_{self.model.__name__}".lower() + + def get_excluded_fields(self): + """if model has custom excluded fields in settings, return them as list""" + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("exclude", []) + + def get_included_fields(self): + """if model has an entire fieldset in settings, return them as list""" + return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("fields", []) + + def get_fieldsets(self, request, obj=None): + """filter fieldsets according to included and excluded fields in settings""" + + # get original fields and user-defined included and excluded fields + original_fieldsets = super().get_fieldsets(request, obj) + included = self.get_included_fields() + excluded = self.get_excluded_fields() + + new_fieldsets = [] + + for title, attrs in original_fieldsets: + fields = attrs.get("fields", []) + + # custom fields take precedence over exclude + filtered_fields = [ + f + for f in fields + if ((not included or f in included) and (included or f not in excluded)) + ] + + if filtered_fields: + # only add fieldset if it has any fields left + new_fieldsets.append( + (title, dict(attrs, **{"fields": filtered_fields})) + ) + + return new_fieldsets + + def get_fields(self, request, obj=None): + """filter fields according to included and excluded fields in settings""" + fields = super().get_fields(request, obj) or [] + excluded = super().get_exclude(request, obj) or [] + custom_included = self.get_included_fields() + custom_excluded = self.get_excluded_fields() + + if custom_included: + # custom included fields take precedence over exclude + return custom_included + return [f for f in fields if f not in custom_excluded and f not in excluded] + + def get_exclude(self, request, obj=None): + """filter excluded fields according to included and excluded fields in settings""" + excluded = super().get_exclude(request, obj) or [] + custom_included = self.get_included_fields() + custom_excluded = self.get_excluded_fields() + + if custom_included: + # custom included fields take precedence over exclude + return list((set(excluded) | set(custom_excluded)) - set(custom_included)) + return list(set(excluded) | set(custom_excluded)) + + + +class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin, FieldCustomizationMixin): def has_add_permission(self, request, obj=None): assert obj is None opts = self.opts @@ -195,72 +262,7 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): # For any other type of field, just call its formfield() method. return db_field.formfield(**kwargs) - - @property - def field_key(self): - """returns the key to look if model has custom fields in settings""" - return f"{self.model._meta.app_label}_{self.model.__name__}".lower() - - def get_excluded_fields(self): - """if model has custom excluded fields in settings, return them as list""" - return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("exclude", []) - - def get_included_fields(self): - """if model has an entire fieldset in settings, return them as list""" - return settings.CUSTOM_MODEL_FIELDS.get(self.field_key, {}).get("fields", []) - - def get_fieldsets(self, request, obj=None): - """filter fieldsets according to included and excluded fields in settings""" - - # get original fields and user-defined included and excluded fields - original_fieldsets = super().get_fieldsets(request, obj) - included = self.get_included_fields() - excluded = self.get_excluded_fields() - - new_fieldsets = [] - - for title, attrs in original_fieldsets: - fields = attrs.get("fields", []) - - # custom fields take precedence over exclude - filtered_fields = [ - f - for f in fields - if ((not included or f in included) and (included or f not in excluded)) - ] - - if filtered_fields: - # only add fieldset if it has any fields left - new_fieldsets.append( - (title, dict(attrs, **{"fields": filtered_fields})) - ) - - return new_fieldsets - - def get_fields(self, request, obj=None): - """filter fields according to included and excluded fields in settings""" - fields = super().get_fields(request, obj) or [] - excluded = super().get_exclude(request, obj) or [] - custom_included = self.get_included_fields() - custom_excluded = self.get_excluded_fields() - - if custom_included: - # custom included fields take precedence over exclude - return custom_included - return [f for f in fields if f not in custom_excluded and f not in excluded] - - def get_exclude(self, request, obj=None): - """filter excluded fields according to included and excluded fields in settings""" - excluded = super().get_exclude(request, obj) or [] - custom_included = self.get_included_fields() - custom_excluded = self.get_excluded_fields() - - if custom_included: - # custom included fields take precedence over exclude - return list((set(excluded) | set(custom_excluded)) - set(custom_included)) - return list(set(excluded) | set(custom_excluded)) - - + class CommonAdminInlineMixin(CommonAdminMixin): def has_add_permission(self, request, obj): diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 308e9e3f..cc7e799f 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -11,7 +11,7 @@ from django.shortcuts import render from django.conf import settings -from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin +from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin, FieldCustomizationMixin from utils import get_member, RestrictedFileField from rules.contrib.admin import ObjectPermissionsModelAdmin @@ -322,7 +322,7 @@ def statement_summary_view(self, request, statement): statement_summary_view.short_description = _('Download summary') -class TransactionOnSubmittedStatementInline(admin.TabularInline): +class TransactionOnSubmittedStatementInline(FieldCustomizationMixin, admin.TabularInline): model = Transaction fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] formfield_overrides = { @@ -356,7 +356,7 @@ def get_readonly_fields(self, request, obj=None): @admin.register(Transaction) -class TransactionAdmin(admin.ModelAdmin): +class TransactionAdmin(FieldCustomizationMixin, admin.ModelAdmin): """The transaction admin site. This is only used to display transactions. All editing is disabled on this site. All transactions should be changed on the respective statement at the correct stage of the approval chain.""" @@ -385,7 +385,7 @@ def has_delete_permission(self, request, obj=None): @admin.register(Bill) -class BillAdmin(admin.ModelAdmin): +class BillAdmin(FieldCustomizationMixin, admin.ModelAdmin): list_display = ['__str__', 'statement', 'explanation', 'pretty_amount', 'paid_by', 'refunded'] list_filter = ('statement', 'paid_by', 'refunded') search_fields = ('reference', 'statement') diff --git a/jdav_web/mailer/admin.py b/jdav_web/mailer/admin.py index 15848f20..36bd1071 100644 --- a/jdav_web/mailer/admin.py +++ b/jdav_web/mailer/admin.py @@ -14,7 +14,7 @@ from .mailutils import NOT_SENT, PARTLY_SENT from members.models import Member from members.admin import FilteredMemberFieldMixin -from contrib.admin import CommonAdminMixin, CommonAdminInlineMixin +from contrib.admin import CommonAdminMixin, CommonAdminInlineMixin, FieldCustomizationMixin class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline): @@ -22,7 +22,7 @@ class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline): extra = 0 -class EmailAddressAdmin(FilteredMemberFieldMixin, admin.ModelAdmin): +class EmailAddressAdmin(FieldCustomizationMixin, FilteredMemberFieldMixin, admin.ModelAdmin): list_display = ('email', 'internal_only') fields = ('name', 'to_members', 'to_groups', 'internal_only') #formfield_overrides = { From 5aa24f35f371d420521a13606eee101c462751e9 Mon Sep 17 00:00:00 2001 From: mariusrklein Date: Sun, 9 Nov 2025 22:51:01 +0100 Subject: [PATCH 15/15] modify tests to cover extra FieldCustomizationMixin --- jdav_web/contrib/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index b0bf1284..63df3e41 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from unittest.mock import Mock, patch from rules.contrib.models import RulesModelMixin, RulesModelBase -from contrib.admin import CommonAdminMixin +from contrib.admin import CommonAdminMixin, FieldCustomizationMixin from contrib.models import CommonModel from contrib.rules import has_global_perm from utils import file_size_validator, RestrictedFileField, cvt_to_decimal, get_member, normalize_name, normalize_filename, coming_midnight, mondays_until_nth @@ -41,7 +41,7 @@ def __init__(self): self.admin_site = AdminSite() -class DummyWithExcludeAdmin(CommonAdminMixin, admin.ModelAdmin): +class DummyWithExcludeAdmin(FieldCustomizationMixin, admin.ModelAdmin): model = DummyModel fieldsets = ( ("Group1", {"fields": ("field1", "field2", "field5")}),