feat(members/admin): add training overview #174

Merged
christian.merten merged 57 commits from MK/training_tab into main 2 months ago

@ -11,13 +11,16 @@ JET_SIDE_MENU_ITEMS = [
{'name': 'group', 'permissions': ['members.view_group']}, {'name': 'group', 'permissions': ['members.view_group']},
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']}, {'name': 'membernotelist', 'permissions': ['members.view_membernotelist']},
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']}, {'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': [ {'label': 'Neue Mitglieder', 'app_label': 'members', 'permissions': ['members.view_memberunconfirmedproxy'], 'items': [
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']}, {'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']},
{'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']}, {'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': [ {'app_label': 'mailer', 'items': [
{'name': 'message', 'permissions': ['mailer.view_message']}, {'name': 'message', 'permissions': ['mailer.view_message']},
{'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']}, {'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']},

@ -108,15 +108,22 @@ class PermissionOnMemberInline(admin.StackedInline):
class TrainingOnMemberInline(CommonAdminInlineMixin, admin.TabularInline): class TrainingOnMemberInline(CommonAdminInlineMixin, admin.TabularInline):
model = MemberTraining 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 = { formfield_overrides = {
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 25})} TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 25})}
} }
ordering = ("date",) ordering = ("date",)
extra = 1 extra = 1
field_change_permissions = {
marius.klein marked this conversation as resolved
Review

Das führt dazu, dass wenn nicht Admins eine Fortbildung eintragen, ein "Integrity Error" geworfen wird, weil participated nicht null sein darf.

Ich denke am Besten ist es einfach im MemberTraining model für participated und passed null zu erlauben.

Das führt dazu, dass wenn nicht Admins eine Fortbildung eintragen, ein "Integrity Error" geworfen wird, weil `participated` nicht null sein darf. Ich denke am Besten ist es einfach im `MemberTraining` model für `participated` und `passed` null zu erlauben.
'participated': 'members.manage_success_trainings',
'passed': 'members.manage_success_trainings',
}
class EmergencyContactInline(CommonAdminInlineMixin, admin.TabularInline): class EmergencyContactInline(CommonAdminInlineMixin, admin.TabularInline):
model = EmergencyContact model = EmergencyContact
description = _('Please enter at least one emergency contact with contact details here. These are necessary for crisis intervention during trips.')
christian.merten marked this conversation as resolved
Review

das gehört hier eigentlich nicht rein, soll ich das noch rausnehmen und wann anders mal in ne merge request machen?

das gehört hier eigentlich nicht rein, soll ich das noch rausnehmen und wann anders mal in ne merge request machen?
Review

Ja habe es gesehen. Wäre natürlich noch besser es separat zu machen, aber ich glaube es ist okay so.

Ja habe es gesehen. Wäre natürlich noch besser es separat zu machen, aber ich glaube es ist okay so.
formfield_overrides = { formfield_overrides = {
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
} }
@ -1392,6 +1399,20 @@ class KlettertreffAdmin(admin.ModelAdmin):
# ForeignKey: {'widget': apply_select2(forms.Select)} # 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(Member, MemberAdmin)
admin.site.register(MemberUnconfirmedProxy, MemberUnconfirmedAdmin) admin.site.register(MemberUnconfirmedProxy, MemberUnconfirmedAdmin)
@ -1402,3 +1423,4 @@ admin.site.register(MemberNoteList, MemberNoteListAdmin)
admin.site.register(Klettertreff, KlettertreffAdmin) admin.site.register(Klettertreff, KlettertreffAdmin)
admin.site.register(ActivityCategory, ActivityCategoryAdmin) admin.site.register(ActivityCategory, ActivityCategoryAdmin)
admin.site.register(TrainingCategory, TrainingCategoryAdmin) admin.site.register(TrainingCategory, TrainingCategoryAdmin)
admin.site.register(MemberTraining, MemberTrainingAdmin)

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,6 +18,29 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\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 #: members/admin.py
msgid "The entered IBAN is not valid." msgid "The entered IBAN is not valid."
msgstr "Die eingegebene IBAN ist ungültig." msgstr "Die eingegebene IBAN ist ungültig."
@ -1242,6 +1265,10 @@ msgstr "Bestanden"
msgid "certificate of attendance" msgid "certificate of attendance"
msgstr "Teilnahmebestätigung" msgstr "Teilnahmebestätigung"
#: members/models.py
msgid "(no date)"
msgstr "(ohne Datum)"
#: members/models.py #: members/models.py
msgid "Training" msgid "Training"
msgstr "Fortbildung" msgstr "Fortbildung"
@ -2319,6 +2346,10 @@ msgstr ""
"Danke %(prename)s für dein Interesse auf der Warteliste zu bleiben.\n" "Danke %(prename)s für dein Interesse auf der Warteliste zu bleiben.\n"
"Dein Platz wurde bestätigt." "Dein Platz wurde bestätigt."
#: members/tests/basic.py
msgid "Insufficient permissions."
msgstr "Unzureichende Berechtigungen."
#: members/tests/basic.py #: members/tests/basic.py
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""

