diff --git a/docs/source/development_manual/customization.rst b/docs/source/development_manual/customization.rst new file mode 100644 index 0000000..fe41db1 --- /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 + + diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index 4346029..2141d9a 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -8,10 +8,10 @@ from django.http import HttpResponse, HttpResponseRedirect 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 - class FieldPermissionsAdminMixin: field_change_permissions = {} field_view_permissions = {} @@ -174,6 +174,72 @@ class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, Filtere # 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 custom_included + return list(set(excluded) | set(custom_excluded)) + class CommonAdminInlineMixin(CommonAdminMixin): diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 99493e1..b485bb2 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 @@ class GlobalPermissionRulesTestCase(TestCase): 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 CommonAdminMixinTestCase(TestCase): # 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'}) diff --git a/jdav_web/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index 71e260c..38f7683 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -76,3 +76,8 @@ REPORTS_SECTION = get_var('startpage', 'reports_section', default='reports') # 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('custom_model_fields', model, default={}) for model in CUSTOM_MODELS} \ No newline at end of file