diff --git a/docs/source/development_manual/customization.rst b/docs/source/development_manual/customization.rst new file mode 100644 index 00000000..74eaebb0 --- /dev/null +++ b/docs/source/development_manual/customization.rst @@ -0,0 +1,121 @@ +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 + +An example `settings.toml` can be found in `deploy/config/settings.toml`. For more details, see the following section. +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 +* ``[custom_model_fields]``: Customize visible model fields in admin interface (see below) + +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 96a38381..96a0837d 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -8,6 +8,7 @@ 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 @@ -107,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 @@ -194,7 +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) - + class CommonAdminInlineMixin(CommonAdminMixin): def has_add_permission(self, request, obj): diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py index 45adca4a..63df3e41 100644 --- a/jdav_web/contrib/tests.py +++ b/jdav_web/contrib/tests.py @@ -1,28 +1,74 @@ from datetime import datetime, timedelta from decimal import Decimal 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.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile 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, FieldCustomizationMixin from contrib.models import CommonModel from contrib.rules import has_global_perm -from contrib.admin import CommonAdminMixin from utils import file_size_validator, RestrictedFileField, cvt_to_decimal, get_member, normalize_name, normalize_filename, coming_midnight, mondays_until_nth +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() + + +class DummyWithExcludeAdmin(FieldCustomizationMixin, 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() + 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) @@ -38,15 +84,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): @@ -54,33 +98,39 @@ 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) 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""" + # 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) @@ -88,15 +138,252 @@ 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) + 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"]) + + 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"]) + + @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. 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"]) + + exclude = self.admin.get_exclude(self.request) + self.assertEqual(set(exclude), set()) + + def test_nonexistent_exclude(self): + """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"]) + 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, 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"]) + + 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"}) + + 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), {"field5", "field2", "field4"}) + class UtilsTestCase(TestCase): def setUp(self): 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/jdav_web/settings/local.py b/jdav_web/jdav_web/settings/local.py index f60cb8b3..0e363377 100644 --- a/jdav_web/jdav_web/settings/local.py +++ b/jdav_web/jdav_web/settings/local.py @@ -84,3 +84,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('custom_model_fields', model, default={}) for model in CUSTOM_MODELS} \ No newline at end of file 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 = { diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 639ed9ed..38dd1938 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -1368,7 +1368,7 @@ class KlettertreffAttendeeInline(admin.TabularInline): #} -class KlettertreffAdmin(admin.ModelAdmin): +class KlettertreffAdmin(CommonAdminMixin, admin.ModelAdmin): form = KlettertreffAdminForm exclude = [] inlines = [KlettertreffAttendeeInline]