From ad33d5db172b34cba2714be56f1c7b15895f71f2 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Sat, 11 Oct 2025 17:10:12 +0200 Subject: [PATCH] feat(members/admin): add training overview (#174) Add a new admin view for managing all trainings of members of the association. The subsections on training and activity categories are moved to a new trainings section. We also protect the `submitted` and `passed` fields of member trainings for the default permission set. Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/jdav_web/settings/components/jet.py | 7 ++- jdav_web/members/admin.py | 22 +++++++++ .../members/locale/de/LC_MESSAGES/django.po | 33 ++++++++++++- .../0044_membertraining_activity_and_more.py | 42 ++++++++++++++++ jdav_web/members/models.py | 25 ++++++++-- jdav_web/members/tests/basic.py | 49 ++++++++++++++++++- .../templates/admin/members/app_index.html | 15 ++++-- 7 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 jdav_web/members/migrations/0044_membertraining_activity_and_more.py diff --git a/jdav_web/jdav_web/settings/components/jet.py b/jdav_web/jdav_web/settings/components/jet.py index 720fe7d..415d720 100644 --- a/jdav_web/jdav_web/settings/components/jet.py +++ b/jdav_web/jdav_web/settings/components/jet.py @@ -11,13 +11,16 @@ JET_SIDE_MENU_ITEMS = [ {'name': 'group', 'permissions': ['members.view_group']}, {'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, - {'name': 'activitycategory', 'permissions': ['members.view_activitycategory']}, - {'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']}, ]}, {'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [ {'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, {'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']}, ]}, + {'label': 'Ausbildung', 'app_label': 'members', 'permissions': ['members.view_membertraining'], 'items': [ + {'name': 'membertraining', 'permissions': ['members.view_membertraining']}, + {'name': 'trainingcategory', 'permissions': ['members.view_trainingcategory']}, + {'name': 'activitycategory', 'permissions': ['members.view_activitycategory']}, + ]}, {'app_label': 'mailer', 'items': [ {'name': 'message', 'permissions': ['mailer.view_message']}, {'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']}, diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index fa27130..d9c36e4 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -108,15 +108,22 @@ class PermissionOnMemberInline(admin.StackedInline): class TrainingOnMemberInline(CommonAdminInlineMixin, admin.TabularInline): model = MemberTraining + description = _("Please enter all training courses and further education courses that you have already attended or will be attending soon. Please also upload your confirmation of participation so that the responsible person can fill in the 'Attended' and 'Passed' fields. If the activity selection does not match your training, please describe it in the comment field.") formfield_overrides = { TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 25})} } ordering = ("date",) extra = 1 + + field_change_permissions = { + 'participated': 'members.manage_success_trainings', + 'passed': 'members.manage_success_trainings', + } class EmergencyContactInline(CommonAdminInlineMixin, admin.TabularInline): model = EmergencyContact + description = _('Please enter at least one emergency contact with contact details here. These are necessary for crisis intervention during trips.') formfield_overrides = { TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} } @@ -1392,6 +1399,20 @@ class KlettertreffAdmin(admin.ModelAdmin): # ForeignKey: {'widget': apply_select2(forms.Select)} #} +class MemberTrainingAdminForm(forms.ModelForm): + class Meta: + model = MemberTraining + exclude = [] + + +class MemberTrainingAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): + form = MemberTrainingAdminForm + list_display = ['title', 'member', 'date', 'category', 'get_activities', 'participated', 'passed', 'certificate'] + search_fields = ['title'] + list_filter = (('date', DateFieldListFilter), 'category', 'passed', 'activity', 'member') + ordering = ('-date',) + + admin.site.register(Member, MemberAdmin) admin.site.register(MemberUnconfirmedProxy, MemberUnconfirmedAdmin) @@ -1402,3 +1423,4 @@ admin.site.register(MemberNoteList, MemberNoteListAdmin) admin.site.register(Klettertreff, KlettertreffAdmin) admin.site.register(ActivityCategory, ActivityCategoryAdmin) admin.site.register(TrainingCategory, TrainingCategoryAdmin) +admin.site.register(MemberTraining, MemberTrainingAdmin) diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index ed60f99..98e26d5 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-20 02:43+0200\n" +"POT-Creation-Date: 2025-10-10 18:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,29 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: members/admin.py +msgid "" +"Please enter all training courses and further education courses that you " +"have already attended or will be attending soon. Please also upload your " +"confirmation of participation so that the responsible person can fill in the " +"'Attended' and 'Passed' fields. If the activity selection does not match " +"your training, please describe it in the comment field." +msgstr "" +"Bitte trage alle Ausbildungen und Fortbildungen ein, die du bereits besucht " +"hast oder bald besuchst. Lade auch deine Teilnahmebestätigung hoch, damit " +"von der verantwortlichen Person die Felder 'Teilgenommen' und 'Bestanden' " +"gepflegt werden können. Wenn die Aktivitätsauswahl nicht zu deiner " +"Ausbildung passt, dann beschreibe sie im Kommentarfeld." + +#: members/admin.py +msgid "" +"Please enter at least one emergency contact with contact details here. These " +"are necessary for crisis intervention during trips." +msgstr "" +"Trage hier bitte mindestens einen Notfallkontakt mit Kontaktdaten ein. Diese " +"sind notwendig für die Krisenintervention auf Ausfahrten und bei " +"Veranstaltungen." + #: members/admin.py msgid "The entered IBAN is not valid." msgstr "Die eingegebene IBAN ist ungültig." @@ -1242,6 +1265,10 @@ msgstr "Bestanden" msgid "certificate of attendance" msgstr "Teilnahmebestätigung" +#: members/models.py +msgid "(no date)" +msgstr "(ohne Datum)" + #: members/models.py msgid "Training" msgstr "Fortbildung" @@ -2319,6 +2346,10 @@ msgstr "" "Danke %(prename)s für dein Interesse auf der Warteliste zu bleiben.\n" "Dein Platz wurde bestätigt." +#: members/tests/basic.py +msgid "Insufficient permissions." +msgstr "Unzureichende Berechtigungen." + #: members/tests/basic.py msgid "This field is required." msgstr "" diff --git a/jdav_web/members/migrations/0044_membertraining_activity_and_more.py b/jdav_web/members/migrations/0044_membertraining_activity_and_more.py new file mode 100644 index 0000000..0c82f57 --- /dev/null +++ b/jdav_web/members/migrations/0044_membertraining_activity_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.20 on 2025-10-10 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0043_waitinglist_permissions'), + ] + + operations = [ + migrations.AddField( + model_name='membertraining', + name='activity', + field=models.ManyToManyField(to='members.activitycategory', verbose_name='Activity'), + ), + migrations.AlterModelOptions( + name='membertraining', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('manage_success_trainings', 'Can edit the success status of trainings.'),), 'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'}, + ), + migrations.AlterField( + model_name='membertraining', + name='title', + field=models.CharField(max_length=150, verbose_name='Title'), + ), + migrations.AlterField( + model_name='membertraining', + name='member', + field=models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='traininigs', to='members.member', verbose_name='Member'), + ), + migrations.AlterField( + model_name='membertraining', + name='participated', + field=models.BooleanField(null=True, verbose_name='Participated'), + ), + migrations.AlterField( + model_name='membertraining', + name='passed', + field=models.BooleanField(null=True, verbose_name='Passed'), + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index d503aa9..a03ad10 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -2093,13 +2093,14 @@ class TrainingCategory(models.Model): class MemberTraining(CommonModel): """Represents a training planned or attended by a member.""" - member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs') - title = models.CharField(verbose_name=_('Title'), max_length=30) + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs', verbose_name=_('Member')) + title = models.CharField(verbose_name=_('Title'), max_length=150) date = models.DateField(verbose_name=_('Date'), null=True, blank=True) category = models.ForeignKey(TrainingCategory, on_delete=models.PROTECT, verbose_name=_('Category')) + activity = models.ManyToManyField(ActivityCategory, verbose_name=_('Activity')) comments = models.TextField(verbose_name=_('Comments'), blank=True) - participated = models.BooleanField(verbose_name=_('Participated')) - passed = models.BooleanField(verbose_name=_('Passed')) + participated = models.BooleanField(verbose_name=_('Participated'), null=True) + passed = models.BooleanField(verbose_name=_('Passed'), null=True) certificate = RestrictedFileField(verbose_name=_('certificate of attendance'), upload_to='training_forms', blank=True, @@ -2108,10 +2109,26 @@ class MemberTraining(CommonModel): 'image/jpeg', 'image/png', 'image/gif']) + + def __str__(self): + if self.date: + return self.title + ' ' + self.date.strftime('%d.%m.%Y') + return self.title + ' ' + str(_('(no date)')) + + def get_activities(self): + activity_string = ', '.join(a.name for a in self.activity.all()) + return activity_string + get_activities.short_description = _('Activities') + + class Meta(CommonModel.Meta): verbose_name = _('Training') verbose_name_plural = _('Trainings') + + permissions = ( + ('manage_success_trainings', 'Can edit the success status of trainings.'), + ) rules_permissions = { # sine this is used in an inline, the member and not the training is passed 'add_obj': is_oneself | has_global_perm('members.add_global_membertraining'), diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 6bb29df..821aa70 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -26,11 +26,12 @@ from members.models import Member, Group, PermissionMember, PermissionGroup, Fre MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\ Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\ - TrainingCategory, Person + TrainingCategory, Person, MemberTraining from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ MemberUnconfirmedAdmin, FilteredMemberFieldMixin,\ MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ - InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter + InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter,\ + MemberTrainingAdmin from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs, render_docx, pdf_add_attachments, scale_pdf_page_to_a4, scale_pdf_to_a4 from members.excel import generate_ljp_vbk from members.views import render_register_success, render_register_failed @@ -2506,6 +2507,50 @@ class TrainingCategoryTestCase(TestCase): def test_str(self): self.assertEqual(str(self.cat), 'school') + +class MemberTrainingTestCase(TestCase): + def setUp(self): + self.member_training = MemberTraining.objects.create( + member=Member.objects.create(**REGISTRATION_DATA), + category=TrainingCategory.objects.create(name='Test Training', permission_needed=False), + date=timezone.now().date() + ) + self.member_training_no_date = MemberTraining.objects.create( + member=Member.objects.create(**REGISTRATION_DATA), + category=TrainingCategory.objects.create(name='Test Training', permission_needed=False), + date=None + ) + + def test_str(self): + self.assertIn(self.member_training.date.strftime('%d.%m.%Y'), str(self.member_training)) + self.assertIn(str(_('(no date)')), str(self.member_training_no_date)) + + +class MemberTrainingAdminTestCase(AdminTestCase): + def setUp(self): + super().setUp(model=MemberTraining, admin=MemberTrainingAdmin) + self.member_training = MemberTraining.objects.create( + member=Member.objects.create(**REGISTRATION_DATA), + category=TrainingCategory.objects.create(name='Test Training', permission_needed=False), + date=timezone.now().date() + ) + self.activity = ActivityCategory.objects.create(name='Test Activity', + ljp_category='Sonstiges', description='Test') + self.member_training.activity.add(self.activity) + + def test_changelist(self): + c = self._login('superuser') + url = reverse('admin:members_membertraining_changelist') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_change(self): + c = self._login('superuser') + url = reverse('admin:members_membertraining_change', args=(self.member_training.pk,)) + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + class PermissionMemberGroupTestCase(BasicMemberTestCase): def setUp(self): super().setUp() diff --git a/jdav_web/templates/admin/members/app_index.html b/jdav_web/templates/admin/members/app_index.html index 73b9050..91b7ef5 100644 --- a/jdav_web/templates/admin/members/app_index.html +++ b/jdav_web/templates/admin/members/app_index.html @@ -107,18 +107,18 @@ Hier kannst du Gruppen anlegen und ändern. {% endif %} -{% if perms.members.change_activitycategory %} +{% if perms.members.change_membertraining %}
-

Sonstiges

+

Ausbildung und Aktivitäten

-Hier kannst du mögliche Aktivitäten und Fortbildungstypen festlegen. Diese bestimmen, welche +Hier kannst du Fortbildungen verwalten und mögliche Aktivitäten und Fortbildungstypen festlegen. Diese bestimmen, welche Aktivitäten bzw. Fortbildungen Nutzer:innen auswählen können.

@@ -130,6 +130,13 @@ Aktivitäten bzw. Fortbildungen Nutzer:innen auswählen können. + + + + +
- Aktivitäten + Fortbildungen
+ Aktivitäten +
{% endif %}