@ -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'),
),
]

@ -2093,13 +2093,14 @@ class TrainingCategory(models.Model):
class MemberTraining(CommonModel): class MemberTraining(CommonModel):
"""Represents a training planned or attended by a member.""" """Represents a training planned or attended by a member."""
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs') member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs', verbose_name=_('Member'))
title = models.CharField(verbose_name=_('Title'), max_length=30) title = models.CharField(verbose_name=_('Title'), max_length=150)
date = models.DateField(verbose_name=_('Date'), null=True, blank=True) date = models.DateField(verbose_name=_('Date'), null=True, blank=True)
category = models.ForeignKey(TrainingCategory, on_delete=models.PROTECT, verbose_name=_('Category')) 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) comments = models.TextField(verbose_name=_('Comments'), blank=True)
participated = models.BooleanField(verbose_name=_('Participated')) participated = models.BooleanField(verbose_name=_('Participated'), null=True)
passed = models.BooleanField(verbose_name=_('Passed')) passed = models.BooleanField(verbose_name=_('Passed'), null=True)
certificate = RestrictedFileField(verbose_name=_('certificate of attendance'), certificate = RestrictedFileField(verbose_name=_('certificate of attendance'),
upload_to='training_forms', upload_to='training_forms',
blank=True, blank=True,
@ -2108,10 +2109,26 @@ class MemberTraining(CommonModel):
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/gif']) '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): class Meta(CommonModel.Meta):
verbose_name = _('Training') verbose_name = _('Training')
verbose_name_plural = _('Trainings') verbose_name_plural = _('Trainings')
permissions = (
('manage_success_trainings', 'Can edit the success status of trainings.'),
)
rules_permissions = { rules_permissions = {
# sine this is used in an inline, the member and not the training is passed # 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'), 'add_obj': is_oneself | has_global_perm('members.add_global_membertraining'),

@ -26,11 +26,12 @@ from members.models import Member, Group, PermissionMember, PermissionGroup, Fre
MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\ MemberNoteList, NewMemberOnList, confirm_mail_by_key, EmergencyContact, MemberWaitingList,\
RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\ RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup, DIVERSE, MALE, FEMALE,\
Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\ Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\
TrainingCategory, Person TrainingCategory, Person, MemberTraining
from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\ from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\
MemberUnconfirmedAdmin, FilteredMemberFieldMixin,\ MemberUnconfirmedAdmin, FilteredMemberFieldMixin,\
MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\ 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.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.excel import generate_ljp_vbk
from members.views import render_register_success, render_register_failed from members.views import render_register_success, render_register_failed
@ -2506,6 +2507,50 @@ class TrainingCategoryTestCase(TestCase):
def test_str(self): def test_str(self):
self.assertEqual(str(self.cat), 'school') 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): class PermissionMemberGroupTestCase(BasicMemberTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()

@ -107,18 +107,18 @@ Hier kannst du Gruppen anlegen und ändern.
</div> </div>
{% endif %} {% endif %}
{% if perms.members.change_activitycategory %} {% if perms.members.change_membertraining %}
<br> <br>
<div class="app-members module current-app"> <div class="app-members module current-app">
<h2>Sonstiges</h2> <h2>Ausbildung und Aktivitäten</h2>
<p> <p>
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. Aktivitäten bzw. Fortbildungen Nutzer:innen auswählen können.
</p> </p>
<table> <table>
<tr> <tr>
<th scope="row"> <th scope="row">
<a href="{% url 'admin:members_activitycategory_changelist' %}">Aktivitäten</a> <a href="{% url 'admin:members_membertraining_changelist' %}">Fortbildungen</a>
</th> </th>
<td></td> <td></td>
<td></td> <td></td>
@ -130,6 +130,13 @@ Aktivitäten bzw. Fortbildungen Nutzer:innen auswählen können.
<td></td> <td></td>
<td></td> <td></td>
</tr> </tr>
<tr>
<th scope="row">
<a href="{% url 'admin:members_activitycategory_changelist' %}">Aktivitäten</a>
</th>
<td></td>
<td></td>
</tr>
</table> </table>
</div> </div>
{% endif %} {% endif %}

Loading…
Cancel
Save