WIP: MK/conditional_fields #175

Draft
marius.klein wants to merge 7 commits from MK/conditional_fields into main

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

@ -8,10 +8,10 @@ from django.http import HttpResponse, HttpResponseRedirect
from django.urls import path, reverse from django.urls import path, reverse
from django.db import models from django.db import models
from django.contrib.admin import helpers, widgets from django.contrib.admin import helpers, widgets
from django.conf import settings
import rules.contrib.admin import rules.contrib.admin
from rules.permissions import perm_exists from rules.permissions import perm_exists
class FieldPermissionsAdminMixin: class FieldPermissionsAdminMixin:
field_change_permissions = {} field_change_permissions = {}
field_view_permissions = {} field_view_permissions = {}
@ -175,6 +175,72 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere
# For any other type of field, just call its formfield() method. # For any other type of field, just call its formfield() method.
return db_field.formfield(**kwargs) 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 custom_included
return list(set(excluded) | set(custom_excluded))
class CommonAdminInlineMixin(CommonAdminMixin): class CommonAdminInlineMixin(CommonAdminMixin):
def has_add_permission(self, request, obj): def has_add_permission(self, request, obj):

@ -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.auth import get_user_model
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.sites import AdminSite
from django.db import models from django.db import models
from django.test import RequestFactory
from unittest.mock import Mock from unittest.mock import Mock
from rules.contrib.models import RulesModelMixin, RulesModelBase
from contrib.admin import CommonAdminMixin
from contrib.models import CommonModel from contrib.models import CommonModel
from contrib.rules import has_global_perm 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() User = get_user_model()
@ -61,8 +86,15 @@ class GlobalPermissionRulesTestCase(TestCase):
class CommonAdminMixinTestCase(TestCase): class CommonAdminMixinTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_superuser('admin', 'admin@test.com', 'password')
def setUp(self): 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): def test_formfield_for_dbfield_with_formfield_overrides(self):
"""Test formfield_for_dbfield when db_field class is in formfield_overrides""" """Test formfield_for_dbfield when db_field class is in formfield_overrides"""
@ -91,3 +123,197 @@ class CommonAdminMixinTestCase(TestCase):
# Verify that the formfield_overrides were used # Verify that the formfield_overrides were used
self.assertIsNotNone(result) 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'})

@ -76,3 +76,8 @@ REPORTS_SECTION = get_var('startpage', 'reports_section', default='reports')
# testing # testing
TEST_MAIL = get_var('testing', 'mail', default='test@localhost') 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('custom_model_fields', model, default={}) for model in CUSTOM_MODELS}
Loading…
Cancel
Save