From d7d3c57ca89a191bacf9b7b0a91034b67764678e Mon Sep 17 00:00:00 2001 From: Ben Samuelson Date: Thu, 21 Aug 2025 10:29:24 +0200 Subject: [PATCH 01/55] added default expiration time to the qualification to make it as versitile as possible --- ephios/core/models/users.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 1e0a57c94..7b3e7fbfd 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -294,6 +294,19 @@ class Qualification(Model): symmetrical=False, blank=True, ) + default_expiration_time = models.CharField( + max_length=254, + verbose_name=_("Default expiration format"), + help_text=_( + "The default expiration format for this qualification. " + "Leave empty to not set a default expiration format. " + "Format: DD.MM.YYYY, Set a specific date or use a relative format like +1 for years, +6 for month, etc. " + "You can do something like 30.06.+2 for 30th of June in two years. " + "Leave some with 00 to take the date of the qualification for this day/month/year. " + ), + null=True, + blank=True, + ) is_imported = models.BooleanField(verbose_name=_("imported"), default=True) objects = QualificationManager() From f6d940a7b898c15bd2ae4dc0d5b37e25a7adff88 Mon Sep 17 00:00:00 2001 From: Ben Samuelson Date: Thu, 21 Aug 2025 10:29:48 +0200 Subject: [PATCH 02/55] addind default expiration time field for QualificationForm --- ephios/plugins/qualification_management/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/plugins/qualification_management/forms.py b/ephios/plugins/qualification_management/forms.py index 8d4d441dd..e3a25deec 100644 --- a/ephios/plugins/qualification_management/forms.py +++ b/ephios/plugins/qualification_management/forms.py @@ -25,7 +25,7 @@ class QualificationForm(forms.ModelForm): class Meta: model = Qualification - fields = ["title", "uuid", "abbreviation", "category", "includes"] + fields = ["title", "uuid", "abbreviation", "category", "includes", "default_expiration_time"] widgets = {"includes": Select2MultipleWidget} help_texts = { "uuid": _( From 696c52fca640012c0cf7db9acb2489811eae9edb Mon Sep 17 00:00:00 2001 From: Ben Samuelson Date: Thu, 21 Aug 2025 13:19:32 +0200 Subject: [PATCH 03/55] added default expiratrion time for import --- ephios/plugins/qualification_management/importing.py | 2 +- ephios/plugins/qualification_management/serializers.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/qualification_management/importing.py b/ephios/plugins/qualification_management/importing.py index 8eccb48b3..1ae7bb7d5 100644 --- a/ephios/plugins/qualification_management/importing.py +++ b/ephios/plugins/qualification_management/importing.py @@ -21,7 +21,7 @@ def __init__(self, validated_data): "included_by": validated_data["included_by"], } self.object = Qualification( - **{key: validated_data[key] for key in ("title", "abbreviation", "uuid")}, + **{key: validated_data[key] for key in ("title", "abbreviation", "default_expiration_time", "uuid")}, ) self.category = QualificationCategory( **{key: validated_data["category"][key] for key in ("title", "uuid")}, diff --git a/ephios/plugins/qualification_management/serializers.py b/ephios/plugins/qualification_management/serializers.py index 4d91cc718..ab583c8fa 100644 --- a/ephios/plugins/qualification_management/serializers.py +++ b/ephios/plugins/qualification_management/serializers.py @@ -35,5 +35,6 @@ class Meta: "abbreviation", "includes", "included_by", + "default_expiration_time", "category", ] From 7a15c3459c5f2145c664fb9e3eb87a683558476a Mon Sep 17 00:00:00 2001 From: Ben Samuelson Date: Thu, 21 Aug 2025 13:55:11 +0200 Subject: [PATCH 04/55] added regex for input validation --- ephios/core/models/users.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 7b3e7fbfd..0f4a8ad6b 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -11,6 +11,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import Group, PermissionsMixin +from django.core.validators import RegexValidator from django.db import models, transaction from django.db.models import ( BooleanField, @@ -30,6 +31,7 @@ ) from django.db.models.functions import Lower, TruncDate from django.utils import timezone +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from ephios.extra.fields import EndOfDayDateTimeField @@ -275,6 +277,16 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) +expiration_format_validator = RegexValidator( + regex=( + r"^$|" # empty + r"^(?:\d{1,2}|0{1,2}|\+\d+)\." # day: 1–2 digits OR 0/00 OR +N + r"(?:\d{1,2}|0{1,2}|\+\d+)\." # month: 1–2 digits OR 0/00 OR +N + r"(?:\d{4}|0{1,2}|0000|\+\d+)$|" # year: 4 digits OR 0 OR 00 OR 0000 OR +N + r"^\+\d+$" # relative only: +N + ), + message=_("Invalid format."), +) class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") @@ -298,14 +310,19 @@ class Qualification(Model): max_length=254, verbose_name=_("Default expiration format"), help_text=_( - "The default expiration format for this qualification. " - "Leave empty to not set a default expiration format. " - "Format: DD.MM.YYYY, Set a specific date or use a relative format like +1 for years, +6 for month, etc. " - "You can do something like 30.06.+2 for 30th of June in two years. " - "Leave some with 00 to take the date of the qualification for this day/month/year. " + "Allowed are:
" + "- leave empty
" + "- DD.MM.YYYY (e.g. 31.12.2025)
" + "- +N (relative years/months/days)
" + "- Mixed like 30.06.+2 or 0.6.+1
" + "- Fully relative like +1.+1.+1
" + "- Placeholders:
" + "  • 0 or 00 for day/month
" + "  • 0, 00 or 0000 for year" ), null=True, blank=True, + validators=[expiration_format_validator], ) is_imported = models.BooleanField(verbose_name=_("imported"), default=True) @@ -330,7 +347,6 @@ def natural_key(self): natural_key.dependencies = ["core.QualificationCategory"] - register_model_for_logging( Qualification, ModelFieldsLogConfig(), From f92927a93f48e7d2acfddbb59d86332fb9c256d1 Mon Sep 17 00:00:00 2001 From: Ben Samuelson Date: Thu, 21 Aug 2025 14:17:38 +0200 Subject: [PATCH 05/55] adding translations for expiration time --- ephios/locale/de/LC_MESSAGES/django.po | 414 +++++++++++++------------ 1 file changed, 214 insertions(+), 200 deletions(-) diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index 1ee472ba0..9254b9c1b 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -7,9 +7,9 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-24 23:17+0200\n" +"POT-Creation-Date: 2025-08-21 14:00+0200\n" "PO-Revision-Date: 2025-07-24 23:17+0200\n" -"Last-Translator: Felix Rindt \n" +"Last-Translator: Ben Samuelson \n" "Language-Team: German \n" "Language: de\n" @@ -38,7 +38,7 @@ msgstr "Geltungsbereich" msgid "For security reasons, only select the scopes that are actually needed." msgstr "Aus Sicherheitsgründen nur den benötigten Geltungsbereich auswählen." -#: ephios/api/access/views.py:99 ephios/core/models/users.py:361 +#: ephios/api/access/views.py:99 ephios/core/models/users.py:390 msgid "expiration date" msgstr "Ablaufdatum" @@ -115,15 +115,14 @@ msgstr "API Token hinzufügen" #: ephios/api/templates/api/access_token_form.html:16 #: ephios/api/templates/oauth2_provider/application_form.html:27 #: ephios/core/forms/users.py:200 ephios/core/forms/users.py:443 -#: ephios/core/signup/forms.py:150 #: ephios/core/templates/core/disposition/disposition.html:125 -#: ephios/core/templates/core/event_detail.html:25 +#: ephios/core/templates/core/event_detail.html:26 #: ephios/core/templates/core/event_form.html:65 #: ephios/core/templates/core/eventtype_form.html:20 #: ephios/core/templates/core/identityprovider_form.html:16 #: ephios/core/templates/core/settings/settings_instance.html:10 #: ephios/core/templates/core/settings/settings_notifications.html:42 -#: ephios/core/templates/core/settings/settings_personal_data.html:50 +#: ephios/core/templates/core/settings/settings_personal_data.html:51 #: ephios/core/templates/core/shift_form.html:72 #: ephios/core/templates/core/userprofile_form.html:150 #: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:285 @@ -135,7 +134,6 @@ msgstr "Speichern" #: ephios/api/templates/api/access_token_form.html:18 #: ephios/api/templates/oauth2_provider/application_confirm_delete.html:23 #: ephios/api/templates/oauth2_provider/authorize.html:33 -#: ephios/core/signup/forms.py:161 #: ephios/core/templates/core/disposition/disposition.html:124 #: ephios/core/templates/core/event_form.html:68 #: ephios/core/templates/core/eventtype_form.html:21 @@ -341,7 +339,7 @@ msgstr "Geändert am" #: ephios/api/templates/oauth2_provider/application_detail.html:67 #: ephios/api/templates/oauth2_provider/application_list.html:21 -#: ephios/core/models/users.py:496 +#: ephios/core/models/users.py:525 msgid "User" msgstr "Benutzer" @@ -423,7 +421,7 @@ msgstr "Neue Anwendung" #: ephios/api/templates/oauth2_provider/application_list.html:20 #: ephios/core/pdf.py:58 -#: ephios/core/templates/core/settings/settings_personal_data.html:10 +#: ephios/core/templates/core/settings/settings_personal_data.html:11 #: ephios/core/templates/core/workinghours_list.html:28 #: ephios/plugins/complexsignup/models.py:18 #: ephios/plugins/federation/templates/federation/federation_settings.html:15 @@ -453,8 +451,8 @@ msgstr "Föderation verwalten" #: ephios/api/templates/oauth2_provider/application_list.html:46 #: ephios/core/templates/core/event_form.html:20 -#: ephios/core/templates/core/home.html:58 -#: ephios/core/templates/core/home.html:102 +#: ephios/core/templates/core/home.html:59 +#: ephios/core/templates/core/home.html:103 #: ephios/core/templates/core/workinghours_list.html:42 #: ephios/plugins/pages/templates/pages/page_list.html:21 msgid "View" @@ -644,7 +642,7 @@ msgstr "" msgid "Responsibles" msgstr "Verantwortliche" -#: ephios/core/forms/events.py:183 ephios/core/models/users.py:499 +#: ephios/core/forms/events.py:183 ephios/core/models/users.py:528 #: ephios/core/pdf.py:137 ephios/core/pdf.py:183 #: ephios/core/templates/core/fragments/shift_box_small.html:22 #: ephios/core/templates/core/userprofile_workinghours.html:25 @@ -743,8 +741,8 @@ msgstr "" "Benutzeraccounts bearbeiten, Gruppen, deren Rechte und Mitglieder ändern " "sowie Veranstaltungstypen und Qualifikationen definieren." -#: ephios/core/forms/users.py:145 ephios/core/models/users.py:240 -#: ephios/core/signals.py:232 +#: ephios/core/forms/users.py:145 ephios/core/models/users.py:242 +#: ephios/core/signals.py:228 #: ephios/core/templates/core/userprofile_list.html:9 #: ephios/core/templates/core/userprofile_list.html:15 msgid "Users" @@ -755,12 +753,12 @@ msgstr "Benutzer" msgid "Can change permissions and manage {platform}" msgstr "Kann Rechte ändern und {platform} verwalten" -#: ephios/core/forms/users.py:182 ephios/core/signals.py:229 -#: ephios/core/signals.py:236 ephios/core/signals.py:249 -#: ephios/core/signals.py:262 ephios/core/views/settings.py:26 +#: ephios/core/forms/users.py:182 ephios/core/signals.py:225 +#: ephios/core/signals.py:232 ephios/core/signals.py:245 +#: ephios/core/signals.py:258 ephios/core/views/settings.py:26 #: ephios/plugins/complexsignup/signals.py:34 -#: ephios/plugins/files/signals.py:19 -#: ephios/plugins/simpleresource/signals.py:34 +#: ephios/plugins/files/signals.py:25 +#: ephios/plugins/simpleresource/signals.py:44 msgid "Management" msgstr "Verwaltung" @@ -780,14 +778,14 @@ msgstr "" "Mindestens eine Gruppe muss Managementrechte haben. Setzen Sie diesen Status " "zunächst bei einer anderen Gruppe, bevor Sie ihn hier entfernen." -#: ephios/core/forms/users.py:249 ephios/core/signals.py:245 +#: ephios/core/forms/users.py:249 ephios/core/signals.py:241 #: ephios/core/templates/core/group_list.html:6 #: ephios/core/templates/core/group_list.html:11 -#: ephios/core/templates/core/settings/settings_personal_data.html:21 +#: ephios/core/templates/core/settings/settings_personal_data.html:22 msgid "Groups" msgstr "Gruppen" -#: ephios/core/forms/users.py:256 ephios/core/models/users.py:107 +#: ephios/core/forms/users.py:256 ephios/core/models/users.py:109 msgid "Administrator" msgstr "Administrator" @@ -901,7 +899,7 @@ msgid "to everyone including guests and federated users" msgstr "Nutzer dieser Organisation sowie Gäste und föderierte Nutzer" #: ephios/core/models/events.py:61 ephios/core/models/events.py:88 -#: ephios/core/models/users.py:254 ephios/core/models/users.py:281 +#: ephios/core/models/users.py:256 ephios/core/models/users.py:293 msgid "title" msgstr "Titel" @@ -1096,255 +1094,275 @@ msgstr "Platzhalter-Teilnahme" msgid "placeholder participations" msgstr "Platzhalter-Teilnahmen" -#: ephios/core/models/users.py:101 ephios/plugins/federation/models.py:140 +#: ephios/core/models/users.py:103 ephios/plugins/federation/models.py:140 #: ephios/plugins/guests/models.py:49 msgid "email address" msgstr "E-Mail-Adresse" -#: ephios/core/models/users.py:102 +#: ephios/core/models/users.py:104 msgid "Email address invalid" msgstr "E-Mail-Adresse ungültig" -#: ephios/core/models/users.py:103 +#: ephios/core/models/users.py:105 msgid "Active" msgstr "Aktiv" -#: ephios/core/models/users.py:104 +#: ephios/core/models/users.py:106 msgid "Visible" msgstr "Sichtbar" -#: ephios/core/models/users.py:109 ephios/plugins/federation/models.py:141 +#: ephios/core/models/users.py:111 ephios/plugins/federation/models.py:141 #: ephios/plugins/guests/models.py:50 msgid "name" msgstr "Name" -#: ephios/core/models/users.py:110 ephios/plugins/federation/models.py:142 +#: ephios/core/models/users.py:112 ephios/plugins/federation/models.py:142 #: ephios/plugins/guests/models.py:51 msgid "date of birth" msgstr "Geburtsdatum" -#: ephios/core/models/users.py:111 ephios/plugins/federation/models.py:143 +#: ephios/core/models/users.py:113 ephios/plugins/federation/models.py:143 #: ephios/plugins/guests/models.py:52 msgid "phone number" msgstr "Telefonnummer" -#: ephios/core/models/users.py:112 +#: ephios/core/models/users.py:114 msgid "calendar token" msgstr "Kalender-Token" -#: ephios/core/models/users.py:114 +#: ephios/core/models/users.py:116 msgid "preferred language" msgstr "bevorzugte Sprache" -#: ephios/core/models/users.py:133 ephios/core/models/users.py:359 +#: ephios/core/models/users.py:135 ephios/core/models/users.py:388 msgid "user profile" msgstr "Benutzerprofil" -#: ephios/core/models/users.py:134 +#: ephios/core/models/users.py:136 msgid "user profiles" msgstr "Benutzerprofile" -#: ephios/core/models/users.py:143 +#: ephios/core/models/users.py:145 msgid "User profile with this email address already exists." msgstr "Ein Nutzer-Account mit dieser E-Mail existiert bereits." -#: ephios/core/models/users.py:257 +#: ephios/core/models/users.py:259 msgid "Show qualifications of this category everywhere a user is presented" msgstr "" "Zeige Qualifikationen dieser Kategorie überall wo ein Nutzer angezeigt wird" -#: ephios/core/models/users.py:263 +#: ephios/core/models/users.py:265 msgid "qualification category" msgstr "Qualifikationskategorie" -#: ephios/core/models/users.py:264 +#: ephios/core/models/users.py:266 msgid "qualification categories" msgstr "Qualifikationskategorien" -#: ephios/core/models/users.py:282 +#: ephios/core/models/users.py:288 +msgid "Invalid format." +msgstr "Ungültiges Format." + +#: ephios/core/models/users.py:294 #: ephios/plugins/qualification_management/templates/core/qualification_list.html:29 msgid "Abbreviation" msgstr "Abkürzung" -#: ephios/core/models/users.py:287 +#: ephios/core/models/users.py:299 msgid "category" msgstr "Kategorie" -#: ephios/core/models/users.py:292 +#: ephios/core/models/users.py:304 msgid "Included" msgstr "Enthaltene" -#: ephios/core/models/users.py:293 +#: ephios/core/models/users.py:305 msgid "other qualifications that this qualification includes" msgstr "andere Qualifikationen, die in dieser enthalten sind" -#: ephios/core/models/users.py:297 +#: ephios/core/models/users.py:311 +msgid "Default expiration format" +msgstr "Standart Ablaufdatum" + +#: ephios/core/models/users.py:313 +msgid "" +"Allowed are:
- leave empty
- DD.MM.YYYY (e.g. 31.12.2025)
- +N " +"(relative years/months/days)
- Mixed like 30.06.+2 or 0.6.+1
- Fully " +"relative like +1.+1.+1
- Placeholders:
  • 0 or 00 for day/" +"month
  • 0, 00 or 0000 for year" +msgstr "" +"Erlaubt sind:
- Leer lassen
- DD.MM.YYYY (z.B. 31.12.2025)
- +N " +"(realives Jahr/Monat/Tag)
- Gemischt wie 30.06.+2 oder 0.6.+1
- Komplett " +"relativ wie +1.+1.+1
- Platzhalter:
  • 0 oder 00 für Tag/" +"Monat
  • 0, 00 oder 0000 für Jahr" + +#: ephios/core/models/users.py:327 msgid "imported" msgstr "importiert" -#: ephios/core/models/users.py:308 ephios/core/models/users.py:352 +#: ephios/core/models/users.py:338 ephios/core/models/users.py:381 msgid "qualification" msgstr "Qualifikation" -#: ephios/core/models/users.py:309 +#: ephios/core/models/users.py:339 msgid "qualifications" msgstr "Qualifikationen" -#: ephios/core/models/users.py:363 +#: ephios/core/models/users.py:392 #: ephios/core/templates/core/userprofile_form.html:72 msgid "externally managed" msgstr "extern verwaltet" -#: ephios/core/models/users.py:365 +#: ephios/core/models/users.py:394 #: ephios/core/templates/core/userprofile_form.html:71 msgid "The qualification was granted by an external system." msgstr "Die Qualifikation wurde durch ein externes System erteilt." -#: ephios/core/models/users.py:377 +#: ephios/core/models/users.py:406 #, python-brace-format msgid "{qualification} for {user}" msgstr "{qualification} für {user}" -#: ephios/core/models/users.py:383 +#: ephios/core/models/users.py:412 msgid "Qualification grant" msgstr "Qualifikationserteilung" -#: ephios/core/models/users.py:384 +#: ephios/core/models/users.py:413 msgid "Qualification grants" msgstr "Qualifikationserteilungen" -#: ephios/core/models/users.py:400 ephios/core/models/users.py:516 +#: ephios/core/models/users.py:429 ephios/core/models/users.py:545 msgid "affected user" msgstr "betroffener Benutzer" -#: ephios/core/models/users.py:406 +#: ephios/core/models/users.py:435 msgid "needs confirmation" msgstr "benötigt Bestätigung" -#: ephios/core/models/users.py:407 +#: ephios/core/models/users.py:436 msgid "executed" msgstr "ausgeführt" -#: ephios/core/models/users.py:408 +#: ephios/core/models/users.py:437 msgid "failed" msgstr "fehlgeschlagen" -#: ephios/core/models/users.py:409 +#: ephios/core/models/users.py:438 msgid "denied" msgstr "abgelehnt" -#: ephios/core/models/users.py:415 ephios/core/views/event.py:84 +#: ephios/core/models/users.py:444 ephios/core/views/event.py:84 msgid "State" msgstr "Status" -#: ephios/core/models/users.py:420 +#: ephios/core/models/users.py:449 msgid "Consequence" msgstr "Konsequenz" -#: ephios/core/models/users.py:421 +#: ephios/core/models/users.py:450 msgid "Consequences" msgstr "Konsequenzen" -#: ephios/core/models/users.py:437 +#: ephios/core/models/users.py:466 msgid "Consequence was executed already." msgstr "Konsequenz wurde bereits ausgeführt." -#: ephios/core/models/users.py:451 +#: ephios/core/models/users.py:480 #: ephios/core/templates/core/userprofile_workinghours.html:26 msgid "Reason" msgstr "Anlass" -#: ephios/core/models/users.py:465 +#: ephios/core/models/users.py:494 msgid "Consequence was executed or denied already." msgstr "Konsequenz wurde bereits ausgeführt oder abgelehnt." -#: ephios/core/models/users.py:497 +#: ephios/core/models/users.py:526 msgid "Hours of work" msgstr "Arbeitsstunden" -#: ephios/core/models/users.py:498 +#: ephios/core/models/users.py:527 msgid "Occasion" msgstr "Anlass" -#: ephios/core/models/users.py:503 ephios/core/models/users.py:504 -#: ephios/core/signals.py:225 +#: ephios/core/models/users.py:532 ephios/core/models/users.py:533 +#: ephios/core/signals.py:221 #: ephios/core/templates/core/userprofile_list.html:96 #: ephios/core/templates/core/workinghours_list.html:9 #: ephios/core/templates/core/workinghours_list.html:14 -#: ephios/templates/base.html:138 +#: ephios/templates/base.html:139 msgid "Working hours" msgstr "Arbeitsstunden" -#: ephios/core/models/users.py:519 +#: ephios/core/models/users.py:548 msgid "read" msgstr "gelesen" -#: ephios/core/models/users.py:522 +#: ephios/core/models/users.py:551 msgid "processing completed" msgstr "Verarbeitung abgeschlossen" -#: ephios/core/models/users.py:524 +#: ephios/core/models/users.py:553 msgid "" "All enabled notification backends have processed this notification when flag " "is set" msgstr "" "Alle Notification-Backends haben diese Benachrichtung verarbeitet falls aktiv" -#: ephios/core/models/users.py:532 +#: ephios/core/models/users.py:561 msgid "" "List of slugs of notification backends that have processed this notification" msgstr "" "Liste von Slugs aller Notification-Backends die diese Benachrichtigung " "verarbeitet haben" -#: ephios/core/models/users.py:560 +#: ephios/core/models/users.py:589 #, python-brace-format msgid "{subject} for {user}" msgstr "{subject} für {user}" -#: ephios/core/models/users.py:560 ephios/plugins/guests/models.py:129 +#: ephios/core/models/users.py:589 msgid "Guest" msgstr "Gast" -#: ephios/core/models/users.py:578 +#: ephios/core/models/users.py:610 msgid "internal name" msgstr "Interner Name" -#: ephios/core/models/users.py:579 +#: ephios/core/models/users.py:611 msgid "Internal name for this provider." msgstr "Interner Name für diesen Provider." -#: ephios/core/models/users.py:584 +#: ephios/core/models/users.py:616 msgid "label" msgstr "Titel" -#: ephios/core/models/users.py:585 +#: ephios/core/models/users.py:617 msgid "The label displayed to users attempting to log in with this provider." msgstr "" "Der Titel, der Benutzen angezeigt wird, die sich mit diesem Provider " "einloggen möchten." -#: ephios/core/models/users.py:589 +#: ephios/core/models/users.py:621 msgid "client id" msgstr "Client-ID" -#: ephios/core/models/users.py:590 +#: ephios/core/models/users.py:622 msgid "Your client id provided by the OIDC provider." msgstr "Ihre Client-ID (vom OIDC-Provider zur Verfügung gestellt)." -#: ephios/core/models/users.py:594 +#: ephios/core/models/users.py:626 msgid "client secret" msgstr "Client-Secret" -#: ephios/core/models/users.py:595 +#: ephios/core/models/users.py:627 msgid "Your client secret provided by the OIDC provider." msgstr "Ihr Client-Secret (vom OIDC-Provider zur Verfügung gestellt)." -#: ephios/core/models/users.py:600 +#: ephios/core/models/users.py:632 msgid "scopes" msgstr "Geltungsbereich" -#: ephios/core/models/users.py:602 +#: ephios/core/models/users.py:634 msgid "" "The OIDC scopes to request from the provider. Separate multiple scopes with " "spaces. Use the default value if you are unsure." @@ -1353,45 +1371,45 @@ msgstr "" "Mehrere Werte mit Leerzeichen trennen. Nutzen Sie den Standardwert, wenn Sie " "unsicher sind." -#: ephios/core/models/users.py:606 +#: ephios/core/models/users.py:638 msgid "authorization endpoint" msgstr "authorization endpoint" -#: ephios/core/models/users.py:606 +#: ephios/core/models/users.py:638 msgid "The OIDC authorization endpoint." msgstr "Der Endpunkt zur Authentifizierung." -#: ephios/core/models/users.py:609 +#: ephios/core/models/users.py:641 msgid "token endpoint" msgstr "token endpoint" -#: ephios/core/models/users.py:609 +#: ephios/core/models/users.py:641 msgid "The OIDC token endpoint." msgstr "Der Token-Endpoint." -#: ephios/core/models/users.py:612 +#: ephios/core/models/users.py:644 msgid "user endpoint" msgstr "Benutzer-Endpoint" -#: ephios/core/models/users.py:612 +#: ephios/core/models/users.py:644 msgid "The OIDC user endpoint." msgstr "Der Endpunkt für Benutzerprofile." -#: ephios/core/models/users.py:617 +#: ephios/core/models/users.py:649 msgid "end session endpoint" msgstr "end session endpoint" -#: ephios/core/models/users.py:618 +#: ephios/core/models/users.py:650 msgid "The OIDC end session endpoint, if supported by your provider." msgstr "" "Der Session-Beendigungs-Endpunkt (sofern von Ihrem OIDC-Provider " "unterstützt)." -#: ephios/core/models/users.py:623 +#: ephios/core/models/users.py:655 msgid "JWKS endpoint" msgstr "JWKS endpoint" -#: ephios/core/models/users.py:625 +#: ephios/core/models/users.py:657 msgid "" "The OIDC JWKS endpoint. A less secure signing method will be used if this is " "not provided." @@ -1399,21 +1417,21 @@ msgstr "" "Der Zertifikatsendpunkt. Ein weniger sicheres Signierverfahren wird " "verwendet, wenn kein Wert angegeben wird." -#: ephios/core/models/users.py:631 +#: ephios/core/models/users.py:663 msgid "default groups" msgstr "Standard-Gruppen" -#: ephios/core/models/users.py:632 +#: ephios/core/models/users.py:664 msgid "The groups that users logging in with this provider will be added to." msgstr "" "Die Gruppen, zu denen Benutzer hinzugefügt werden, die sich mit diesem " "Provider einloggen." -#: ephios/core/models/users.py:637 +#: ephios/core/models/users.py:669 msgid "group claim" msgstr "OIDC-Claim für Gruppen" -#: ephios/core/models/users.py:639 +#: ephios/core/models/users.py:671 msgid "" "The name of the claim that contains the user's groups. Leave empty if your " "provider does not support this. You can use dot notation to access nested " @@ -1423,11 +1441,11 @@ msgstr "" "Feld frei, wenn Ihr Provider dies nicht unterstützt. Sie können per Punkt-" "Notation auf verschachtelte Claims zugreifen." -#: ephios/core/models/users.py:645 +#: ephios/core/models/users.py:677 msgid "create missing groups" msgstr "Fehlende Gruppen erstellen" -#: ephios/core/models/users.py:647 +#: ephios/core/models/users.py:679 msgid "" "If enabled, groups from the claim defined above that do not exist yet will " "be created automatically." @@ -1435,11 +1453,11 @@ msgstr "" "Legt fest, ob bisher noch nicht existierende Gruppen aus dem Gruppen-Claim " "automatisch erstellt werden sollen." -#: ephios/core/models/users.py:653 +#: ephios/core/models/users.py:685 msgid "qualification claim" msgstr "Qualifikations-Claim" -#: ephios/core/models/users.py:655 +#: ephios/core/models/users.py:687 msgid "" "The name of the claim that contains the user's qualifications. Leave empty " "if your provider does not support this. You can use dot notation to access " @@ -1450,11 +1468,11 @@ msgstr "" "Sie dieses Feld frei, wenn Ihr Provider dies nicht unterstützt. Sie können " "per Punkt-Notation auf verschachtelte Claims zugreifen." -#: ephios/core/models/users.py:661 +#: ephios/core/models/users.py:693 msgid "qualification codename to uuid" msgstr "Qualifikations-Codename zu UUID" -#: ephios/core/models/users.py:663 +#: ephios/core/models/users.py:695 msgid "" "A json encoded dictionary containing mappings of qualification names as they " "appear in thequalification claim to the qualification uuid. If a key is not " @@ -1464,23 +1482,23 @@ msgstr "" "Claim auftauchen, zu Qualifikations-UUIDs übersetzt. Wenn ein Key nicht " "gefunden wird, wird versucht den Key selbst als UUID zu nutzen." -#: ephios/core/models/users.py:671 +#: ephios/core/models/users.py:703 #, python-brace-format msgid "Identity provider {label}" msgstr "Identitätsprovider {label}" -#: ephios/core/models/users.py:674 +#: ephios/core/models/users.py:706 msgid "Identity provider" msgstr "Identitätsprovider" -#: ephios/core/models/users.py:675 +#: ephios/core/models/users.py:707 #: ephios/core/templates/core/identityprovider_list.html:5 #: ephios/core/views/settings.py:78 msgid "Identity providers" msgstr "Identitätsprovider" #: ephios/core/pdf.py:59 -#: ephios/core/templates/core/settings/settings_personal_data.html:31 +#: ephios/core/templates/core/settings/settings_personal_data.html:32 #: ephios/core/templates/core/userprofile_form.html:53 #: ephios/plugins/baseshiftstructures/templates/baseshiftstructures/qualification_mix/configuration_form.html:13 #: ephios/plugins/guests/models.py:55 @@ -1622,7 +1640,6 @@ msgid "A new profile has been created" msgstr "Ein neues Benutzerprofil wurde erstellt" #: ephios/core/services/notifications/types.py:157 -#, python-brace-format msgid "Welcome to {}!" msgstr "Willkommen bei {}!" @@ -1666,9 +1683,9 @@ msgstr "" #: ephios/core/services/notifications/types.py:231 #: ephios/core/services/notifications/types.py:249 -#: ephios/core/services/notifications/types.py:382 -#: ephios/core/services/notifications/types.py:547 -#: ephios/core/services/notifications/types.py:618 +#: ephios/core/services/notifications/types.py:388 +#: ephios/core/services/notifications/types.py:553 +#: ephios/core/services/notifications/types.py:624 #: ephios/core/templates/core/mails/new_event.html:35 #: ephios/core/templates/core/userprofile_workinghours.html:47 msgid "View event" @@ -1699,10 +1716,6 @@ msgid "Your participation for {shift} has been rejected by a responsible user." msgstr "" "Ihre Teilnahme an {shift} wurde von einer verantwortlichen Person abgelehnt." -#: ephios/core/services/notifications/types.py:308 -msgid "Your time is" -msgstr "Ihre Zeit ist" - #: ephios/core/services/notifications/types.py:314 msgid "A confirmed participation of yours has been tweaked by a responsible" msgstr "" @@ -1720,124 +1733,124 @@ msgid "Your participation for {shift} has been tweaked by a responsible user." msgstr "" "Ihre Teilnahme an {shift} wurde von einer verantwortlichen Person angepasst." -#: ephios/core/services/notifications/types.py:385 +#: ephios/core/services/notifications/types.py:391 #: ephios/core/templates/core/disposition/disposition.html:8 -#: ephios/core/templates/core/fragments/shift_box_big.html:119 -#: ephios/core/templates/core/home.html:106 +#: ephios/core/templates/core/fragments/shift_box_big.html:117 +#: ephios/core/templates/core/home.html:107 msgid "Disposition" msgstr "Disposition" -#: ephios/core/services/notifications/types.py:394 +#: ephios/core/services/notifications/types.py:400 #, python-brace-format msgid "{participant} signed up for {shift}" msgstr "{participant} nimmt teil an {shift}" -#: ephios/core/services/notifications/types.py:396 +#: ephios/core/services/notifications/types.py:402 #, python-brace-format msgid "{participant} has requested to participate in {shift}" msgstr "Teilnahme angefragt von {participant} für {shift}" -#: ephios/core/services/notifications/types.py:399 +#: ephios/core/services/notifications/types.py:405 #, python-brace-format msgid "{participant} declined to participate in {shift}" msgstr "{participant} hat abgesagt für {shift}" -#: ephios/core/services/notifications/types.py:402 +#: ephios/core/services/notifications/types.py:408 #, python-brace-format msgid "{participant} was rejected for {shift}" msgstr "{participant} wurde abgelehnt für {shift}" -#: ephios/core/services/notifications/types.py:405 +#: ephios/core/services/notifications/types.py:411 #, python-brace-format msgid "{participant} is being dispatched for {shift}" msgstr "{participant} wird disponiert in {shift}" -#: ephios/core/services/notifications/types.py:418 +#: ephios/core/services/notifications/types.py:424 msgid "A participation for your event awaits disposition" msgstr "Eine Teilnahme an Ihrer Veranstaltung wartet auf Disposition" -#: ephios/core/services/notifications/types.py:432 +#: ephios/core/services/notifications/types.py:438 #, python-brace-format msgid "{participant} requested participating in {shift}." msgstr "{participant} hat eine Teilnahme an {shift} angefragt." -#: ephios/core/services/notifications/types.py:442 +#: ephios/core/services/notifications/types.py:448 msgid "A participation for your event has changed state" msgstr "Der Status einer Teilnahme an Ihrer Veranstaltung hat sich geändert" -#: ephios/core/services/notifications/types.py:456 +#: ephios/core/services/notifications/types.py:462 #, python-brace-format msgid "The participation of {participant} for {shift} is now {state}." msgstr "Die Teilnahme von {participant} an {shift} ist nun {state}." -#: ephios/core/services/notifications/types.py:469 +#: ephios/core/services/notifications/types.py:475 msgid "A participant declined after having been confirmed for your event" msgstr "" "Ein Teilnehmer hat abgesagt, nachdem er für Ihre Veranstaltung bestätigt " "wurde" -#: ephios/core/services/notifications/types.py:476 +#: ephios/core/services/notifications/types.py:482 #, python-brace-format msgid "{participant} declined their participation in {shift}." msgstr "{participant} hat abgesagt für {shift}." -#: ephios/core/services/notifications/types.py:485 +#: ephios/core/services/notifications/types.py:491 msgid "A confirmed participant altered their participation" msgstr "Ein bestätigter Teilnehmer hat seine Teilnahme angepasst" -#: ephios/core/services/notifications/types.py:492 +#: ephios/core/services/notifications/types.py:498 #, python-brace-format msgid "Participation altered for {event}" msgstr "Teilnahme angepasst für {event}" -#: ephios/core/services/notifications/types.py:504 +#: ephios/core/services/notifications/types.py:510 #, python-brace-format msgid "{participant} altered their participation in {shift}." msgstr "{participant} hat die Teilnahme an {shift} angepasst." -#: ephios/core/services/notifications/types.py:513 +#: ephios/core/services/notifications/types.py:519 msgid "An event has vacant spots" msgstr "Für eine Veranstaltung werden noch Teilnehmende gesucht" -#: ephios/core/services/notifications/types.py:533 +#: ephios/core/services/notifications/types.py:539 #, python-brace-format msgid "Help needed for {title}" msgstr "Unterstützung benötigt für {title}" -#: ephios/core/services/notifications/types.py:538 +#: ephios/core/services/notifications/types.py:544 #, python-brace-format msgid "Your support is needed for {title} ({start} - {end})." msgstr "Ihre Unterstützung wird benötigt für {title} ({start} - {end})." -#: ephios/core/services/notifications/types.py:552 +#: ephios/core/services/notifications/types.py:558 msgid "Message to all participants" msgstr "Nachricht an alle Teilnehmenden" -#: ephios/core/services/notifications/types.py:600 +#: ephios/core/services/notifications/types.py:606 #, python-brace-format msgid "Information regarding {title}" msgstr "Informationen zu {title}" -#: ephios/core/services/notifications/types.py:614 +#: ephios/core/services/notifications/types.py:620 msgid "View message" msgstr "Nachricht anzeigen" -#: ephios/core/services/notifications/types.py:630 -#: ephios/core/services/notifications/types.py:640 +#: ephios/core/services/notifications/types.py:636 +#: ephios/core/services/notifications/types.py:646 msgid "Your request has been approved" msgstr "Ihre Anfrage wurde akzeptiert" -#: ephios/core/services/notifications/types.py:645 +#: ephios/core/services/notifications/types.py:651 #, python-brace-format msgid "\"{consequence}\" has been approved." msgstr "\"{consequence}\" wurde akzeptiert." -#: ephios/core/services/notifications/types.py:650 -#: ephios/core/services/notifications/types.py:660 +#: ephios/core/services/notifications/types.py:656 +#: ephios/core/services/notifications/types.py:666 msgid "Your request has been denied" msgstr "Ihre Anfrage wurde abgelehnt" -#: ephios/core/services/notifications/types.py:665 +#: ephios/core/services/notifications/types.py:671 #, python-brace-format msgid "\"{consequence}\" has been denied." msgstr "\"{consequence}\" wurde abgelehnt." @@ -1861,10 +1874,10 @@ msgstr "Ihr {platform}-Passwort wurde geändert" msgid "Your password has been changed" msgstr "Ihr Passwort wurde geändert" -#: ephios/core/signals.py:258 +#: ephios/core/signals.py:254 #: ephios/core/templates/core/settings/settings_base.html:6 #: ephios/core/templates/core/settings/settings_base.html:11 -#: ephios/templates/base.html:142 +#: ephios/templates/base.html:143 msgid "Settings" msgstr "Einstellungen" @@ -2011,13 +2024,13 @@ msgstr "Anmeldeaktion" #: ephios/core/signup/forms.py:134 #: ephios/core/templates/core/disposition/fragment_participation.html:53 -#: ephios/core/templates/core/fragments/shift_box_big.html:93 -#: ephios/core/templates/core/fragments/shift_box_big.html:101 +#: ephios/core/templates/core/fragments/shift_box_big.html:91 +#: ephios/core/templates/core/fragments/shift_box_big.html:99 msgid "Customize" msgstr "Anpassen" -#: ephios/core/signup/forms.py:135 ephios/core/signup/forms.py:167 -#: ephios/core/templates/core/fragments/shift_box_big.html:107 +#: ephios/core/signup/forms.py:135 +#: ephios/core/templates/core/fragments/shift_box_big.html:105 msgid "Decline" msgstr "Absagen" @@ -2063,7 +2076,7 @@ msgstr "Möchten Sie die folgenden Veranstaltungen wirklich löschen?" #: ephios/core/templates/core/event_confirm_delete.html:6 #: ephios/core/templates/core/event_confirm_delete.html:11 -#: ephios/core/templates/core/event_detail.html:60 +#: ephios/core/templates/core/event_detail.html:61 msgid "Delete event" msgstr "Veranstaltung löschen" @@ -2074,11 +2087,11 @@ msgstr "Möchten Sie die Veranstaltung \"%(object)s\" wirklich löschen?" #: ephios/core/templates/core/event_copy.html:7 #: ephios/core/templates/core/event_copy.html:21 -#: ephios/core/templates/core/event_detail.html:69 +#: ephios/core/templates/core/event_detail.html:70 msgid "Copy event" msgstr "Veranstaltung kopieren" -#: ephios/core/templates/core/event_detail.html:24 +#: ephios/core/templates/core/event_detail.html:25 msgid "" "This event has not been saved! If you are done editing the event, you can " "save it." @@ -2086,36 +2099,36 @@ msgstr "" "Diese Veranstaltung wurde noch nicht gespeichert! Wenn Sie mit dem " "Bearbeiten der Veranstaltung fertig sind, können Sie sie speichern." -#: ephios/core/templates/core/event_detail.html:31 +#: ephios/core/templates/core/event_detail.html:32 msgid "This event has not been saved! Please add a shift to save this event." msgstr "" "Diese Veranstaltung wurde noch nicht gespeichert! Bitte fügen Sie eine " "Schicht hinzu, um sie zu speichern." -#: ephios/core/templates/core/event_detail.html:49 +#: ephios/core/templates/core/event_detail.html:50 #: ephios/core/templates/core/event_form.html:10 #: ephios/core/templates/core/event_form.html:26 msgid "Edit event" msgstr "Veranstaltung bearbeiten" -#: ephios/core/templates/core/event_detail.html:54 +#: ephios/core/templates/core/event_detail.html:55 #: ephios/core/templates/core/shift_form.html:82 msgid "Add another shift" msgstr "Weitere Schicht hinzufügen" -#: ephios/core/templates/core/event_detail.html:75 +#: ephios/core/templates/core/event_detail.html:76 msgid "Download PDF" msgstr "PDF herunterladen" -#: ephios/core/templates/core/event_detail.html:80 +#: ephios/core/templates/core/event_detail.html:81 msgid "Send notifications" msgstr "Benachrichtigungen senden" -#: ephios/core/templates/core/event_detail.html:87 +#: ephios/core/templates/core/event_detail.html:88 msgid "View edit history" msgstr "Änderungsverlauf" -#: ephios/core/templates/core/event_detail.html:113 +#: ephios/core/templates/core/event_detail.html:114 #: ephios/core/templates/core/fragments/event_list_list_mode.html:121 #: ephios/core/templates/core/mails/new_event.html:18 #: ephios/core/templates/core/workinghours_list.html:18 @@ -2124,17 +2137,17 @@ msgstr "Änderungsverlauf" msgid "to" msgstr "bis" -#: ephios/core/templates/core/event_detail.html:127 +#: ephios/core/templates/core/event_detail.html:128 #: ephios/core/templates/core/fragments/event_list_list_mode.html:80 msgid "Event has not been made visible to any groups." msgstr "Veranstaltung wurde für keine Gruppe sichtbar gemacht." -#: ephios/core/templates/core/event_detail.html:130 +#: ephios/core/templates/core/event_detail.html:131 #: ephios/core/templates/core/fragments/event_list_list_mode.html:78 msgid "Viewable by" msgstr "Sichtbar für" -#: ephios/core/templates/core/event_detail.html:149 +#: ephios/core/templates/core/event_detail.html:147 msgid "No shifts" msgstr "Keine Schichten" @@ -2161,7 +2174,7 @@ msgstr "Weiter" #: ephios/core/templates/core/event_list.html:11 #: ephios/core/templates/core/event_list.html:22 ephios/core/views/pwa.py:67 -#: ephios/templates/base.html:98 +#: ephios/templates/base.html:99 msgid "Events" msgstr "Veranstaltungen" @@ -2258,24 +2271,24 @@ msgstr[1] "%(confirmed)s bestätigte Teilnehmende." msgid "Your requests" msgstr "Ihre Anfragen" -#: ephios/core/templates/core/fragments/shift_box_big.html:17 +#: ephios/core/templates/core/fragments/shift_box_big.html:18 msgid "participants needed" msgstr "benötigt Teilnehmende" -#: ephios/core/templates/core/fragments/shift_box_big.html:20 +#: ephios/core/templates/core/fragments/shift_box_big.html:21 msgid "enough participants" msgstr "ausreichend Teilnehmende" -#: ephios/core/templates/core/fragments/shift_box_big.html:23 +#: ephios/core/templates/core/fragments/shift_box_big.html:24 #: ephios/core/templates/core/fragments/signup_stats_indicator.html:21 msgid "full" msgstr "voll" -#: ephios/core/templates/core/fragments/shift_box_big.html:26 +#: ephios/core/templates/core/fragments/shift_box_big.html:27 msgid "unit optional" msgstr "Einheit optional" -#: ephios/core/templates/core/fragments/shift_box_big.html:129 +#: ephios/core/templates/core/fragments/shift_box_big.html:127 msgid "Signup ends" msgstr "Anmeldung endet" @@ -2379,38 +2392,38 @@ msgstr "Benutzer bearbeiten" msgid "change permissions" msgstr "Berechtigungen ändern" -#: ephios/core/templates/core/home.html:11 ephios/templates/base.html:94 +#: ephios/core/templates/core/home.html:12 ephios/templates/base.html:95 msgid "Home" msgstr "Startseite" -#: ephios/core/templates/core/home.html:20 +#: ephios/core/templates/core/home.html:21 #, python-format msgid "Welcome to %(organization_name)s!" msgstr "Willkommen bei %(organization_name)s!" -#: ephios/core/templates/core/home.html:34 +#: ephios/core/templates/core/home.html:35 msgid "Your upcoming events" msgstr "Anstehende Schichten" -#: ephios/core/templates/core/home.html:64 +#: ephios/core/templates/core/home.html:65 msgctxt "empty list of upcoming shifts" msgid "None yet" msgstr "Noch keine" -#: ephios/core/templates/core/home.html:66 +#: ephios/core/templates/core/home.html:67 msgid "View events" msgstr "Veranstaltungen ansehen" -#: ephios/core/templates/core/home.html:78 +#: ephios/core/templates/core/home.html:79 msgid "Events with requested participations" msgstr "Veranstaltungen mit angefragten Teilnahmen" -#: ephios/core/templates/core/home.html:124 +#: ephios/core/templates/core/home.html:125 #: ephios/core/templates/core/logentry_list.html:15 msgid "Edit history" msgstr "Änderungsverlauf" -#: ephios/core/templates/core/home.html:127 +#: ephios/core/templates/core/home.html:128 msgid "View everything" msgstr "Alles ansehen" @@ -2505,7 +2518,7 @@ msgid "Forgot your password?" msgstr "Passwort vergessen?" #: ephios/core/templates/core/mails/base.html:152 ephios/templates/500.html:54 -#: ephios/templates/base.html:181 +#: ephios/templates/base.html:182 #, python-format msgid "powered by %(brand)s" msgstr "powered by %(brand)s" @@ -2546,7 +2559,7 @@ msgstr "Andere Benachrichtigungen anzeigen" #: ephios/core/templates/core/notification_list.html:6 #: ephios/core/templates/core/notification_list.html:11 #: ephios/core/templates/core/settings/settings_notifications.html:12 -#: ephios/core/views/settings.py:38 ephios/templates/base.html:140 +#: ephios/core/views/settings.py:38 ephios/templates/base.html:141 msgid "Notifications" msgstr "Benachrichtigungen" @@ -2642,37 +2655,37 @@ msgstr "" "Sie können einstellen, zu welchen Anlässen Sie Benachrichtigungen erhalten " "möchten:" -#: ephios/core/templates/core/settings/settings_personal_data.html:8 +#: ephios/core/templates/core/settings/settings_personal_data.html:9 #: ephios/core/views/settings.py:33 msgid "Personal data" msgstr "Persönliche Daten" -#: ephios/core/templates/core/settings/settings_personal_data.html:12 +#: ephios/core/templates/core/settings/settings_personal_data.html:13 msgid "E-Mail address" msgstr "E-Mail-Adresse" -#: ephios/core/templates/core/settings/settings_personal_data.html:14 +#: ephios/core/templates/core/settings/settings_personal_data.html:15 msgid "Date of birth" msgstr "Geburtsdatum" -#: ephios/core/templates/core/settings/settings_personal_data.html:16 +#: ephios/core/templates/core/settings/settings_personal_data.html:17 msgid "Phone number" msgstr "Telefonnummer" -#: ephios/core/templates/core/settings/settings_personal_data.html:26 +#: ephios/core/templates/core/settings/settings_personal_data.html:27 msgid "You are not a member of any group." msgstr "Sie sind in keiner Gruppe Mitglied." -#: ephios/core/templates/core/settings/settings_personal_data.html:37 +#: ephios/core/templates/core/settings/settings_personal_data.html:38 #: ephios/extra/templates/extra/widgets/recurrence_picker.html:154 msgid "until" msgstr "bis" -#: ephios/core/templates/core/settings/settings_personal_data.html:41 +#: ephios/core/templates/core/settings/settings_personal_data.html:42 msgid "You have not been assigned any qualificiations." msgstr "Ihnen wurden keine Qualifikationen zugewiesen." -#: ephios/core/templates/core/settings/settings_personal_data.html:46 +#: ephios/core/templates/core/settings/settings_personal_data.html:47 msgid "Language" msgstr "Sprache" @@ -3521,7 +3534,6 @@ msgid "This plugins adds the standard signup flows." msgstr "Dieses Plugin fügt grundlegende Anmeldeverfahren hinzu." #: ephios/plugins/basesignupflows/flow/coupled.py:28 -#, python-brace-format msgid "Participation is coupled to {}." msgstr "Teilnahme ist gekoppelt an {}." @@ -3679,12 +3691,6 @@ msgstr "jeder in {block} benötigt {qualifications}" msgid "at least {at_least} on {block} need {qualifications}" msgstr "mindestens {at_least} in {block} benötigen {qualifications}" -#: ephios/plugins/complexsignup/models.py:86 -#: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:81 -#: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:242 -msgid "and" -msgstr "und" - #: ephios/plugins/complexsignup/models.py:90 msgid "qualification requirement" msgstr "Erforderliche Qualifikation" @@ -3821,6 +3827,11 @@ msgstr "Bitte geben Sie einen Namen an." msgid "qualified as" msgstr "qualifiziert als" +#: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:81 +#: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:242 +msgid "and" +msgstr "und" + #: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:86 #: ephios/plugins/complexsignup/templates/complexsignup/vue_editor.html:247 msgid "select qualification" @@ -4269,7 +4280,7 @@ msgid "Failed to remove this instance. Please try again later." msgstr "" "Instanz konnte nicht entfernt werden. Bitte versuchen Sie es später erneut." -#: ephios/plugins/files/apps.py:10 ephios/plugins/files/signals.py:16 +#: ephios/plugins/files/apps.py:10 ephios/plugins/files/signals.py:22 #: ephios/plugins/files/templates/files/document_list.html:10 msgid "Files" msgstr "Dateien" @@ -4313,11 +4324,11 @@ msgstr "Datei" msgid "Documents" msgstr "Dateien" -#: ephios/plugins/files/signals.py:53 +#: ephios/plugins/files/signals.py:61 msgid "Manage files" msgstr "Dateien verwalten" -#: ephios/plugins/files/signals.py:55 +#: ephios/plugins/files/signals.py:63 msgid "" "Enables this group to upload and manage files. Files can be attached to " "events by all planners." @@ -4770,7 +4781,7 @@ msgstr "Ressource" #: ephios/plugins/simpleresource/models.py:41 #: ephios/plugins/simpleresource/models.py:52 -#: ephios/plugins/simpleresource/signals.py:30 +#: ephios/plugins/simpleresource/signals.py:40 #: ephios/plugins/simpleresource/templates/simpleresource/resource_list.html:5 #: ephios/plugins/simpleresource/templates/simpleresource/resource_list.html:9 msgid "Resources" @@ -4780,11 +4791,11 @@ msgstr "Ressourcen" msgid "Resource allocations" msgstr "Ressourcenzuweisungen" -#: ephios/plugins/simpleresource/signals.py:56 +#: ephios/plugins/simpleresource/signals.py:66 msgid "Manage Resources" msgstr "Ressourcen bearbeiten" -#: ephios/plugins/simpleresource/signals.py:58 +#: ephios/plugins/simpleresource/signals.py:68 msgid "" "Enables this group to add resources. Resources can be attached to shifts by " "all planners." @@ -4911,7 +4922,7 @@ msgstr "" msgid "Return home" msgstr "Zurück zur Startseite" -#: ephios/templates/base.html:144 +#: ephios/templates/base.html:145 msgid "Logout" msgstr "Abmelden" @@ -4973,6 +4984,9 @@ msgstr "Passwort erfolgreich zurückgesetzt" msgid "Please check your mailbox for further instructions." msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." +#~ msgid "Your time is" +#~ msgstr "Ihre Zeit ist" + #~ msgid "Optional" #~ msgstr "optional" From 36d67f6637c2ca35150d0146845babd59dc8ba4b Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 12:27:38 +0200 Subject: [PATCH 06/55] adding template for relative time field --- .../extra/templates/extra/widgets/relative_time_field.html | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 ephios/extra/templates/extra/widgets/relative_time_field.html diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html new file mode 100644 index 000000000..79120eaad --- /dev/null +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -0,0 +1,5 @@ +
+ {% spaceless %}{% for widget in widget.subwidgets %} +
{% include widget.template_name %}
+ {% endfor %}{% endspaceless %} +
\ No newline at end of file From 63641fd8414277f54b09d965dc16027ec69a6131 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 12:28:09 +0200 Subject: [PATCH 07/55] adding widget for custom relative time fields --- ephios/extra/widgets.py | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 529af3f39..ba3c0ed1e 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -5,6 +5,8 @@ from django.forms.utils import to_current_timezone from django.utils.translation import gettext as _ +import json + class CustomDateInput(DateInput): template_name = "extra/widgets/custom_date_input.html" @@ -70,6 +72,53 @@ def clean(self, value): raise ValidationError(_("Invalid recurrence rule: {error}").format(error=e)) from e +class RelativeTimeWidget(MultiWidget): + template_name = "extra/widgets/relative_time_field.html" + + def __init__(self, *args, **kwargs): + widgets = ( + forms.Select( + choices=( + (0, _("No expiration")), + (1, _("In x years")), + (2, _("On x day of month y in z years")), + ), + attrs={"class": "form-control"}, + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": _("Days (1-31)"), + "min": 1, + "max": 31, + } + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": _("Months (1-12)"), + "min": 1, + "max": 12, + } + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": _("Years"), + "min": 0, + } + ), + ) + super().__init__(widgets, *args, **kwargs) + + def decompress(self, value): + if value is None: + return [0, None, None, None] + return value # always a list now + + + + class MarkdownTextarea(forms.Textarea): """ Textarea widget that might be extended in the future From 8e64c215476dd41f0da1d9816b8deb121d437e81 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 12:28:26 +0200 Subject: [PATCH 08/55] added relative time field --- ephios/extra/fields.py | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index 8605fc932..89dc84450 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -1,8 +1,11 @@ import datetime +from django.utils.translation import gettext as _ from django import forms from django.forms.utils import from_current_timezone +from ephios.extra.widgets import RelativeTimeWidget +import json class EndOfDayDateTimeField(forms.DateTimeField): """ @@ -21,3 +24,115 @@ def to_python(self, value): day=result.day, ) ) + +class RelativeTimeField(forms.JSONField): + """ + A custom form field that allows selection between two options: + - 'after_x_years': After X years + - 'at_xy_after_z_years': For at the X.Y. after Z years + The value is stored as JSON. + """ + + widget = RelativeTimeWidget + + def bound_data(self, data, initial): + # If the widget gave us a list, just return it directly + if isinstance(data, list): + return data + return super().bound_data(data, initial) + + def to_python(self, value): + if not value: + return None + + try: + if isinstance(value, list): + choice, day, month, years = value + + choice = int(choice) if choice is not None else 0 + + if choice == 0: + return { + "type": "no_expiration" + } + elif choice == 1: + return { + "type": "after_x_years", + "years": int(years) if years is not None else 0 + } + elif choice == 2: + return { + "type": "at_xy_after_z_years", + "day": int(day) if day else None, + "month": int(month) if month else None, + "years": int(years) if years else 0 + } + else: + raise ValueError( + _("Invalid choice") + ) + + if isinstance(value, str): + data = json.loads(value) + else: + data = value # could already be a dict + + # Validation + if not isinstance(data, dict): + raise ValueError("Not a dict") + + if data.get("type") == "after_x_years": + if not isinstance(data.get("years"), int) or data["years"] < 0: + raise ValueError("Invalid years") + + elif data.get("type") == "at_xy_after_z_years": + if not isinstance(data.get("years"), int) or data["years"] < 0: + raise ValueError("Invalid years") + if not (1 <= int(data.get("day", 0)) <= 31): + raise ValueError("Invalid day") + if not (1 <= int(data.get("month", 0)) <= 12): + raise ValueError("Invalid month") + + elif data.get("type") == "no_expiration": + pass + + else: + raise ValueError("Invalid type") + + return data + + except (json.JSONDecodeError, ValueError, TypeError) as e: + raise forms.ValidationError( + _("Invalid format: {error}").format(error=e) + ) from e + + def prepare_value(self, value): + """ + Ensure the widget always gets a list [choice, day, month, years]. + """ + if value is None: + return [0, None, None, None] + + # If already a list, just pass it through + if isinstance(value, list): + return value + + # If it's a JSON string, parse it + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return [0, None, None, None] + + if not isinstance(value, dict): + return [0, None, None, None] + + t = value.get("type") + if t == "no_expiration": + return [0, None, None, None] + elif t == "after_x_years": + return [1, None, None, value.get("years")] + elif t == "at_xy_after_z_years": + return [2, value.get("day"), value.get("month"), value.get("years")] + + return [0, None, None, None] \ No newline at end of file From 71305aefa201fd27d224aaec7916296716295165 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 12:29:01 +0200 Subject: [PATCH 09/55] added defaultexpirationtimefield and implemented to qualification model --- ephios/core/models/users.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 0f4a8ad6b..f1f9a8993 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -34,9 +34,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from ephios.extra.fields import EndOfDayDateTimeField +from ephios.extra.fields import EndOfDayDateTimeField, RelativeTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder -from ephios.extra.widgets import CustomDateInput +from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget from ephios.modellogging.log import ( ModelFieldsLogConfig, add_log_recorder, @@ -277,7 +277,20 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) -expiration_format_validator = RegexValidator( +class DefaultExpirationTimeField(models.JSONField): + """ + A model field whose formfield is a RelativeTimeField + """ + + def formfield(self, **kwargs): + return super().formfield( + widget = RelativeTimeWidget, + form_class=RelativeTimeField, + **kwargs, + ) + + +"""expiration_format_validator = RegexValidator( regex=( r"^$|" # empty r"^(?:\d{1,2}|0{1,2}|\+\d+)\." # day: 1–2 digits OR 0/00 OR +N @@ -286,7 +299,7 @@ def get_by_natural_key(self, qualification_uuid, *args): r"^\+\d+$" # relative only: +N ), message=_("Invalid format."), -) +)""" class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") @@ -306,7 +319,7 @@ class Qualification(Model): symmetrical=False, blank=True, ) - default_expiration_time = models.CharField( + """default_expiration_time = models.CharField( max_length=254, verbose_name=_("Default expiration format"), help_text=_( @@ -323,6 +336,14 @@ class Qualification(Model): null=True, blank=True, validators=[expiration_format_validator], + )""" + default_expiration_time = DefaultExpirationTimeField( + verbose_name=_("Default expiration time"), + help_text=_( + "The default expiration time for this qualification. Leave empty for no expiration." + ), + null=True, + blank=True, ) is_imported = models.BooleanField(verbose_name=_("imported"), default=True) From aaeb3b01567c4fb27e5cb626b4327be09c7782d5 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 14:53:06 +0200 Subject: [PATCH 10/55] Changed Option for expiration time --- ephios/core/models/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index f1f9a8993..82f6aff77 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -340,7 +340,7 @@ class Qualification(Model): default_expiration_time = DefaultExpirationTimeField( verbose_name=_("Default expiration time"), help_text=_( - "The default expiration time for this qualification. Leave empty for no expiration." + "The default expiration time for this qualification." ), null=True, blank=True, From 341b3e20dd1a3184e1f3eefd12a0f72e3d64f29e Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 14:53:23 +0200 Subject: [PATCH 11/55] added translations for default expiration time --- ephios/locale/de/LC_MESSAGES/django.po | 212 ++++++++++++++----------- 1 file changed, 122 insertions(+), 90 deletions(-) diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index 9254b9c1b..0d5281c70 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-08-21 14:00+0200\n" +"POT-Creation-Date: 2025-09-15 14:21+0200\n" "PO-Revision-Date: 2025-07-24 23:17+0200\n" "Last-Translator: Ben Samuelson \n" "Language-Team: German - leave empty
- DD.MM.YYYY (e.g. 31.12.2025)
- +N " -"(relative years/months/days)
- Mixed like 30.06.+2 or 0.6.+1
- Fully " -"relative like +1.+1.+1
- Placeholders:
  • 0 or 00 for day/" -"month
  • 0, 00 or 0000 for year" -msgstr "" -"Erlaubt sind:
- Leer lassen
- DD.MM.YYYY (z.B. 31.12.2025)
- +N " -"(realives Jahr/Monat/Tag)
- Gemischt wie 30.06.+2 oder 0.6.+1
- Komplett " -"relativ wie +1.+1.+1
- Platzhalter:
  • 0 oder 00 für Tag/" -"Monat
  • 0, 00 oder 0000 für Jahr" +#: ephios/core/models/users.py:343 +msgid "The default expiration time for this qualification." +msgstr "Die Standart Ablaufzeit für diese Qualifikation." -#: ephios/core/models/users.py:327 +#: ephios/core/models/users.py:348 msgid "imported" msgstr "importiert" -#: ephios/core/models/users.py:338 ephios/core/models/users.py:381 +#: ephios/core/models/users.py:359 ephios/core/models/users.py:402 msgid "qualification" msgstr "Qualifikation" -#: ephios/core/models/users.py:339 +#: ephios/core/models/users.py:360 msgid "qualifications" msgstr "Qualifikationen" -#: ephios/core/models/users.py:392 +#: ephios/core/models/users.py:413 #: ephios/core/templates/core/userprofile_form.html:72 msgid "externally managed" msgstr "extern verwaltet" -#: ephios/core/models/users.py:394 +#: ephios/core/models/users.py:415 #: ephios/core/templates/core/userprofile_form.html:71 msgid "The qualification was granted by an external system." msgstr "Die Qualifikation wurde durch ein externes System erteilt." -#: ephios/core/models/users.py:406 +#: ephios/core/models/users.py:427 #, python-brace-format msgid "{qualification} for {user}" msgstr "{qualification} für {user}" -#: ephios/core/models/users.py:412 +#: ephios/core/models/users.py:433 msgid "Qualification grant" msgstr "Qualifikationserteilung" -#: ephios/core/models/users.py:413 +#: ephios/core/models/users.py:434 msgid "Qualification grants" msgstr "Qualifikationserteilungen" -#: ephios/core/models/users.py:429 ephios/core/models/users.py:545 +#: ephios/core/models/users.py:450 ephios/core/models/users.py:566 msgid "affected user" msgstr "betroffener Benutzer" -#: ephios/core/models/users.py:435 +#: ephios/core/models/users.py:456 msgid "needs confirmation" msgstr "benötigt Bestätigung" -#: ephios/core/models/users.py:436 +#: ephios/core/models/users.py:457 msgid "executed" msgstr "ausgeführt" -#: ephios/core/models/users.py:437 +#: ephios/core/models/users.py:458 msgid "failed" msgstr "fehlgeschlagen" -#: ephios/core/models/users.py:438 +#: ephios/core/models/users.py:459 msgid "denied" msgstr "abgelehnt" -#: ephios/core/models/users.py:444 ephios/core/views/event.py:84 +#: ephios/core/models/users.py:465 ephios/core/views/event.py:84 msgid "State" msgstr "Status" -#: ephios/core/models/users.py:449 +#: ephios/core/models/users.py:470 msgid "Consequence" msgstr "Konsequenz" -#: ephios/core/models/users.py:450 +#: ephios/core/models/users.py:471 msgid "Consequences" msgstr "Konsequenzen" -#: ephios/core/models/users.py:466 +#: ephios/core/models/users.py:487 msgid "Consequence was executed already." msgstr "Konsequenz wurde bereits ausgeführt." -#: ephios/core/models/users.py:480 +#: ephios/core/models/users.py:501 #: ephios/core/templates/core/userprofile_workinghours.html:26 msgid "Reason" msgstr "Anlass" -#: ephios/core/models/users.py:494 +#: ephios/core/models/users.py:515 msgid "Consequence was executed or denied already." msgstr "Konsequenz wurde bereits ausgeführt oder abgelehnt." -#: ephios/core/models/users.py:526 +#: ephios/core/models/users.py:547 msgid "Hours of work" msgstr "Arbeitsstunden" -#: ephios/core/models/users.py:527 +#: ephios/core/models/users.py:548 msgid "Occasion" msgstr "Anlass" -#: ephios/core/models/users.py:532 ephios/core/models/users.py:533 +#: ephios/core/models/users.py:553 ephios/core/models/users.py:554 #: ephios/core/signals.py:221 #: ephios/core/templates/core/userprofile_list.html:96 #: ephios/core/templates/core/workinghours_list.html:9 @@ -1293,76 +1281,76 @@ msgstr "Anlass" msgid "Working hours" msgstr "Arbeitsstunden" -#: ephios/core/models/users.py:548 +#: ephios/core/models/users.py:569 msgid "read" msgstr "gelesen" -#: ephios/core/models/users.py:551 +#: ephios/core/models/users.py:572 msgid "processing completed" msgstr "Verarbeitung abgeschlossen" -#: ephios/core/models/users.py:553 +#: ephios/core/models/users.py:574 msgid "" "All enabled notification backends have processed this notification when flag " "is set" msgstr "" "Alle Notification-Backends haben diese Benachrichtung verarbeitet falls aktiv" -#: ephios/core/models/users.py:561 +#: ephios/core/models/users.py:582 msgid "" "List of slugs of notification backends that have processed this notification" msgstr "" "Liste von Slugs aller Notification-Backends die diese Benachrichtigung " "verarbeitet haben" -#: ephios/core/models/users.py:589 +#: ephios/core/models/users.py:610 #, python-brace-format msgid "{subject} for {user}" msgstr "{subject} für {user}" -#: ephios/core/models/users.py:589 +#: ephios/core/models/users.py:610 msgid "Guest" msgstr "Gast" -#: ephios/core/models/users.py:610 +#: ephios/core/models/users.py:631 msgid "internal name" msgstr "Interner Name" -#: ephios/core/models/users.py:611 +#: ephios/core/models/users.py:632 msgid "Internal name for this provider." msgstr "Interner Name für diesen Provider." -#: ephios/core/models/users.py:616 +#: ephios/core/models/users.py:637 msgid "label" msgstr "Titel" -#: ephios/core/models/users.py:617 +#: ephios/core/models/users.py:638 msgid "The label displayed to users attempting to log in with this provider." msgstr "" "Der Titel, der Benutzen angezeigt wird, die sich mit diesem Provider " "einloggen möchten." -#: ephios/core/models/users.py:621 +#: ephios/core/models/users.py:642 msgid "client id" msgstr "Client-ID" -#: ephios/core/models/users.py:622 +#: ephios/core/models/users.py:643 msgid "Your client id provided by the OIDC provider." msgstr "Ihre Client-ID (vom OIDC-Provider zur Verfügung gestellt)." -#: ephios/core/models/users.py:626 +#: ephios/core/models/users.py:647 msgid "client secret" msgstr "Client-Secret" -#: ephios/core/models/users.py:627 +#: ephios/core/models/users.py:648 msgid "Your client secret provided by the OIDC provider." msgstr "Ihr Client-Secret (vom OIDC-Provider zur Verfügung gestellt)." -#: ephios/core/models/users.py:632 +#: ephios/core/models/users.py:653 msgid "scopes" msgstr "Geltungsbereich" -#: ephios/core/models/users.py:634 +#: ephios/core/models/users.py:655 msgid "" "The OIDC scopes to request from the provider. Separate multiple scopes with " "spaces. Use the default value if you are unsure." @@ -1371,45 +1359,45 @@ msgstr "" "Mehrere Werte mit Leerzeichen trennen. Nutzen Sie den Standardwert, wenn Sie " "unsicher sind." -#: ephios/core/models/users.py:638 +#: ephios/core/models/users.py:659 msgid "authorization endpoint" msgstr "authorization endpoint" -#: ephios/core/models/users.py:638 +#: ephios/core/models/users.py:659 msgid "The OIDC authorization endpoint." msgstr "Der Endpunkt zur Authentifizierung." -#: ephios/core/models/users.py:641 +#: ephios/core/models/users.py:662 msgid "token endpoint" msgstr "token endpoint" -#: ephios/core/models/users.py:641 +#: ephios/core/models/users.py:662 msgid "The OIDC token endpoint." msgstr "Der Token-Endpoint." -#: ephios/core/models/users.py:644 +#: ephios/core/models/users.py:665 msgid "user endpoint" msgstr "Benutzer-Endpoint" -#: ephios/core/models/users.py:644 +#: ephios/core/models/users.py:665 msgid "The OIDC user endpoint." msgstr "Der Endpunkt für Benutzerprofile." -#: ephios/core/models/users.py:649 +#: ephios/core/models/users.py:670 msgid "end session endpoint" msgstr "end session endpoint" -#: ephios/core/models/users.py:650 +#: ephios/core/models/users.py:671 msgid "The OIDC end session endpoint, if supported by your provider." msgstr "" "Der Session-Beendigungs-Endpunkt (sofern von Ihrem OIDC-Provider " "unterstützt)." -#: ephios/core/models/users.py:655 +#: ephios/core/models/users.py:676 msgid "JWKS endpoint" msgstr "JWKS endpoint" -#: ephios/core/models/users.py:657 +#: ephios/core/models/users.py:678 msgid "" "The OIDC JWKS endpoint. A less secure signing method will be used if this is " "not provided." @@ -1417,21 +1405,21 @@ msgstr "" "Der Zertifikatsendpunkt. Ein weniger sicheres Signierverfahren wird " "verwendet, wenn kein Wert angegeben wird." -#: ephios/core/models/users.py:663 +#: ephios/core/models/users.py:684 msgid "default groups" msgstr "Standard-Gruppen" -#: ephios/core/models/users.py:664 +#: ephios/core/models/users.py:685 msgid "The groups that users logging in with this provider will be added to." msgstr "" "Die Gruppen, zu denen Benutzer hinzugefügt werden, die sich mit diesem " "Provider einloggen." -#: ephios/core/models/users.py:669 +#: ephios/core/models/users.py:690 msgid "group claim" msgstr "OIDC-Claim für Gruppen" -#: ephios/core/models/users.py:671 +#: ephios/core/models/users.py:692 msgid "" "The name of the claim that contains the user's groups. Leave empty if your " "provider does not support this. You can use dot notation to access nested " @@ -1441,11 +1429,11 @@ msgstr "" "Feld frei, wenn Ihr Provider dies nicht unterstützt. Sie können per Punkt-" "Notation auf verschachtelte Claims zugreifen." -#: ephios/core/models/users.py:677 +#: ephios/core/models/users.py:698 msgid "create missing groups" msgstr "Fehlende Gruppen erstellen" -#: ephios/core/models/users.py:679 +#: ephios/core/models/users.py:700 msgid "" "If enabled, groups from the claim defined above that do not exist yet will " "be created automatically." @@ -1453,11 +1441,11 @@ msgstr "" "Legt fest, ob bisher noch nicht existierende Gruppen aus dem Gruppen-Claim " "automatisch erstellt werden sollen." -#: ephios/core/models/users.py:685 +#: ephios/core/models/users.py:706 msgid "qualification claim" msgstr "Qualifikations-Claim" -#: ephios/core/models/users.py:687 +#: ephios/core/models/users.py:708 msgid "" "The name of the claim that contains the user's qualifications. Leave empty " "if your provider does not support this. You can use dot notation to access " @@ -1468,11 +1456,11 @@ msgstr "" "Sie dieses Feld frei, wenn Ihr Provider dies nicht unterstützt. Sie können " "per Punkt-Notation auf verschachtelte Claims zugreifen." -#: ephios/core/models/users.py:693 +#: ephios/core/models/users.py:714 msgid "qualification codename to uuid" msgstr "Qualifikations-Codename zu UUID" -#: ephios/core/models/users.py:695 +#: ephios/core/models/users.py:716 msgid "" "A json encoded dictionary containing mappings of qualification names as they " "appear in thequalification claim to the qualification uuid. If a key is not " @@ -1482,16 +1470,16 @@ msgstr "" "Claim auftauchen, zu Qualifikations-UUIDs übersetzt. Wenn ein Key nicht " "gefunden wird, wird versucht den Key selbst als UUID zu nutzen." -#: ephios/core/models/users.py:703 +#: ephios/core/models/users.py:724 #, python-brace-format msgid "Identity provider {label}" msgstr "Identitätsprovider {label}" -#: ephios/core/models/users.py:706 +#: ephios/core/models/users.py:727 msgid "Identity provider" msgstr "Identitätsprovider" -#: ephios/core/models/users.py:707 +#: ephios/core/models/users.py:728 #: ephios/core/templates/core/identityprovider_list.html:5 #: ephios/core/views/settings.py:78 msgid "Identity providers" @@ -3198,6 +3186,15 @@ msgstr "Arbeitsstunden wurden gelöscht." msgid "close" msgstr "schließen" +#: ephios/extra/fields.py:72 +msgid "Invalid choice" +msgstr "Ungültige Auswahl" + +#: ephios/extra/fields.py:106 +#, python-brace-format +msgid "Invalid format: {error}" +msgstr "Ungültiges Format: {error}" + #: ephios/extra/templates/extra/widgets/recurrence_picker.html:9 msgid "starting at" msgstr "beginnend am" @@ -3302,11 +3299,35 @@ msgstr "" msgid "None" msgstr "Nichts" -#: ephios/extra/widgets.py:70 +#: ephios/extra/widgets.py:72 #, python-brace-format msgid "Invalid recurrence rule: {error}" msgstr "Ungültige Regel: {error}" +#: ephios/extra/widgets.py:82 +msgid "No expiration" +msgstr "Keine Ablaufzeit" + +#: ephios/extra/widgets.py:83 +msgid "In x years" +msgstr "In x Jahren" + +#: ephios/extra/widgets.py:84 +msgid "On x day of month y in z years" +msgstr "Am x. Tag des Monats y in z Jahren" + +#: ephios/extra/widgets.py:91 +msgid "Days (1-31)" +msgstr "Tage (1-31)" + +#: ephios/extra/widgets.py:99 +msgid "Months (1-12)" +msgstr "Monate (1-12)" + +#: ephios/extra/widgets.py:107 +msgid "Years" +msgstr "Jahre" + #: ephios/modellogging/models.py:48 msgid "Log entry" msgstr "Änderungseintrag" @@ -4984,6 +5005,17 @@ msgstr "Passwort erfolgreich zurückgesetzt" msgid "Please check your mailbox for further instructions." msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." +#~ msgid "" +#~ "Allowed are:
- leave empty
- DD.MM.YYYY (e.g. 31.12.2025)
- +N " +#~ "(relative years/months/days)
- Mixed like 30.06.+2 or 0.6.+1
- " +#~ "Fully relative like +1.+1.+1
- Placeholders:
  • 0 or 00 " +#~ "for day/month
  • 0, 00 or 0000 for year" +#~ msgstr "" +#~ "Erlaubt sind:
- Leer lassen
- DD.MM.YYYY (z.B. 31.12.2025)
- +N " +#~ "(realives Jahr/Monat/Tag)
- Gemischt wie 30.06.+2 oder 0.6.+1
- " +#~ "Komplett relativ wie +1.+1.+1
- Platzhalter:
  • 0 oder " +#~ "00 für Tag/Monat
  • 0, 00 oder 0000 für Jahr" + #~ msgid "Your time is" #~ msgstr "Ihre Zeit ist" From d427317146a0b5699feca35133f99ceebf7fe867 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 17:45:00 +0200 Subject: [PATCH 12/55] add labels --- .../extra/widgets/relative_time_field.html | 16 +++++++++---- ephios/extra/widgets.py | 24 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html index 79120eaad..aee4f0798 100644 --- a/ephios/extra/templates/extra/widgets/relative_time_field.html +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -1,5 +1,11 @@ -
- {% spaceless %}{% for widget in widget.subwidgets %} -
{% include widget.template_name %}
- {% endfor %}{% endspaceless %} -
\ No newline at end of file +
+ {% for widget in widget.subwidgets %} +
+ + {% include widget.template_name %} +
+ {% endfor %} +
+ +{% load static %} + \ No newline at end of file diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index ba3c0ed1e..5a695c93f 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -83,7 +83,11 @@ def __init__(self, *args, **kwargs): (1, _("In x years")), (2, _("On x day of month y in z years")), ), - attrs={"class": "form-control"}, + attrs={ + "class": "form-control", + "title": _("Choice"), + "aria-label": _("Choice"), + }, ), forms.NumberInput( attrs={ @@ -91,6 +95,8 @@ def __init__(self, *args, **kwargs): "placeholder": _("Days (1-31)"), "min": 1, "max": 31, + "title": _("Days (1-31)"), + "aria-label": _("Days (1-31)"), } ), forms.NumberInput( @@ -99,6 +105,8 @@ def __init__(self, *args, **kwargs): "placeholder": _("Months (1-12)"), "min": 1, "max": 12, + "title": _("Months (1-12)"), + "aria-label": _("Months (1-12)"), } ), forms.NumberInput( @@ -106,15 +114,29 @@ def __init__(self, *args, **kwargs): "class": "form-control", "placeholder": _("Years"), "min": 0, + "title": _("Years"), + "aria-label": _("Years"), } ), ) super().__init__(widgets, *args, **kwargs) + self.labels = [ + _("Choice"), + _("Day"), + _("Month"), + _("Years"), + ] def decompress(self, value): if value is None: return [0, None, None, None] return value # always a list now + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + for idx, subwidget in enumerate(context["widget"]["subwidgets"]): + subwidget["label"] = self.labels[idx] + return context From 6827e7f51d980928efe170ad555a210575e82526 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 17:45:15 +0200 Subject: [PATCH 13/55] add hidding for unused fields --- .../static/extra/js/relative_time_field.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 ephios/extra/static/extra/js/relative_time_field.js diff --git a/ephios/extra/static/extra/js/relative_time_field.js b/ephios/extra/static/extra/js/relative_time_field.js new file mode 100644 index 000000000..cd555134e --- /dev/null +++ b/ephios/extra/static/extra/js/relative_time_field.js @@ -0,0 +1,29 @@ +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll(".relative-time-widget").forEach((wrapper) => { + const select = wrapper.querySelector(".field-0 select"); + const day = wrapper.querySelector(".field-1 input"); + const month = wrapper.querySelector(".field-2 input"); + const years = wrapper.querySelector(".field-3 input"); + + const all_fields = [day, month, years]; + + const relative_time_map = { + 0: [], // no_expiration + 1: [years], // after_x_years + 2: [day, month, years], // at_xy_after_z_years + }; + + function updateVisibility() { + const val = parseInt(select.value); + const show = relative_time_map[val] || []; + + all_fields.forEach((field) => { + if (!field) return; + field.parentElement.style.display = show.includes(field) ? "" : "none"; + }); + } + + select.addEventListener("change", updateVisibility); + updateVisibility(); + }) +}) \ No newline at end of file From d8ff0d61442da93fc5008dbcb815e8704bf15ac2 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 15 Sep 2025 17:51:09 +0200 Subject: [PATCH 14/55] translation for labels of subwidgets --- ephios/locale/de/LC_MESSAGES/django.po | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index 0d5281c70..d4675913d 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-15 14:21+0200\n" +"POT-Creation-Date: 2025-09-15 17:46+0200\n" "PO-Revision-Date: 2025-07-24 23:17+0200\n" "Last-Translator: Ben Samuelson \n" "Language-Team: German Date: Mon, 15 Sep 2025 20:20:39 +0200 Subject: [PATCH 15/55] improved styling for mobile devices --- ephios/extra/templates/extra/widgets/relative_time_field.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html index aee4f0798..4ab009ec8 100644 --- a/ephios/extra/templates/extra/widgets/relative_time_field.html +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -1,6 +1,6 @@
{% for widget in widget.subwidgets %} -
+
{% include widget.template_name %}
From da6225bca6c07a6c84daf56347678ee92a388ea2 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Fri, 3 Oct 2025 22:04:05 +0200 Subject: [PATCH 16/55] init commit for plugin --- ephios/plugins/qualification_submit/__init__.py | 0 ephios/plugins/qualification_submit/apps.py | 15 +++++++++++++++ ephios/plugins/qualification_submit/forms.py | 0 ephios/plugins/qualification_submit/signals.py | 0 ephios/plugins/qualification_submit/urls.py | 0 ephios/plugins/qualification_submit/views.py | 0 6 files changed, 15 insertions(+) create mode 100644 ephios/plugins/qualification_submit/__init__.py create mode 100644 ephios/plugins/qualification_submit/apps.py create mode 100644 ephios/plugins/qualification_submit/forms.py create mode 100644 ephios/plugins/qualification_submit/signals.py create mode 100644 ephios/plugins/qualification_submit/urls.py create mode 100644 ephios/plugins/qualification_submit/views.py diff --git a/ephios/plugins/qualification_submit/__init__.py b/ephios/plugins/qualification_submit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/qualification_submit/apps.py b/ephios/plugins/qualification_submit/apps.py new file mode 100644 index 000000000..4819b397a --- /dev/null +++ b/ephios/plugins/qualification_submit/apps.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +from ephios.core.plugins import PluginConfig + + +class PluginApp(PluginConfig): + name = "ephios.plugins.qualification_submit" + + class EphiosPluginMeta: + name = _("Qualification Submiting") + author = "Ben Samuelson" + description = _("This plugins lets you a user submit a qualification request.") + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/ephios/plugins/qualification_submit/forms.py b/ephios/plugins/qualification_submit/forms.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/qualification_submit/signals.py b/ephios/plugins/qualification_submit/signals.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/qualification_submit/urls.py b/ephios/plugins/qualification_submit/urls.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/qualification_submit/views.py b/ephios/plugins/qualification_submit/views.py new file mode 100644 index 000000000..e69de29bb From 1622d7dfb977dff20c70aa3a8ded43af2d87b9b2 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 8 Oct 2025 14:59:34 +0200 Subject: [PATCH 17/55] added own modelgroup for relative time types --- ephios/extra/relative_time.py | 142 ++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 ephios/extra/relative_time.py diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py new file mode 100644 index 000000000..683f0e188 --- /dev/null +++ b/ephios/extra/relative_time.py @@ -0,0 +1,142 @@ +from calendar import calendar +import datetime +import json +from django.db import models +from django.utils.translation import gettext as _ +from dateutil.relativedelta import relativedelta + + +class RelativeTimeTypeRegistry: + """ + Registry that holds all known relative time types. + """ + + """Global registry for all relative time types.""" + _registry = {} + + @classmethod + def register(cls, name, handler): + if not isinstance(handler, type): + raise TypeError(f"Handler for '{name}' must be a class, got {handler!r}") + cls._registry[name] = handler + return handler + + @classmethod + def get(cls, name): + return cls._registry.get(name) + + @classmethod + def all(cls): + return cls._registry.items() + +class RelativeTime: + """ + Represents a relative time duration. + """ + + def __init__(self, type="no_expiration", **kwargs): + self.type = type + self.params = kwargs + + def __repr__(self): + return f"RelativeTime(type={self.type}, params={self.params})" + + def to_json(self): + return {"type": self.type, **self.params} + + @classmethod + def from_json(cls, data): + if not data: + return cls("no_expiration") + if isinstance(data, str): + data = json.loads(data) + data = data.copy() + type_ = data.pop("type", "no_expiration") + return cls(type_, **data) + + def apply_to(self, base_time: datetime.date): + """Delegates to the registered handler.""" + handler = RelativeTimeTypeRegistry.get(self.type) + if not handler: + raise ValueError(f"Unknown relative time type: {self.type}") + return handler.apply(base_time, **self.params) + + # decorator + @classmethod + def register_type(cls, name): + def decorator(handler_cls): + RelativeTimeTypeRegistry.register(name, handler_cls) + return handler_cls + return decorator + + +# --------------------------------------------------------------------- +# Default built-in types +# --------------------------------------------------------------------- + +@RelativeTime.register_type("no_expiration") +class NoExpirationType: + fields = [] + + @staticmethod + def apply(base_date, **kwargs): + return None + +@RelativeTime.register_type("after_x_years") +class AfterXYearsType: + fields = ["years"] + + @staticmethod + def apply(base_date, years=0, **kwargs): + return base_date + relativedelta(years=years) + +@RelativeTime.register_type("at_xy_after_z_years") +class AtXYAfterZYearsType: + fields = ["day", "month", "years"] + + @staticmethod + def apply(base_date, day=None, month=None, years=0, **kwargs): + if not (day and month): + raise ValueError(_("Day and Month must be provided")) + target_date = base_date + relativedelta(years=years) + target_date = target_date.replace(month=month) + last_day = calendar.monthrange(target_date.year, month)[1] + target_day = min(day, last_day) + return target_date.replace(day=target_day) + + +# --------------------------------------------------------------------- +# ModelField integration +# --------------------------------------------------------------------- + +class RelativeTimeModelField(models.JSONField): + """ + Stores a RelativeTime object as JSON. + """ + + description = _("Relative Time") + + def from_db_value(self, value, expression, connection): + if value is None: + return RelativeTime("no_expiration") + return RelativeTime.from_json(value) + + def to_python(self, value): + if isinstance(value, RelativeTime): + return value + if value is None: + return RelativeTime("no_expiration") + return RelativeTime.from_json(value) + + def get_prep_value(self, value): + if isinstance(value, RelativeTime): + return value.to_json() + if value is None: + return {"type": "no_expiration"} + return RelativeTime.from_json(value) + + def formfield(self, **kwargs): + from ephios.extra.fields import RelativeTimeField + defaults = {'form_class': RelativeTimeField} + defaults.update(kwargs) + return super().formfield(**defaults) \ No newline at end of file From 314735c7bcc0229e46103631756958d2f4d6e200 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 8 Oct 2025 15:00:01 +0200 Subject: [PATCH 18/55] refactored widget for variable use --- ephios/extra/widgets.py | 94 +++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 5a695c93f..168589d11 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -7,6 +7,8 @@ import json +from ephios.extra.relative_time import RelativeTimeTypeRegistry + class CustomDateInput(DateInput): template_name = "extra/widgets/custom_date_input.html" @@ -73,63 +75,63 @@ def clean(self, value): class RelativeTimeWidget(MultiWidget): + """ + A MultiWidget that renders all registered RelativeTime types dynamically. + """ + template_name = "extra/widgets/relative_time_field.html" def __init__(self, *args, **kwargs): - widgets = ( + # Generate dynamic choices + choices = [(i, _(name.replace("_", " ").title())) for i, (name, handler) in enumerate(RelativeTimeTypeRegistry.all())] + self.type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + + widgets = [ forms.Select( - choices=( - (0, _("No expiration")), - (1, _("In x years")), - (2, _("On x day of month y in z years")), - ), + choices=choices, attrs={ "class": "form-control", - "title": _("Choice"), - "aria-label": _("Choice"), + "title": _("Type"), + "aria-label": _("Type"), }, - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "placeholder": _("Days (1-31)"), - "min": 1, - "max": 31, - "title": _("Days (1-31)"), - "aria-label": _("Days (1-31)"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "placeholder": _("Months (1-12)"), - "min": 1, - "max": 12, - "title": _("Months (1-12)"), - "aria-label": _("Months (1-12)"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "placeholder": _("Years"), - "min": 0, - "title": _("Years"), - "aria-label": _("Years"), - } - ), - ) - super().__init__(widgets, *args, **kwargs) - self.labels = [ - _("Choice"), - _("Day"), - _("Month"), - _("Years"), + ) ] + + # Collect all possible parameter names across all registered types + field_placeholders = { + "years": _("Years"), + "months": _("Months (1–12)"), + "day": _("Day (1–31)"), + "month": _("Month (1–12)"), + } + + # Build a unified list of NumberInputs for all possible numeric parameters + # (widget values will still be passed as a list) + param_names = sorted({p for name, handler in RelativeTimeTypeRegistry.all() for p in getattr(handler, "fields", [])}) + self.param_names = param_names + + for param in param_names: + widgets.append( + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": field_placeholders.get(param, param.title()), + "min": 0, + "title": field_placeholders.get(param, param.title()), + "aria-label": field_placeholders.get(param, param.title()), + } + ) + ) + + super().__init__(widgets, *args, **kwargs) + + # Labels: first is the type choice, then one per param + self.labels = [_("Type")] + [param.title() for param in self.param_names] def decompress(self, value): + # Expect value as list [choice, param1, param2, ...] if value is None: - return [0, None, None, None] + return [0] + [None] * len(self.param_names) return value # always a list now def get_context(self, name, value, attrs): From 0cabaf90481545c35f7d3915f174509a2c56178a Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 8 Oct 2025 15:00:13 +0200 Subject: [PATCH 19/55] refactored fields for variable use --- ephios/extra/fields.py | 109 ++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 67 deletions(-) diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index 89dc84450..0b9e38123 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext as _ from django import forms from django.forms.utils import from_current_timezone +from ephios.extra.relative_time import RelativeTimeTypeRegistry from ephios.extra.widgets import RelativeTimeWidget import json @@ -27,16 +28,12 @@ def to_python(self, value): class RelativeTimeField(forms.JSONField): """ - A custom form field that allows selection between two options: - - 'after_x_years': After X years - - 'at_xy_after_z_years': For at the X.Y. after Z years - The value is stored as JSON. + A form field that dynamically adapts to all registered RelativeTime types. """ widget = RelativeTimeWidget def bound_data(self, data, initial): - # If the widget gave us a list, just return it directly if isinstance(data, list): return data return super().bound_data(data, initial) @@ -44,95 +41,73 @@ def bound_data(self, data, initial): def to_python(self, value): if not value: return None - + try: + # Determine all known types and their parameters + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + if isinstance(value, list): - choice, day, month, years = value - - choice = int(choice) if choice is not None else 0 - - if choice == 0: - return { - "type": "no_expiration" - } - elif choice == 1: - return { - "type": "after_x_years", - "years": int(years) if years is not None else 0 - } - elif choice == 2: - return { - "type": "at_xy_after_z_years", - "day": int(day) if day else None, - "month": int(month) if month else None, - "years": int(years) if years else 0 - } - else: - raise ValueError( - _("Invalid choice") - ) - + # first element = type index + type_index = int(value[0]) if value and value[0] is not None else 0 + type_name = type_names[type_index] if 0 <= type_index < len(type_names) else None + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Invalid choice")) + + params = {} + # remaining values correspond to all known parameters + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + for param_name, param_value in zip(all_param_names, value[1:]): + if param_value not in (None, ""): + params[param_name] = int(param_value) + return {"type": type_name, **params} + if isinstance(value, str): data = json.loads(value) else: - data = value # could already be a dict + data = value - # Validation if not isinstance(data, dict): raise ValueError("Not a dict") - if data.get("type") == "after_x_years": - if not isinstance(data.get("years"), int) or data["years"] < 0: - raise ValueError("Invalid years") + type_name = data.get("type") + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Unknown type")) - elif data.get("type") == "at_xy_after_z_years": - if not isinstance(data.get("years"), int) or data["years"] < 0: - raise ValueError("Invalid years") - if not (1 <= int(data.get("day", 0)) <= 31): - raise ValueError("Invalid day") - if not (1 <= int(data.get("month", 0)) <= 12): - raise ValueError("Invalid month") - - elif data.get("type") == "no_expiration": - pass - - else: - raise ValueError("Invalid type") + # basic validation: ensure required params exist + for param in getattr(handler, "fields", []): + if param not in data: + raise ValueError(_("Missing field: {param}").format(param=param)) return data - + except (json.JSONDecodeError, ValueError, TypeError) as e: raise forms.ValidationError( _("Invalid format: {error}").format(error=e) ) from e - + def prepare_value(self, value): - """ - Ensure the widget always gets a list [choice, day, month, years]. - """ if value is None: - return [0, None, None, None] + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - # If already a list, just pass it through if isinstance(value, list): return value - # If it's a JSON string, parse it if isinstance(value, str): try: value = json.loads(value) except json.JSONDecodeError: - return [0, None, None, None] + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) if not isinstance(value, dict): - return [0, None, None, None] + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + type_name = value.get("type", "no_expiration") + type_index = type_names.index(type_name) if type_name in type_names else 0 - t = value.get("type") - if t == "no_expiration": - return [0, None, None, None] - elif t == "after_x_years": - return [1, None, None, value.get("years")] - elif t == "at_xy_after_z_years": - return [2, value.get("day"), value.get("month"), value.get("years")] + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + params = [value.get(p) for p in all_param_names] - return [0, None, None, None] \ No newline at end of file + return [type_index] + params \ No newline at end of file From 5a52f256e166431862365be7c2c8f96c646dc653 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:25:17 +0200 Subject: [PATCH 20/55] added plugin --- ephios/plugins/qualification_requests/__init__.py | 0 ephios/plugins/qualification_requests/apps.py | 15 +++++++++++++++ ephios/settings.py | 1 + 3 files changed, 16 insertions(+) create mode 100644 ephios/plugins/qualification_requests/__init__.py create mode 100644 ephios/plugins/qualification_requests/apps.py diff --git a/ephios/plugins/qualification_requests/__init__.py b/ephios/plugins/qualification_requests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/qualification_requests/apps.py b/ephios/plugins/qualification_requests/apps.py new file mode 100644 index 000000000..dee89460f --- /dev/null +++ b/ephios/plugins/qualification_requests/apps.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +from ephios.core.plugins import PluginConfig + + +class PluginApp(PluginConfig): + name = "ephios.plugins.qualification_requests" + + class EphiosPluginMeta: + name = _("Qualification Requests") + author = "Ben Samuelson" + description = _("This plugins lets an user submit a qualification request.") + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/ephios/settings.py b/ephios/settings.py index a13bb04f9..fecb111c1 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -114,6 +114,7 @@ "ephios.plugins.simpleresource.apps.PluginApp", "ephios.plugins.federation.apps.PluginApp", "ephios.plugins.files.apps.PluginApp", + "ephios.plugins.qualification_requests.apps.PluginApp", ] PLUGINS = copy.copy(CORE_PLUGINS) for ep in metadata.entry_points(group="ephios.plugins"): From 7acd8d8fcafae0c08d302be787c521ac7fc5a708 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:25:49 +0200 Subject: [PATCH 21/55] removed old plugin(name) --- ephios/plugins/qualification_submit/__init__.py | 0 ephios/plugins/qualification_submit/apps.py | 15 --------------- ephios/plugins/qualification_submit/forms.py | 0 ephios/plugins/qualification_submit/signals.py | 0 ephios/plugins/qualification_submit/urls.py | 0 ephios/plugins/qualification_submit/views.py | 0 6 files changed, 15 deletions(-) delete mode 100644 ephios/plugins/qualification_submit/__init__.py delete mode 100644 ephios/plugins/qualification_submit/apps.py delete mode 100644 ephios/plugins/qualification_submit/forms.py delete mode 100644 ephios/plugins/qualification_submit/signals.py delete mode 100644 ephios/plugins/qualification_submit/urls.py delete mode 100644 ephios/plugins/qualification_submit/views.py diff --git a/ephios/plugins/qualification_submit/__init__.py b/ephios/plugins/qualification_submit/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ephios/plugins/qualification_submit/apps.py b/ephios/plugins/qualification_submit/apps.py deleted file mode 100644 index 4819b397a..000000000 --- a/ephios/plugins/qualification_submit/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.utils.translation import gettext_lazy as _ - -from ephios.core.plugins import PluginConfig - - -class PluginApp(PluginConfig): - name = "ephios.plugins.qualification_submit" - - class EphiosPluginMeta: - name = _("Qualification Submiting") - author = "Ben Samuelson" - description = _("This plugins lets you a user submit a qualification request.") - - def ready(self): - from . import signals # pylint: disable=unused-import diff --git a/ephios/plugins/qualification_submit/forms.py b/ephios/plugins/qualification_submit/forms.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ephios/plugins/qualification_submit/signals.py b/ephios/plugins/qualification_submit/signals.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ephios/plugins/qualification_submit/urls.py b/ephios/plugins/qualification_submit/urls.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ephios/plugins/qualification_submit/views.py b/ephios/plugins/qualification_submit/views.py deleted file mode 100644 index e69de29bb..000000000 From 57aa11bb67d5ddc11ec441137d93c793936fbff8 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:26:12 +0200 Subject: [PATCH 22/55] copied old templates --- .../qualification_requests/details_form.html | 87 +++++++++++++++++++ .../qualification_requests/own_list.html | 35 ++++++++ .../qualification_requests_badge.html | 7 ++ .../qualification_requests_form.html | 15 ++++ .../qualification_requests_list.html | 49 +++++++++++ .../qualification_requests_list_own.html | 33 +++++++ 6 files changed, 226 insertions(+) create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html new file mode 100644 index 000000000..013dd6601 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html @@ -0,0 +1,87 @@ +{% extends "core/settings/settings_base.html" %} + +{% load static %} +{% load i18n %} + +{% block javascript %} + +{% endblock %} + +{% block css %} + +{% endblock %} + +{% block settings_content %} +

{% trans "Details for the Qualificationrequest" %}

+{% if qualification_request.image_data %} +

{% trans "Uploaded Image" %}

+ + {% trans 'Uploaded Image' %} + +

+ + {% trans "Download Image" %} + +

+ + +{% else %} +

{% trans "No image uploaded." %}

+{% endif %} + +
+

{% trans "User" %}: {{ qualification_request.user }}

+

{% trans "Qualification" %}: {{ qualification_request.qualification }}

+

{% trans "Requested at" %}: {{ qualification_request.requested_at }}

+ {% csrf_token %} {{ form.as_p }} + {% if is_own_request and not can_manage_own_request %} +

{% trans "You aren't allowed to manage your own qualification request." %}

+ {% else %} +

+ + +

+ {% endif %} +
+ +

+ {% if is_own_request %} + {% trans "Back" %} + {% else %} + {% trans "Back" %} + {% endif %} +

+{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html new file mode 100644 index 000000000..ef0250392 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html @@ -0,0 +1,35 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Submitted Qualificationsrequests" %}

+

{% trans "Here you can manage your submitted qualificationsrequests." %}

+ {% if can_submit %} + {% trans "Submit Qualification Request" %} + {% endif %} + + + + + + + + + + {% for request in qualification_requests %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Qualification" %}{% trans "Requested at" %}{% trans "Action" %}
{{ request.qualification }}{{ request.requested_at }} + {% trans "Check" %} +
{% trans "No qualificationsrequests found." %}
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html new file mode 100644 index 000000000..40fbc2661 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html @@ -0,0 +1,7 @@ +{% load i18n %} + + diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html new file mode 100644 index 000000000..e93989fdc --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html @@ -0,0 +1,15 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Submit Qualification" %}

+

{% trans "Here you can submit your qualifications." %}

+
+ {% csrf_token %} + {{ form.as_p }} +

+ +

+
+{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html new file mode 100644 index 000000000..328694782 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html @@ -0,0 +1,49 @@ +{% extends "core/settings/settings_base.html" %} + +{% load ephios_crispy %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Submitted Qualificationsrequests" %}

+

{% trans "Here you can manage all submitted qualificationsrequests." %}

+ +
+ {% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.qualification wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.status wrapper_class="col-12 col-lg" show_labels=False %} + +
+ + {% translate "Reset" %} +
+
+ + + + + + + + + + + + {% for request in qualification_requests %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "User" %}{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.user.get_full_name }}{{ request.qualification }}{{ request.status }} + {% trans "Check" %} +
{% trans "No qualificationsrequests found." %}
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html new file mode 100644 index 000000000..4137b2e4f --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html @@ -0,0 +1,33 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Submitted Qualificationsrequests" %}

+

{% trans "Here you can see your submitted qualificationsrequests." %}

+ {% trans "Request Qualification" %} + + + + + + + + + + {% for request in qualification_requests %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.qualification }}{{ request.status }} + {% trans "View" %} +
{% trans "No qualificationsrequests found." %}
+{% endblock %} \ No newline at end of file From 4d721272680939953961fec20fd25cba541462e3 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:26:26 +0200 Subject: [PATCH 23/55] added model from old plugin --- .../plugins/qualification_requests/models.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ephios/plugins/qualification_requests/models.py diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py new file mode 100644 index 000000000..18434287f --- /dev/null +++ b/ephios/plugins/qualification_requests/models.py @@ -0,0 +1,32 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from ephios.core.models import UserProfile, Qualification + +class QualificationRequest(models.Model): + user = models.ForeignKey( + UserProfile, + on_delete=models.CASCADE, + related_name='qualification_requests', + ) + qualification = models.ForeignKey( + Qualification, + on_delete=models.CASCADE, + related_name='qualification_requests', + ) + qualification_date = models.DateField(null=False, blank=False) + expiration_date = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + status = models.CharField( + max_length=20, + choices=[ + ('pending', _("Pending")), + ('approved', _("Approved")), + ('rejected', _("Rejected")), + ], + default='pending', + ) + #image_data = models.BinaryField(null=True, blank=True) + #image_content_type = models.CharField(max_length=100, null=True, blank=True) + + def __str__(self): + return f"{self.user} requested {self.qualification} on {self.created_at}" \ No newline at end of file From 219a3393ec50c1972cec897183aa9688f7bd6aaf Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:26:45 +0200 Subject: [PATCH 24/55] added add view (only view, not save) --- .../plugins/qualification_requests/forms.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 ephios/plugins/qualification_requests/forms.py diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py new file mode 100644 index 000000000..33e0a3ee8 --- /dev/null +++ b/ephios/plugins/qualification_requests/forms.py @@ -0,0 +1,24 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from django_select2.forms import ModelSelect2Widget + +from ephios.core.models import Qualification +from ephios.plugins.qualification_requests.models import QualificationRequest + +class QualificationRequestForm(forms.ModelForm): + class Meta: + model = QualificationRequest + fields = ["user", "qualification", "qualification_date", "expiration_date", "status"] + widgets = { + "qualification": ModelSelect2Widget( + model=Qualification.objects.all(), + search_fields=["title__icontains", "abbreviation__icontains"], + attrs={ + "data-placeholder": _("Select Qualification"), + "style": "width: 100%;", + }, + ), + "qualification_date": forms.DateInput(attrs={"type": "date"}), + "expiration_date": forms.DateInput(attrs={"type": "date"}), + "requested_at": forms.DateInput(attrs={"type": "date"}), + } \ No newline at end of file From 2e534a4416665ca74d5152e49f64f8e97f833edd Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:27:04 +0200 Subject: [PATCH 25/55] added views for own, all and add --- .../plugins/qualification_requests/views.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 ephios/plugins/qualification_requests/views.py diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py new file mode 100644 index 000000000..1d1b5494a --- /dev/null +++ b/ephios/plugins/qualification_requests/views.py @@ -0,0 +1,106 @@ +from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import Group +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet +from django.urls import reverse_lazy +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views.generic import ListView, FormView +from django.views.generic.detail import SingleObjectMixin +from django_select2.forms import ModelSelect2Widget, Select2Widget + +from ephios.core.models import Qualification +from ephios.extra.mixins import CustomPermissionRequiredMixin +from ephios.plugins.qualification_requests.forms import QualificationRequestForm +from ephios.plugins.qualification_requests.models import QualificationRequest + +class UserProfileFilterForm(forms.Form): + query = forms.CharField( + label=_("Search for…"), + widget=forms.TextInput(attrs={"placeholder": _("Search for…"), "autofocus": "autofocus"}), + required=False, + ) + qualification = forms.ModelChoiceField( + label=_("Qualification"), + queryset=Qualification.objects.all(), + required=False, + widget=ModelSelect2Widget( + search_fields=["title__icontains", "abbreviation__icontains"], + attrs={ + "data-placeholder": _("Qualification"), + "classes": "w-auto", + }, + ), + ) + status = forms.ChoiceField( + label=_("Status"), + choices=[ + ("", _("Any status")), + ("pending", _("Pending")), + ("approved", _("Approved")), + ("rejected", _("Rejected")), + ], + required=False, + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + + def filter(self, qs: QuerySet[QualificationRequest]): + fdata = self.cleaned_data + + if query := fdata.get("query"): + qs = qs.filter(Q(user__display_name__icontains=query) | Q(user__email__icontains=query)) + + if qualification := fdata.get("qualification"): + qs = qs.filter(qualification=qualification) + + if status := fdata.get("status"): + qs = qs.filter(status=status) + + return qs.distinct() + +class QualificationRequestListView(CustomPermissionRequiredMixin, ListView): + model = QualificationRequest + ordering = ("-created_at") + template_name = "qualification_requests/qualification_requests_list.html" + permission_required = "core.view_userprofile" + + @cached_property + def filter_form(self): + return UserProfileFilterForm(data=self.request.GET or None, request=self.request) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["filter_form"] = self.filter_form + return ctx + + def get_queryset(self): + qs = QualificationRequest.objects.select_related("user", "qualification") + if self.filter_form.is_valid(): + qs = self.filter_form.filter(qs) + return qs.order_by("-created_at", "-user__first_name", "-user__last_name") + +class QualificationRequestOwnListView(LoginRequiredMixin, ListView): + model = QualificationRequest + ordering = ("-created_at") + template_name = "qualification_requests/qualification_requests_list_own.html" + + def get_queryset(self): + return QualificationRequest.objects.filter(user=self.request.user).order_by("-created_at") + +class QualificationRequestAddView(LoginRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestForm + template_name = "qualification_requests/qualification_requests_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({"initial": {"user": self.request.user}}) + return kwargs + + def form_valid(self, form): + form.instance.user = self.request.user + return super().form_valid(form) \ No newline at end of file From 91cf98cf9378c3dd4ef39a2836cf4615ddf3d777 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:27:17 +0200 Subject: [PATCH 26/55] added urls for own, all, add --- ephios/plugins/qualification_requests/urls.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 ephios/plugins/qualification_requests/urls.py diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py new file mode 100644 index 000000000..87198c5a8 --- /dev/null +++ b/ephios/plugins/qualification_requests/urls.py @@ -0,0 +1,26 @@ +from django.urls import path +from ephios.plugins.qualification_requests.views import ( + QualificationRequestListView, + QualificationRequestOwnListView, + QualificationRequestAddView, +) + +app_name = "qualification_requests" + +urlpatterns = [ + path( + "settings/qualifications/requests/", + QualificationRequestListView.as_view(), + name="qualification_requests_list", + ), + path( + "settings/qualifications/requests/own/", + QualificationRequestOwnListView.as_view(), + name="qualification_requests_list_own", + ), + path( + "settings/qualifications/requests/add/", + QualificationRequestAddView.as_view(), + name="qualification_request_add", + ), +] \ No newline at end of file From 877515e792201427f179ee4282ca8ad4d490fe51 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Mon, 13 Oct 2025 23:27:31 +0200 Subject: [PATCH 27/55] added signals to add to settings --- .../plugins/qualification_requests/signals.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 ephios/plugins/qualification_requests/signals.py diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py new file mode 100644 index 000000000..2f2bfb024 --- /dev/null +++ b/ephios/plugins/qualification_requests/signals.py @@ -0,0 +1,55 @@ +from django.dispatch import receiver +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import gettext as _ + +from ephios.core.signals import ( + insert_html, + + settings_sections, + HTML_PERSONAL_DATA_PAGE +) +from ephios.core.views.settings import ( + SETTINGS_PERSONAL_SECTION_KEY, + SETTINGS_MANAGEMENT_SECTION_KEY +) + +@receiver( + insert_html, + dispatch_uid="ephios.plugins.qualification_submit.signals.add_qualification_request_badge", +) +def add_qualification_request_badge(sender, request, **kwargs): + if sender != HTML_PERSONAL_DATA_PAGE: + return "" + + return render_to_string("qualification_requests/qualification_requests_badge.html", request=request) + +@receiver( + settings_sections, + dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", +) +def add_navigation_item(sender, request, **kwargs): + return ( + ( + [ + { + "label": _(" Own Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list_own"), + "active": request.resolver_match.url_name.startswith("qualification_requests_list_own"), + "group": SETTINGS_PERSONAL_SECTION_KEY, + }, + ] + ) + + ( + [ + { + "label": _("Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list"), + "active": request.resolver_match.url_name.startswith("qualification_requests_list"), + "group": SETTINGS_MANAGEMENT_SECTION_KEY, + } + ] + if request.user.has_perm("core.view_userprofile") + else [] + ) + ) \ No newline at end of file From 0b12fcd01fc9f02fc11d08cfc87ee26313b8ad29 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 15 Oct 2025 23:11:50 +0200 Subject: [PATCH 28/55] added human readable names --- .../plugins/qualification_requests/models.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py index 18434287f..46384fd1f 100644 --- a/ephios/plugins/qualification_requests/models.py +++ b/ephios/plugins/qualification_requests/models.py @@ -7,15 +7,28 @@ class QualificationRequest(models.Model): UserProfile, on_delete=models.CASCADE, related_name='qualification_requests', + verbose_name=_("User"), ) qualification = models.ForeignKey( Qualification, on_delete=models.CASCADE, - related_name='qualification_requests', + related_name='qualification_request', + verbose_name=_("Qualification"), + ) + qualification_date = models.DateField( + null=False, + blank=False, + verbose_name=_("Qualification Date"), + ) + expiration_date = models.DateField( + null=True, + blank=True, + verbose_name=_("Expiration Date"), + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_("Created At"), ) - qualification_date = models.DateField(null=False, blank=False) - expiration_date = models.DateField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) status = models.CharField( max_length=20, choices=[ @@ -24,6 +37,7 @@ class QualificationRequest(models.Model): ('rejected', _("Rejected")), ], default='pending', + verbose_name=_("Status"), ) #image_data = models.BinaryField(null=True, blank=True) #image_content_type = models.CharField(max_length=100, null=True, blank=True) From 23608d8eacb0b5fdf23091ddcfb87eee442465fd Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 15 Oct 2025 23:12:17 +0200 Subject: [PATCH 29/55] reentered which fields to show in the list view. --- ephios/plugins/qualification_requests/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index 33e0a3ee8..3b926aca0 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -8,7 +8,7 @@ class QualificationRequestForm(forms.ModelForm): class Meta: model = QualificationRequest - fields = ["user", "qualification", "qualification_date", "expiration_date", "status"] + fields = ["user", "qualification", "qualification_date", "expiration_date"] widgets = { "qualification": ModelSelect2Widget( model=Qualification.objects.all(), From 3b20b12f27f842c2a25a837c5362c30dce23efd3 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 15 Oct 2025 23:12:41 +0200 Subject: [PATCH 30/55] corrected views an added update view --- .../plugins/qualification_requests/views.py | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 1d1b5494a..37e0b5e4d 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -80,11 +80,11 @@ def get_queryset(self): qs = QualificationRequest.objects.select_related("user", "qualification") if self.filter_form.is_valid(): qs = self.filter_form.filter(qs) - return qs.order_by("-created_at", "-user__first_name", "-user__last_name") + return qs.order_by("-created_at", "-user__display_name") class QualificationRequestOwnListView(LoginRequiredMixin, ListView): model = QualificationRequest - ordering = ("-created_at") + ordering = ("-created_at",) template_name = "qualification_requests/qualification_requests_list_own.html" def get_queryset(self): @@ -102,5 +102,38 @@ def get_form_kwargs(self): return kwargs def form_valid(self, form): - form.instance.user = self.request.user + qualification_request = QualificationRequest.objects.create( + user=self.request.user, + qualification=form.instance.qualification, + qualification_date=form.instance.qualification_date + ) + + print(f"Created qualification request: {qualification_request}") + + return super().form_valid(form) + +class QualificationRequestUpdateView(LoginRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestForm + template_name = "qualification_requests/qualification_requests_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + if self.object.user != request.user: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + return QualificationRequest.objects.get(pk=self.kwargs["pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({"instance": self.object}) + return kwargs + + def form_valid(self, form): + self.object.qualification = form.instance.qualification + self.object.qualification_date = form.instance.qualification_date + self.object.save() return super().form_valid(form) \ No newline at end of file From c244addb6e326d320a7a219160e8fdfb8bd12fca Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 15 Oct 2025 23:12:59 +0200 Subject: [PATCH 31/55] added url for update view --- ephios/plugins/qualification_requests/urls.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py index 87198c5a8..0a551d5a1 100644 --- a/ephios/plugins/qualification_requests/urls.py +++ b/ephios/plugins/qualification_requests/urls.py @@ -3,6 +3,7 @@ QualificationRequestListView, QualificationRequestOwnListView, QualificationRequestAddView, + QualificationRequestUpdateView, ) app_name = "qualification_requests" @@ -23,4 +24,9 @@ QualificationRequestAddView.as_view(), name="qualification_request_add", ), + path( + "settings/qualifications/requests//", + QualificationRequestUpdateView.as_view(), + name="qualification_request_update", + ), ] \ No newline at end of file From 1dee0149df756be68e577b710700a5e8368dfafb Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Wed, 15 Oct 2025 23:13:16 +0200 Subject: [PATCH 32/55] corrected tempaltes for list views --- .../qualification_requests/qualification_requests_list.html | 4 ++-- .../qualification_requests_list_own.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html index 328694782..03fe4854d 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html @@ -30,13 +30,13 @@

{% trans "Submitted Qualificationsrequests" %}

- {% for request in qualification_requests %} + {% for request in qualificationrequest_list %} {{ request.user.get_full_name }} {{ request.qualification }} {{ request.status }} - {% trans "Check" %} + {% trans "Check" %} {% empty %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html index 4137b2e4f..e1307423b 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html @@ -15,12 +15,12 @@

{% trans "Submitted Qualificationsrequests" %}

- {% for request in qualification_requests %} + {% for request in qualificationrequest_list %} {{ request.qualification }} {{ request.status }} - {% trans "View" %} + {% trans "View" %} {% empty %} From 494306869d50ac103a0fbcebfb1a63497568b7dd Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 16 Oct 2025 23:13:18 +0200 Subject: [PATCH 33/55] corrected templates --- ....html => qualification_requests_add_form.html} | 0 .../qualification_requests_check_form.html | 15 +++++++++++++++ .../qualification_requests_list.html | 2 +- .../qualification_requests_list_own.html | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) rename ephios/plugins/qualification_requests/templates/qualification_requests/{qualification_requests_form.html => qualification_requests_add_form.html} (100%) create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html similarity index 100% rename from ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html rename to ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html new file mode 100644 index 000000000..e93989fdc --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html @@ -0,0 +1,15 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Submit Qualification" %}

+

{% trans "Here you can submit your qualifications." %}

+
+ {% csrf_token %} + {{ form.as_p }} +

+ +

+
+{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html index 03fe4854d..c614bd2db 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html @@ -36,7 +36,7 @@

{% trans "Submitted Qualificationsrequests" %}

{{ request.qualification }} {{ request.status }} - {% trans "Check" %} + {% trans "Check" %} {% empty %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html index e1307423b..8a01e9151 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html @@ -20,7 +20,7 @@

{% trans "Submitted Qualificationsrequests" %}

{{ request.qualification }} {{ request.status }} - {% trans "View" %} + {% trans "View" %} {% empty %} From b36d3dff644fa369f4a12236faa190d7f040bff4 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 16 Oct 2025 23:13:33 +0200 Subject: [PATCH 34/55] added user_comment to model --- ephios/plugins/qualification_requests/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py index 46384fd1f..9ba9a9c77 100644 --- a/ephios/plugins/qualification_requests/models.py +++ b/ephios/plugins/qualification_requests/models.py @@ -29,6 +29,11 @@ class QualificationRequest(models.Model): auto_now_add=True, verbose_name=_("Created At"), ) + user_comment = models.TextField( + null=True, + blank=True, + verbose_name=_("User Comment"), + ) status = models.CharField( max_length=20, choices=[ From ffb2204c0257692f920c7d63ed14c35150a56473 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 16 Oct 2025 23:13:49 +0200 Subject: [PATCH 35/55] seperated form for checking --- .../plugins/qualification_requests/forms.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index 3b926aca0..c3ff92ceb 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -5,20 +5,54 @@ from ephios.core.models import Qualification from ephios.plugins.qualification_requests.models import QualificationRequest -class QualificationRequestForm(forms.ModelForm): +class QualificationRequestAddForm(forms.ModelForm): class Meta: model = QualificationRequest - fields = ["user", "qualification", "qualification_date", "expiration_date"] + fields = [ + "qualification", + "qualification_date", + "user_comment" + ] widgets = { "qualification": ModelSelect2Widget( model=Qualification.objects.all(), search_fields=["title__icontains", "abbreviation__icontains"], attrs={ "data-placeholder": _("Select Qualification"), - "style": "width: 100%;", }, ), "qualification_date": forms.DateInput(attrs={"type": "date"}), + "user_comment": forms.Textarea(attrs={"rows": 4}), + } + +class QualificationRequestCheckForm(forms.ModelForm): + + created_at = forms.DateTimeField( + label=_("Created At"), + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "readonly": "readonly"}), + required=False, + ) + + class Meta: + model = QualificationRequest + fields = [ + "user", + "qualification", + "qualification_date", + "expiration_date", + "user_comment", + "status" + ] + widgets = { + "user": forms.TextInput(attrs={"readonly": "readonly"}), + "qualification": forms.TextInput(attrs={"readonly": "readonly"}), + "qualification_date": forms.DateInput(attrs={"type": "date", "readonly": "readonly"}), "expiration_date": forms.DateInput(attrs={"type": "date"}), - "requested_at": forms.DateInput(attrs={"type": "date"}), - } \ No newline at end of file + "user_comment": forms.Textarea(attrs={"rows": 4, "readonly": "readonly"}), + "status": forms.Select(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields["created_at"].initial = self.instance.created_at \ No newline at end of file From e157ef8af63d04aaac6f2b07b320da8b67ef7e66 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 16 Oct 2025 23:14:01 +0200 Subject: [PATCH 36/55] configured view for new form --- ephios/plugins/qualification_requests/views.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 37e0b5e4d..117e80dfb 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -1,17 +1,19 @@ from django import forms from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import Group -from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet +from django.db.models import Q, QuerySet from django.urls import reverse_lazy from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.views.generic import ListView, FormView -from django.views.generic.detail import SingleObjectMixin -from django_select2.forms import ModelSelect2Widget, Select2Widget +from django_select2.forms import ModelSelect2Widget from ephios.core.models import Qualification from ephios.extra.mixins import CustomPermissionRequiredMixin -from ephios.plugins.qualification_requests.forms import QualificationRequestForm +from ephios.plugins.qualification_requests.forms import ( + QualificationRequestAddForm, + QualificationRequestCheckForm, +) from ephios.plugins.qualification_requests.models import QualificationRequest class UserProfileFilterForm(forms.Form): @@ -92,7 +94,7 @@ def get_queryset(self): class QualificationRequestAddView(LoginRequiredMixin, FormView): model = QualificationRequest - form_class = QualificationRequestForm + form_class = QualificationRequestAddForm template_name = "qualification_requests/qualification_requests_form.html" success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") @@ -112,9 +114,9 @@ def form_valid(self, form): return super().form_valid(form) -class QualificationRequestUpdateView(LoginRequiredMixin, FormView): +class QualificationRequestCheckView(LoginRequiredMixin, FormView): model = QualificationRequest - form_class = QualificationRequestForm + form_class = QualificationRequestCheckForm template_name = "qualification_requests/qualification_requests_form.html" success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") From d931e4d4fa92b7870dc91f5d4c5802d035482433 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 16 Oct 2025 23:14:11 +0200 Subject: [PATCH 37/55] changed names in urls --- ephios/plugins/qualification_requests/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py index 0a551d5a1..bd4c890b2 100644 --- a/ephios/plugins/qualification_requests/urls.py +++ b/ephios/plugins/qualification_requests/urls.py @@ -3,7 +3,7 @@ QualificationRequestListView, QualificationRequestOwnListView, QualificationRequestAddView, - QualificationRequestUpdateView, + QualificationRequestCheckView, ) app_name = "qualification_requests" @@ -26,7 +26,7 @@ ), path( "settings/qualifications/requests//", - QualificationRequestUpdateView.as_view(), - name="qualification_request_update", + QualificationRequestCheckView.as_view(), + name="qualification_request_check", ), ] \ No newline at end of file From c1403bcb3394b2dd3079694e6289e86d02b33a93 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Sun, 19 Oct 2025 16:15:40 +0200 Subject: [PATCH 38/55] removed batch --- .../plugins/qualification_requests/signals.py | 17 ----------------- .../qualification_requests_badge.html | 7 ------- 2 files changed, 24 deletions(-) delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index 2f2bfb024..fba7ec0be 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -1,29 +1,12 @@ from django.dispatch import receiver -from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext as _ -from ephios.core.signals import ( - insert_html, - - settings_sections, - HTML_PERSONAL_DATA_PAGE -) from ephios.core.views.settings import ( SETTINGS_PERSONAL_SECTION_KEY, SETTINGS_MANAGEMENT_SECTION_KEY ) -@receiver( - insert_html, - dispatch_uid="ephios.plugins.qualification_submit.signals.add_qualification_request_badge", -) -def add_qualification_request_badge(sender, request, **kwargs): - if sender != HTML_PERSONAL_DATA_PAGE: - return "" - - return render_to_string("qualification_requests/qualification_requests_badge.html", request=request) - @receiver( settings_sections, dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html deleted file mode 100644 index 40fbc2661..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_badge.html +++ /dev/null @@ -1,7 +0,0 @@ -{% load i18n %} - - From 83f71d3864d9a0d9298220eaa764802cdbc2c1f5 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Sun, 19 Oct 2025 16:19:09 +0200 Subject: [PATCH 39/55] added falsly deleted import --- ephios/plugins/qualification_requests/signals.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index fba7ec0be..dd7c26c07 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -2,6 +2,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from ephios.core.signals import settings_sections from ephios.core.views.settings import ( SETTINGS_PERSONAL_SECTION_KEY, SETTINGS_MANAGEMENT_SECTION_KEY From 6e2e1c9e611deb77fe1f643d7d671e8245fd1a56 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:39:54 +0200 Subject: [PATCH 40/55] removed unused templates --- .../qualification_requests/details_form.html | 87 ------------------- .../qualification_requests/own_list.html | 35 -------- 2 files changed, 122 deletions(-) delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html deleted file mode 100644 index 013dd6601..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/details_form.html +++ /dev/null @@ -1,87 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load static %} -{% load i18n %} - -{% block javascript %} - -{% endblock %} - -{% block css %} - -{% endblock %} - -{% block settings_content %} -

{% trans "Details for the Qualificationrequest" %}

-{% if qualification_request.image_data %} -

{% trans "Uploaded Image" %}

- - {% trans 'Uploaded Image' %} - -

- - {% trans "Download Image" %} - -

- - -{% else %} -

{% trans "No image uploaded." %}

-{% endif %} - -
-

{% trans "User" %}: {{ qualification_request.user }}

-

{% trans "Qualification" %}: {{ qualification_request.qualification }}

-

{% trans "Requested at" %}: {{ qualification_request.requested_at }}

- {% csrf_token %} {{ form.as_p }} - {% if is_own_request and not can_manage_own_request %} -

{% trans "You aren't allowed to manage your own qualification request." %}

- {% else %} -

- - -

- {% endif %} -
- -

- {% if is_own_request %} - {% trans "Back" %} - {% else %} - {% trans "Back" %} - {% endif %} -

-{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html deleted file mode 100644 index ef0250392..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/own_list.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load i18n %} - -{% block settings_content %} -

{% trans "Submitted Qualificationsrequests" %}

-

{% trans "Here you can manage your submitted qualificationsrequests." %}

- {% if can_submit %} - {% trans "Submit Qualification Request" %} - {% endif %} - - - - - - - - - - {% for request in qualification_requests %} - - - - - - {% empty %} - - - - {% endfor %} - -
{% trans "Qualification" %}{% trans "Requested at" %}{% trans "Action" %}
{{ request.qualification }}{{ request.requested_at }} - {% trans "Check" %} -
{% trans "No qualificationsrequests found." %}
-{% endblock %} \ No newline at end of file From 784ecd92b64e161dd3e96a5b8677465054b8648d Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:40:12 +0200 Subject: [PATCH 41/55] removed unused fields in form of comments --- ephios/core/models/users.py | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 82f6aff77..4b664fe33 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -289,18 +289,6 @@ def formfield(self, **kwargs): **kwargs, ) - -"""expiration_format_validator = RegexValidator( - regex=( - r"^$|" # empty - r"^(?:\d{1,2}|0{1,2}|\+\d+)\." # day: 1–2 digits OR 0/00 OR +N - r"(?:\d{1,2}|0{1,2}|\+\d+)\." # month: 1–2 digits OR 0/00 OR +N - r"(?:\d{4}|0{1,2}|0000|\+\d+)$|" # year: 4 digits OR 0 OR 00 OR 0000 OR +N - r"^\+\d+$" # relative only: +N - ), - message=_("Invalid format."), -)""" - class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") title = CharField(_("title"), max_length=254) @@ -319,24 +307,6 @@ class Qualification(Model): symmetrical=False, blank=True, ) - """default_expiration_time = models.CharField( - max_length=254, - verbose_name=_("Default expiration format"), - help_text=_( - "Allowed are:
" - "- leave empty
" - "- DD.MM.YYYY (e.g. 31.12.2025)
" - "- +N (relative years/months/days)
" - "- Mixed like 30.06.+2 or 0.6.+1
" - "- Fully relative like +1.+1.+1
" - "- Placeholders:
" - "  • 0 or 00 for day/month
" - "  • 0, 00 or 0000 for year" - ), - null=True, - blank=True, - validators=[expiration_format_validator], - )""" default_expiration_time = DefaultExpirationTimeField( verbose_name=_("Default expiration time"), help_text=_( From 41e9655d74fc9f7981bc067158f1f9f1e581b203 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:40:38 +0200 Subject: [PATCH 42/55] restructured templates --- .../qualification_requests_add_form.html | 10 +++++---- .../qualification_requests_check_form.html | 22 ++++++++++++++----- .../qualification_requests_delete_form.html | 18 +++++++++++++++ ...ualification_requests_delete_own_form.html | 21 ++++++++++++++++++ .../qualification_requests_list.html | 6 ++--- .../qualification_requests_list_own.html | 8 +++---- .../qualification_requests_update_form.html | 21 ++++++++++++++++++ 7 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html index e93989fdc..9c80ecf48 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html @@ -1,15 +1,17 @@ {% extends "core/settings/settings_base.html" %} +{% load crispy_forms_tags %} {% load i18n %} {% block settings_content %} -

{% trans "Submit Qualification" %}

-

{% trans "Here you can submit your qualifications." %}

+

{% trans "Request Qualification" %}

+

{% trans "Here you can request a qualification." %}

{% csrf_token %} - {{ form.as_p }} + {{ form|crispy }}

+ {% trans "Back" %}

-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html index e93989fdc..17476af52 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html @@ -1,15 +1,27 @@ {% extends "core/settings/settings_base.html" %} +{% load crispy_forms_tags %} {% load i18n %} {% block settings_content %} -

{% trans "Submit Qualification" %}

-

{% trans "Here you can submit your qualifications." %}

+

{% trans "Check Qualificationrequest" %}

{% csrf_token %} - {{ form.as_p }} +

{% trans "Created at" %}: {{ form.instance.created_at }}

+ {{ form|crispy }}

- + {% trans "Back" %} + {% if form.instance.status == "pending" %} + + {% else %} + {% trans "Delete" %} + {% endif %}

+ {% if form.instance.status == "pending" %} +

+ + +

+ {% endif %}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html new file mode 100644 index 000000000..c76cd2351 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html @@ -0,0 +1,18 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Delete Qualification Request" %}

+ +

+ {% trans "Are you sure you want to delete the qualification request" %} + "{{ object }}"? +

+ +
+ {% csrf_token %} + {% trans "Cancel" %} + +
+{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html new file mode 100644 index 000000000..237bab0d0 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html @@ -0,0 +1,21 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Delete Qualificationrequest" %}

+
+ {% csrf_token %} + {{ form|crispy }} +

+ {% trans "Back" %} + {% if form.instance.status != "pending" %} + + {% endif %} +

+ + {% if form.instance.status == "pending" %} +

{% trans "You can't delete a request that is still pending." %}

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html index c614bd2db..0f8e6076a 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html @@ -4,8 +4,8 @@ {% load i18n %} {% block settings_content %} -

{% trans "Submitted Qualificationsrequests" %}

-

{% trans "Here you can manage all submitted qualificationsrequests." %}

+

{% trans "Qualificationrequests" %}

+

{% trans "Here you see all qualificationsrequests." %}

{% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} @@ -36,7 +36,7 @@

{% trans "Submitted Qualificationsrequests" %}

{{ request.qualification }} {{ request.status }} - {% trans "Check" %} + {% trans "Check" %} {% empty %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html index 8a01e9151..f75e83a08 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html @@ -3,9 +3,9 @@ {% load i18n %} {% block settings_content %} -

{% trans "Submitted Qualificationsrequests" %}

-

{% trans "Here you can see your submitted qualificationsrequests." %}

- {% trans "Request Qualification" %} +

{% trans "Your Qualificationrequests" %}

+

{% trans "Here you can see your qualificationsrequests." %}

+ {% trans "Create Qualificationrequest" %} @@ -20,7 +20,7 @@

{% trans "Submitted Qualificationsrequests" %}

{% empty %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html new file mode 100644 index 000000000..4accfbc15 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html @@ -0,0 +1,21 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Check Qualificationrequest" %}

+ + {% csrf_token %} +

{% trans "Created at" %}: {{ form.instance.created_at }}

+ {{ form|crispy }} +

+ {% trans "Back" %} + {% if form.instance.status == "pending" %} + + {% else %} + {% trans "Delete" %} + {% endif %} +

+ +{% endblock %} \ No newline at end of file From 1181fc38c76c6a75674b70a846cbc7f397305405 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:41:02 +0200 Subject: [PATCH 43/55] refactoring model and adding __str__ method --- .../plugins/qualification_requests/models.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py index 9ba9a9c77..8dc6022f1 100644 --- a/ephios/plugins/qualification_requests/models.py +++ b/ephios/plugins/qualification_requests/models.py @@ -1,40 +1,46 @@ from django.db import models +from django.db.models import ( + CharField, + DateField, + DateTimeField, + ForeignKey, +) from django.utils.translation import gettext_lazy as _ from ephios.core.models import UserProfile, Qualification class QualificationRequest(models.Model): - user = models.ForeignKey( + user = ForeignKey( UserProfile, on_delete=models.CASCADE, related_name='qualification_requests', verbose_name=_("User"), ) - qualification = models.ForeignKey( + qualification = ForeignKey( Qualification, on_delete=models.CASCADE, related_name='qualification_request', verbose_name=_("Qualification"), ) - qualification_date = models.DateField( + qualification_date = DateField( null=False, blank=False, verbose_name=_("Qualification Date"), ) - expiration_date = models.DateField( + expiration_date = DateField( null=True, blank=True, verbose_name=_("Expiration Date"), ) - created_at = models.DateTimeField( + created_at = DateTimeField( auto_now_add=True, verbose_name=_("Created At"), ) - user_comment = models.TextField( + user_comment = CharField( null=True, blank=True, verbose_name=_("User Comment"), ) - status = models.CharField( + status = CharField( max_length=20, choices=[ ('pending', _("Pending")), @@ -44,8 +50,20 @@ class QualificationRequest(models.Model): default='pending', verbose_name=_("Status"), ) + reason = CharField( + null=True, + blank=True, + verbose_name=_("Reason"), + ) #image_data = models.BinaryField(null=True, blank=True) #image_content_type = models.CharField(max_length=100, null=True, blank=True) def __str__(self): - return f"{self.user} requested {self.qualification} on {self.created_at}" \ No newline at end of file + return _( + "%(user)s requested %(qualification)s on %(created)s (Status: %(status)s)" + ) % { + "user": self.user, + "qualification": self.qualification, + "created": self.created_at.strftime("%d.%m.%Y %H:%M"), + "status": self.status, # zeigt die übersetzte Status-Option + } \ No newline at end of file From ac0f0064476cd98d8016eef6122283e8e790d3a7 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:41:36 +0200 Subject: [PATCH 44/55] reworking form into multiple simpler forms --- .../plugins/qualification_requests/forms.py | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index c3ff92ceb..b6658dde5 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -1,38 +1,38 @@ from django import forms +from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ -from django_select2.forms import ModelSelect2Widget +from django_select2.forms import Select2Widget -from ephios.core.models import Qualification +from ephios.extra.widgets import CustomDateInput from ephios.plugins.qualification_requests.models import QualificationRequest -class QualificationRequestAddForm(forms.ModelForm): +class QualificationRequestCreateForm(ModelForm): class Meta: model = QualificationRequest fields = [ "qualification", "qualification_date", - "user_comment" + "user_comment", ] widgets = { - "qualification": ModelSelect2Widget( - model=Qualification.objects.all(), - search_fields=["title__icontains", "abbreviation__icontains"], - attrs={ - "data-placeholder": _("Select Qualification"), - }, - ), - "qualification_date": forms.DateInput(attrs={"type": "date"}), - "user_comment": forms.Textarea(attrs={"rows": 4}), + "qualification": Select2Widget, + "qualification_date": CustomDateInput, } -class QualificationRequestCheckForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - created_at = forms.DateTimeField( - label=_("Created At"), - widget=forms.DateTimeInput(attrs={"type": "datetime-local", "readonly": "readonly"}), - required=False, - ) + # Default auf "pending", wenn status nicht existiert + status = getattr(self.instance, "status", "pending") + if status != "pending": + self.disable_fields(self.fields.keys()) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True +class QualificationRequestCheckForm(ModelForm): class Meta: model = QualificationRequest fields = [ @@ -41,18 +41,32 @@ class Meta: "qualification_date", "expiration_date", "user_comment", - "status" + "status", + "reason", ] widgets = { - "user": forms.TextInput(attrs={"readonly": "readonly"}), - "qualification": forms.TextInput(attrs={"readonly": "readonly"}), - "qualification_date": forms.DateInput(attrs={"type": "date", "readonly": "readonly"}), - "expiration_date": forms.DateInput(attrs={"type": "date"}), - "user_comment": forms.Textarea(attrs={"rows": 4, "readonly": "readonly"}), - "status": forms.Select(), + "qualification": Select2Widget, + "qualification_date": CustomDateInput, + "expiration_date": CustomDateInput, + } + help_texts = { + "expiration_date": _("Leave empty for no expiration."), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.instance and self.instance.pk: - self.fields["created_at"].initial = self.instance.created_at \ No newline at end of file + + if self.instance.status != "pending": + self.disable_fields(self.fields.keys()) + return + + self.disable_fields([ + "user", + "user_comment", + "status", + ]) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True \ No newline at end of file From 36ea6bb0e542e15076a2fbc7cc372a8ddd783fef Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:41:53 +0200 Subject: [PATCH 45/55] corrected active state for nav items --- ephios/plugins/qualification_requests/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index dd7c26c07..888d50931 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -19,7 +19,7 @@ def add_navigation_item(sender, request, **kwargs): { "label": _(" Own Qualification Requests"), "url": reverse("qualification_requests:qualification_requests_list_own"), - "active": request.resolver_match.url_name.startswith("qualification_requests_list_own"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and request.resolver_match.url_name.endswith("_own"), "group": SETTINGS_PERSONAL_SECTION_KEY, }, ] @@ -29,7 +29,7 @@ def add_navigation_item(sender, request, **kwargs): { "label": _("Qualification Requests"), "url": reverse("qualification_requests:qualification_requests_list"), - "active": request.resolver_match.url_name.startswith("qualification_requests_list"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and not request.resolver_match.url_name.endswith("_own"), "group": SETTINGS_MANAGEMENT_SECTION_KEY, } ] From 124822a80ae04fe777c1791db590761151dcaf9e Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:42:02 +0200 Subject: [PATCH 46/55] added and corrected url names --- ephios/plugins/qualification_requests/urls.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py index bd4c890b2..d4350946c 100644 --- a/ephios/plugins/qualification_requests/urls.py +++ b/ephios/plugins/qualification_requests/urls.py @@ -2,8 +2,11 @@ from ephios.plugins.qualification_requests.views import ( QualificationRequestListView, QualificationRequestOwnListView, - QualificationRequestAddView, + QualificationRequestOwnCreateView, + QualificationRequestOwnUpdateView, QualificationRequestCheckView, + QualificationRequestOwnDeleteView, + QualificationRequestDeleteView, ) app_name = "qualification_requests" @@ -20,13 +23,28 @@ name="qualification_requests_list_own", ), path( - "settings/qualifications/requests/add/", - QualificationRequestAddView.as_view(), - name="qualification_request_add", + "settings/qualifications/requests/create/", + QualificationRequestOwnCreateView.as_view(), + name="qualification_requests_create_own", ), path( - "settings/qualifications/requests//", + "settings/qualifications/requests//edit/", + QualificationRequestOwnUpdateView.as_view(), + name="qualification_requests_update_own", + ), + path( + "settings/qualifications/requests//check/", QualificationRequestCheckView.as_view(), - name="qualification_request_check", + name="qualification_requests_check", + ), + path( + "settings/qualifications/requests//deleteown/", + QualificationRequestOwnDeleteView.as_view(), + name="qualification_requests_delete_own", + ), + path( + "settings/qualifications/requests//delete/", + QualificationRequestDeleteView.as_view(), + name="qualification_requests_delete", ), ] \ No newline at end of file From de3558fee28d28a8c50f2adb8e2b8279f163ff53 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Tue, 21 Oct 2025 20:42:39 +0200 Subject: [PATCH 47/55] restructured add view and adding check and delete views --- .../plugins/qualification_requests/views.py | 151 +++++++++++++++--- 1 file changed, 132 insertions(+), 19 deletions(-) diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 117e80dfb..9ccc2a687 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -1,17 +1,26 @@ from django import forms +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import Group from django.db.models import Q, QuerySet +from django.http import ( + HttpResponseRedirect, + HttpResponseForbidden, +) from django.urls import reverse_lazy from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.generic import ListView, FormView +from django.views.generic import ( + ListView, + FormView, + DeleteView, +) from django_select2.forms import ModelSelect2Widget -from ephios.core.models import Qualification +from ephios.core.models import Qualification, QualificationGrant from ephios.extra.mixins import CustomPermissionRequiredMixin from ephios.plugins.qualification_requests.forms import ( - QualificationRequestAddForm, + QualificationRequestCreateForm, QualificationRequestCheckForm, ) from ephios.plugins.qualification_requests.models import QualificationRequest @@ -92,32 +101,26 @@ class QualificationRequestOwnListView(LoginRequiredMixin, ListView): def get_queryset(self): return QualificationRequest.objects.filter(user=self.request.user).order_by("-created_at") -class QualificationRequestAddView(LoginRequiredMixin, FormView): +class QualificationRequestOwnCreateView(LoginRequiredMixin, FormView): model = QualificationRequest - form_class = QualificationRequestAddForm - template_name = "qualification_requests/qualification_requests_form.html" + form_class = QualificationRequestCreateForm + template_name = "qualification_requests/qualification_requests_add_form.html" success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.update({"initial": {"user": self.request.user}}) - return kwargs - def form_valid(self, form): - qualification_request = QualificationRequest.objects.create( + QualificationRequest.objects.create( user=self.request.user, qualification=form.instance.qualification, - qualification_date=form.instance.qualification_date + qualification_date=form.instance.qualification_date, + user_comment=form.instance.user_comment, ) - print(f"Created qualification request: {qualification_request}") - return super().form_valid(form) -class QualificationRequestCheckView(LoginRequiredMixin, FormView): +class QualificationRequestOwnUpdateView(LoginRequiredMixin, FormView): model = QualificationRequest - form_class = QualificationRequestCheckForm - template_name = "qualification_requests/qualification_requests_form.html" + form_class = QualificationRequestCreateForm + template_name = "qualification_requests/qualification_requests_update_form.html" success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") def dispatch(self, request, *args, **kwargs): @@ -128,14 +131,124 @@ def dispatch(self, request, *args, **kwargs): def get_object(self): return QualificationRequest.objects.get(pk=self.kwargs["pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({"instance": self.object}) + return kwargs + def form_valid(self, form): + if self.object.status != "pending": + messages.error( + self.request, + _("You cannot edit a qualification request that is not pending.") + ) + return self.form_invalid(form) + + self.object.qualification = form.instance.qualification + self.object.qualification_date = form.instance.qualification_date + self.object.user_comment = form.instance.user_comment + self.object.save() + return super().form_valid(form) + +class QualificationRequestCheckView(CustomPermissionRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestCheckForm + template_name = "qualification_requests/qualification_requests_check_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list") + permission_required = "core.change_userprofile" + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + return QualificationRequest.objects.get(pk=self.kwargs["pk"]) + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs.update({"instance": self.object}) return kwargs def form_valid(self, form): + if self.object.status != "pending": + messages.error( + self.request, + _("You cannot edit a qualification request that is not pending.") + ) + return self.form_invalid(form) + self.object.qualification = form.instance.qualification self.object.qualification_date = form.instance.qualification_date + self.object.expiration_date = form.instance.expiration_date + self.object.reason = form.instance.reason self.object.save() - return super().form_valid(form) \ No newline at end of file + + action = self.request.POST.get("action") + if action == "approve": + form.instance.status = "approved" + form.instance.save() + messages.success(self.request, _("Qualification request approved.")) + self.grant_qualification() + elif action == "reject": + form.instance.status = "rejected" + form.instance.save() + messages.success(self.request, _("Qualification request rejected.")) + return super().form_valid(form) + + def grant_qualification(self): + """Grant the qualification to the user if the request is approved.""" + if self.object.status == "approved": + return QualificationGrant.objects.get_or_create( + user=self.object.user, + qualification=self.object.qualification, + expires=self.object.expiration_date if self.object.expiration_date else None, + ) + +class QualificationRequestOwnDeleteView(LoginRequiredMixin, DeleteView): + model = QualificationRequest + template_name = "qualification_requests/qualification_requests_delete_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.object.user != request.user: + return HttpResponseForbidden(_("You have no rights to delete this request.")) + + if self.object.status == "pending": + messages.error( + self.request, + _("You cannot delete a qualification request that is pending.") + ) + return HttpResponseRedirect(self.success_url) + + self.object.delete() + messages.success( + request, + _("Qualification request deleted.") + ) + return HttpResponseRedirect(self.success_url) + +class QualificationRequestDeleteView(CustomPermissionRequiredMixin, DeleteView): + model = QualificationRequest + template_name = "qualification_requests/qualification_requests_delete_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list") + permission_required = "core.change_userprofile" + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.object.status == "pending": + messages.error( + self.request, + _("You cannot delete a qualification request that is pending.") + ) + return HttpResponseRedirect(self.success_url) + + self.object.delete() + messages.success( + request, + _("Qualification request deleted.") + ) + return HttpResponseRedirect(self.success_url) \ No newline at end of file From 16a7207dcb111e16f1ade55047b5002c82c1e421 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Sat, 25 Oct 2025 23:29:41 +0200 Subject: [PATCH 48/55] added calculation for expiration date --- ephios/api/urls.py | 6 ++ ephios/api/views/users.py | 87 ++++++++++++++++++- ephios/core/models/users.py | 5 +- ephios/extra/widgets.py | 2 +- .../qualification_requests_check_script.js | 60 +++++++++++++ .../qualification_requests_check_form.html | 3 + 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js diff --git a/ephios/api/urls.py b/ephios/api/urls.py index a7dbce08d..b208561b4 100644 --- a/ephios/api/urls.py +++ b/ephios/api/urls.py @@ -25,6 +25,7 @@ UserParticipationView, UserProfileMeView, UserViewSet, + calculate_expiration_date, ) from ephios.extra.permissions import staff_required @@ -109,5 +110,10 @@ SpectacularSwaggerSplitView.as_view(url_name="openapi-schema"), name="swagger-ui", ), + path( + "qualifications/default-expiration-date/calculate/", + calculate_expiration_date, + name="default_expiration_time_calculate" + ), path("", include(router.urls)), ] diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index 42b05bcb1..e73269137 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -1,4 +1,8 @@ +from datetime import date +from django.http import JsonResponse from django_filters.rest_framework import DjangoFilterBackend +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_GET from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied @@ -22,7 +26,11 @@ UserinfoParticipationSerializer, UserProfileSerializer, ) -from ephios.core.models import AbstractParticipation, UserProfile +from ephios.core.models import ( + AbstractParticipation, + UserProfile, + Qualification, +) class UserProfileMeView(RetrieveAPIView): @@ -84,3 +92,80 @@ def get_queryset(self): return AbstractParticipation.objects.filter( localparticipation__user=self.kwargs.get("user") ).select_related("shift", "shift__event", "shift__event__type") + + +@require_GET +def calculate_expiration_date(request): + qualification_id = request.GET.get("qualification") + qualification_date_str = request.GET.get("qualification_date") + + # Eingaben prüfen + if not qualification_id: + return JsonResponse( + { + "error": _("No qualification selected."), + "expiration_date": "", + }, + status=400, + ) + if not qualification_date_str: + return JsonResponse( + { + "error": _("No qualification date provided."), + "expiration_date": "", + }, + status=400, + ) + + try: + qualification = Qualification.objects.get(pk=qualification_id) + except Qualification.DoesNotExist: + return JsonResponse( + { + "error": _("Selected qualification does not exist."), + "expiration_date": "", + }, + status=400, + ) + try: + qualification_date = date.fromisoformat(qualification_date_str) + except ValueError: + return JsonResponse( + { + "error": _("Invalid qualification date format."), + "expiration_date": "", + }, + status=400, + ) + + # Default Expiration Time prüfen + default_expiration = getattr(qualification, "default_expiration_time", None) + if not default_expiration: + return JsonResponse( + { + "error": _("This qualification has no default expiration time defined."), + "expiration_date": "", + }, + status=200, + ) + + # Ablaufdatum berechnen + try: + expiration_date = default_expiration.apply_to(qualification_date) + except Exception as e: + return JsonResponse( + { + "error": _("Error while calculating expiration date: %(error)s") % {"error": str(e)}, + "expiration_date": "", + }, + status=500, + ) + + # Erfolg + return JsonResponse( + { + "error": "", + "expiration_date": expiration_date.isoformat() if expiration_date else "", + }, + status=200, + ) \ No newline at end of file diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 4b664fe33..8e8eca83a 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -11,7 +11,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import Group, PermissionsMixin -from django.core.validators import RegexValidator from django.db import models, transaction from django.db.models import ( BooleanField, @@ -31,11 +30,11 @@ ) from django.db.models.functions import Lower, TruncDate from django.utils import timezone -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from ephios.extra.fields import EndOfDayDateTimeField, RelativeTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder +from ephios.extra.relative_time import RelativeTimeModelField from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget from ephios.modellogging.log import ( ModelFieldsLogConfig, @@ -277,7 +276,7 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) -class DefaultExpirationTimeField(models.JSONField): +class DefaultExpirationTimeField(RelativeTimeModelField): """ A model field whose formfield is a RelativeTimeField """ diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 168589d11..8ed13c49a 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -90,7 +90,7 @@ def __init__(self, *args, **kwargs): forms.Select( choices=choices, attrs={ - "class": "form-control", + "class": "form-select", "title": _("Type"), "aria-label": _("Type"), }, diff --git a/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js b/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js new file mode 100644 index 000000000..1ad8b054b --- /dev/null +++ b/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js @@ -0,0 +1,60 @@ +const calculateExpirationURL = "/api/qualifications/default-expiration-date/calculate/" + +document.addEventListener("DOMContentLoaded", () => { + const qualificationField = document.querySelector("#id_qualification"); + const qualificationDateField = document.querySelector("#id_qualification_date"); + const expirationField = document.querySelector("#id_expiration_date"); + const errorContainer = document.createElement("div"); + errorContainer.classList.add("text-danger", "mt-1"); + expirationField.parentNode.appendChild(errorContainer); + + async function updateExpirationDate() { + const qualification = qualificationField.value; + const qualification_date = qualificationDateField.value; + errorContainer.textContent = ""; + + if(!qualification || !qualification_date){ + expirationField.value = ""; + return; + } + + try{ + const response = await fetch( + calculateExpirationURL + `?qualification=${qualification}&qualification_date=${qualification_date}` + ); + + const data = await response.json(); + + if(!response.ok || data.error){ + expirationField.value = ""; + errorContainer.textContent = data.error || "Unknown error."; + return; + } + + expirationField.value = data.expiration_date || ""; + } catch (err){ + expirationField.value = ""; + errorContainer.textContent = "Network error: " + err.message; + } + } + + function observeField(field){ + let lastValue = field.value; + + const observer = new MutationObserver(() => { + if (field.value !== lastValue){ + lastValue = field.value; + updateExpirationDate(); + } + }) + + observer.observe(field, { attributes: true, attributeFilter: ["value"] }); + } + + observeField(qualificationField); + observeField(qualificationDateField); + + qualificationField.addEventListener("change", updateExpirationDate); + + updateExpirationDate(); +}) \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html index 17476af52..04e0a34c1 100644 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html @@ -1,5 +1,6 @@ {% extends "core/settings/settings_base.html" %} +{% load static %} {% load crispy_forms_tags %} {% load i18n %} @@ -24,4 +25,6 @@

{% trans "Check Qualificationrequest" %}

{% endif %} + + {% endblock %} \ No newline at end of file From 387ea3e156d8d9d6e42f104e018472f91e790edd Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Sun, 26 Oct 2025 10:55:46 +0100 Subject: [PATCH 49/55] corrected event observing --- .../qualification_requests_check_script.js | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js b/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js index 1ad8b054b..17a497fba 100644 --- a/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js +++ b/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js @@ -20,7 +20,7 @@ document.addEventListener("DOMContentLoaded", () => { try{ const response = await fetch( - calculateExpirationURL + `?qualification=${qualification}&qualification_date=${qualification_date}` + `${calculateExpirationURL}?qualification=${qualification}&qualification_date=${qualification_date}` ); const data = await response.json(); @@ -39,22 +39,18 @@ document.addEventListener("DOMContentLoaded", () => { } function observeField(field){ - let lastValue = field.value; - - const observer = new MutationObserver(() => { - if (field.value !== lastValue){ - lastValue = field.value; - updateExpirationDate(); - } - }) - - observer.observe(field, { attributes: true, attributeFilter: ["value"] }); + field.addEventListener("input", updateExpirationDate) + field.addEventListener("change", updateExpirationDate) } - observeField(qualificationField); observeField(qualificationDateField); - qualificationField.addEventListener("change", updateExpirationDate); + if(window.jQuery && jQuery.fn.select2){ + jQuery('#id_qualification').on('select2:select select2:clear', updateExpirationDate); + }else{ + const observer = new MutationObserver(() => updateExpirationDate()); + observer.observe(qualificationField, { attributes: true, attributeFilter: ["value"] }); + } updateExpirationDate(); }) \ No newline at end of file From 3e49074962f6405c1eb74a6b7e1587694e243d36 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Sun, 26 Oct 2025 11:06:30 +0100 Subject: [PATCH 50/55] added notifications --- ephios/plugins/qualification_requests/views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 9ccc2a687..19fb974f8 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -18,6 +18,7 @@ from django_select2.forms import ModelSelect2Widget from ephios.core.models import Qualification, QualificationGrant +from ephios.core.services.notifications.types import ConsequenceApprovedNotification, ConsequenceDeniedNotification from ephios.extra.mixins import CustomPermissionRequiredMixin from ephios.plugins.qualification_requests.forms import ( QualificationRequestCreateForm, @@ -188,11 +189,17 @@ def form_valid(self, form): if action == "approve": form.instance.status = "approved" form.instance.save() - messages.success(self.request, _("Qualification request approved.")) self.grant_qualification() + + ConsequenceApprovedNotification.send(self.object) + + messages.success(self.request, _("Qualification request approved.")) elif action == "reject": form.instance.status = "rejected" form.instance.save() + + ConsequenceDeniedNotification.send(self.object) + messages.success(self.request, _("Qualification request rejected.")) return super().form_valid(form) From e303003c880b717e3b213576efb4a63c3b233398 Mon Sep 17 00:00:00 2001 From: samuelson-ben Date: Thu, 30 Oct 2025 13:59:53 +0100 Subject: [PATCH 51/55] changed name --- ephios/extra/relative_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index 683f0e188..7f699ffcf 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -90,7 +90,7 @@ class AfterXYearsType: def apply(base_date, years=0, **kwargs): return base_date + relativedelta(years=years) -@RelativeTime.register_type("at_xy_after_z_years") +@RelativeTime.register_type("at_x_y_after_z_years") class AtXYAfterZYearsType: fields = ["day", "month", "years"] From 15bf226ad9eac19fc9f4daf3a868ff70e1d0fb96 Mon Sep 17 00:00:00 2001 From: jeriox Date: Fri, 5 Dec 2025 00:49:44 +0100 Subject: [PATCH 52/55] rework field and widget to use fixed values and MultiValueField --- ephios/core/models/users.py | 24 ++---- ephios/extra/fields.py | 99 ++++--------------------- ephios/extra/relative_time.py | 133 +++++++--------------------------- ephios/extra/widgets.py | 85 +++++++++------------- 4 files changed, 80 insertions(+), 261 deletions(-) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 8e8eca83a..bab4d1a19 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -32,10 +32,10 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from ephios.extra.fields import EndOfDayDateTimeField, RelativeTimeField +from ephios.extra.fields import EndOfDayDateTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder from ephios.extra.relative_time import RelativeTimeModelField -from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget +from ephios.extra.widgets import CustomDateInput from ephios.modellogging.log import ( ModelFieldsLogConfig, add_log_recorder, @@ -276,17 +276,6 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) -class DefaultExpirationTimeField(RelativeTimeModelField): - """ - A model field whose formfield is a RelativeTimeField - """ - - def formfield(self, **kwargs): - return super().formfield( - widget = RelativeTimeWidget, - form_class=RelativeTimeField, - **kwargs, - ) class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") @@ -306,11 +295,9 @@ class Qualification(Model): symmetrical=False, blank=True, ) - default_expiration_time = DefaultExpirationTimeField( + default_expiration_time = RelativeTimeModelField( verbose_name=_("Default expiration time"), - help_text=_( - "The default expiration time for this qualification." - ), + help_text=_("The default expiration time for this qualification."), null=True, blank=True, ) @@ -337,9 +324,10 @@ def natural_key(self): natural_key.dependencies = ["core.QualificationCategory"] + register_model_for_logging( Qualification, - ModelFieldsLogConfig(), + ModelFieldsLogConfig(unlogged_fields=["default_expiration_time"]), ) diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index 0b9e38123..c14a17c1d 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -1,12 +1,13 @@ import datetime -from django.utils.translation import gettext as _ from django import forms +from django.forms.fields import IntegerField from django.forms.utils import from_current_timezone -from ephios.extra.relative_time import RelativeTimeTypeRegistry +from django.utils.translation import gettext as _ + +from ephios.extra.relative_time import RelativeTime from ephios.extra.widgets import RelativeTimeWidget -import json class EndOfDayDateTimeField(forms.DateTimeField): """ @@ -26,88 +27,18 @@ def to_python(self, value): ) ) -class RelativeTimeField(forms.JSONField): - """ - A form field that dynamically adapts to all registered RelativeTime types. - """ +class RelativeTimeField(forms.MultiValueField): + require_all_fields = False widget = RelativeTimeWidget - def bound_data(self, data, initial): - if isinstance(data, list): - return data - return super().bound_data(data, initial) - - def to_python(self, value): - if not value: - return None - - try: - # Determine all known types and their parameters - type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] - - if isinstance(value, list): - # first element = type index - type_index = int(value[0]) if value and value[0] is not None else 0 - type_name = type_names[type_index] if 0 <= type_index < len(type_names) else None - handler = RelativeTimeTypeRegistry.get(type_name) - if not handler: - raise ValueError(_("Invalid choice")) - - params = {} - # remaining values correspond to all known parameters - all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - for param_name, param_value in zip(all_param_names, value[1:]): - if param_value not in (None, ""): - params[param_name] = int(param_value) - return {"type": type_name, **params} - - if isinstance(value, str): - data = json.loads(value) - else: - data = value - - if not isinstance(data, dict): - raise ValueError("Not a dict") - - type_name = data.get("type") - handler = RelativeTimeTypeRegistry.get(type_name) - if not handler: - raise ValueError(_("Unknown type")) - - # basic validation: ensure required params exist - for param in getattr(handler, "fields", []): - if param not in data: - raise ValueError(_("Missing field: {param}").format(param=param)) - - return data - - except (json.JSONDecodeError, ValueError, TypeError) as e: - raise forms.ValidationError( - _("Invalid format: {error}").format(error=e) - ) from e - - def prepare_value(self, value): - if value is None: - return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - - if isinstance(value, list): - return value - - if isinstance(value, str): - try: - value = json.loads(value) - except json.JSONDecodeError: - return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - - if not isinstance(value, dict): - return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - - type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] - type_name = value.get("type", "no_expiration") - type_index = type_names.index(type_name) if type_name in type_names else 0 - - all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) - params = [value.get(p) for p in all_param_names] + def __init__(self, **kwargs): + fields = ( + IntegerField(label=_("Days"), min_value=1, max_value=31), + IntegerField(label=_("Months"), min_value=1, max_value=12), + IntegerField(label=_("Years"), min_value=0), + ) + super().__init__(fields, require_all_fields=False) - return [type_index] + params \ No newline at end of file + def compress(self, data_list): + return RelativeTime(days=data_list[0], months=data_list[1], years=data_list[2]) diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index 7f699ffcf..9e0c43bd7 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -1,113 +1,42 @@ -from calendar import calendar import datetime import json +from calendar import calendar + +from dateutil.relativedelta import relativedelta from django.db import models from django.utils.translation import gettext as _ -from dateutil.relativedelta import relativedelta - -class RelativeTimeTypeRegistry: - """ - Registry that holds all known relative time types. - """ - - """Global registry for all relative time types.""" - _registry = {} - - @classmethod - def register(cls, name, handler): - if not isinstance(handler, type): - raise TypeError(f"Handler for '{name}' must be a class, got {handler!r}") - cls._registry[name] = handler - return handler - - @classmethod - def get(cls, name): - return cls._registry.get(name) - - @classmethod - def all(cls): - return cls._registry.items() class RelativeTime: """ Represents a relative time duration. """ - def __init__(self, type="no_expiration", **kwargs): - self.type = type - self.params = kwargs + def __init__(self, years=None, months=None, days=None, **kwargs): + self.years = years + self.months = months + self.days = days - def __repr__(self): - return f"RelativeTime(type={self.type}, params={self.params})" - def to_json(self): - return {"type": self.type, **self.params} - + return {"years": self.years, "months": self.months, "days": self.days} + @classmethod def from_json(cls, data): if not data: - return cls("no_expiration") + return cls() if isinstance(data, str): data = json.loads(data) - data = data.copy() - type_ = data.pop("type", "no_expiration") - return cls(type_, **data) - - def apply_to(self, base_time: datetime.date): - """Delegates to the registered handler.""" - handler = RelativeTimeTypeRegistry.get(self.type) - if not handler: - raise ValueError(f"Unknown relative time type: {self.type}") - return handler.apply(base_time, **self.params) - - # decorator - @classmethod - def register_type(cls, name): - def decorator(handler_cls): - RelativeTimeTypeRegistry.register(name, handler_cls) - return handler_cls - return decorator + return cls(**data) + def apply_to(self, base_date: datetime.date): + target_date = base_date + relativedelta(years=self.years) + if self.days and self.months: + target_date = target_date.replace(month=self.months) + last_day = calendar.monthrange(target_date.year, self.months)[1] + target_day = min(self.days, last_day) + target_date = target_date.replace(day=target_day) + return target_date -# --------------------------------------------------------------------- -# Default built-in types -# --------------------------------------------------------------------- - -@RelativeTime.register_type("no_expiration") -class NoExpirationType: - fields = [] - - @staticmethod - def apply(base_date, **kwargs): - return None - -@RelativeTime.register_type("after_x_years") -class AfterXYearsType: - fields = ["years"] - - @staticmethod - def apply(base_date, years=0, **kwargs): - return base_date + relativedelta(years=years) - -@RelativeTime.register_type("at_x_y_after_z_years") -class AtXYAfterZYearsType: - fields = ["day", "month", "years"] - - @staticmethod - def apply(base_date, day=None, month=None, years=0, **kwargs): - if not (day and month): - raise ValueError(_("Day and Month must be provided")) - target_date = base_date + relativedelta(years=years) - target_date = target_date.replace(month=month) - last_day = calendar.monthrange(target_date.year, month)[1] - target_day = min(day, last_day) - return target_date.replace(day=target_day) - - -# --------------------------------------------------------------------- -# ModelField integration -# --------------------------------------------------------------------- class RelativeTimeModelField(models.JSONField): """ @@ -118,25 +47,15 @@ class RelativeTimeModelField(models.JSONField): def from_db_value(self, value, expression, connection): if value is None: - return RelativeTime("no_expiration") + return RelativeTime() return RelativeTime.from_json(value) - + def to_python(self, value): - if isinstance(value, RelativeTime): - return value - if value is None: - return RelativeTime("no_expiration") - return RelativeTime.from_json(value) - - def get_prep_value(self, value): - if isinstance(value, RelativeTime): - return value.to_json() - if value is None: - return {"type": "no_expiration"} - return RelativeTime.from_json(value) - + return value.to_json() + def formfield(self, **kwargs): from ephios.extra.fields import RelativeTimeField - defaults = {'form_class': RelativeTimeField} + + defaults = {"form_class": RelativeTimeField} defaults.update(kwargs) - return super().formfield(**defaults) \ No newline at end of file + return super().formfield(**defaults) diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 8ed13c49a..bb0ae1d11 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -5,9 +5,7 @@ from django.forms.utils import to_current_timezone from django.utils.translation import gettext as _ -import json - -from ephios.extra.relative_time import RelativeTimeTypeRegistry +from ephios.extra.relative_time import RelativeTime class CustomDateInput(DateInput): @@ -80,67 +78,50 @@ class RelativeTimeWidget(MultiWidget): """ template_name = "extra/widgets/relative_time_field.html" - - def __init__(self, *args, **kwargs): - # Generate dynamic choices - choices = [(i, _(name.replace("_", " ").title())) for i, (name, handler) in enumerate(RelativeTimeTypeRegistry.all())] - self.type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + def __init__(self, *args, **kwargs): widgets = [ forms.Select( - choices=choices, + choices=[ + (0, _("No expiration")), + (1, _("After X years")), + (2, _("At set date after X years")), + ], attrs={ "class": "form-select", - "title": _("Type"), + "label": _("Type"), "aria-label": _("Type"), }, - ) + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "min": 0, + "label": _("Day (1–31)"), + } + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "min": 0, + "label": _("Months (1–12)"), + } + ), + forms.NumberInput( + attrs={ + "class": "form-control", + "min": 0, + "label": _("Years"), + } + ), ] - # Collect all possible parameter names across all registered types - field_placeholders = { - "years": _("Years"), - "months": _("Months (1–12)"), - "day": _("Day (1–31)"), - "month": _("Month (1–12)"), - } - - # Build a unified list of NumberInputs for all possible numeric parameters - # (widget values will still be passed as a list) - param_names = sorted({p for name, handler in RelativeTimeTypeRegistry.all() for p in getattr(handler, "fields", [])}) - self.param_names = param_names - - for param in param_names: - widgets.append( - forms.NumberInput( - attrs={ - "class": "form-control", - "placeholder": field_placeholders.get(param, param.title()), - "min": 0, - "title": field_placeholders.get(param, param.title()), - "aria-label": field_placeholders.get(param, param.title()), - } - ) - ) - super().__init__(widgets, *args, **kwargs) - # Labels: first is the type choice, then one per param - self.labels = [_("Type")] + [param.title() for param in self.param_names] - def decompress(self, value): - # Expect value as list [choice, param1, param2, ...] - if value is None: - return [0] + [None] * len(self.param_names) - return value # always a list now - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - for idx, subwidget in enumerate(context["widget"]["subwidgets"]): - subwidget["label"] = self.labels[idx] - return context - - + if isinstance(value, RelativeTime): + return [2, value.days, value.months, value.years] + return [None, None, None, None] class MarkdownTextarea(forms.Textarea): From fc85285a92bd655d1700a6cfc7652435453b6178 Mon Sep 17 00:00:00 2001 From: jeriox Date: Sat, 6 Dec 2025 00:29:05 +0100 Subject: [PATCH 53/55] rework request to use consequence handler --- ephios/core/consequences.py | 8 + ephios/extra/relative_time.py | 6 +- .../qualification_management/importing.py | 6 +- .../plugins/qualification_requests/forms.py | 93 +++---- .../plugins/qualification_requests/models.py | 69 ----- .../plugins/qualification_requests/signals.py | 43 +-- .../qualification_requests_add_form.html | 17 -- .../qualification_requests_check_form.html | 30 -- .../qualification_requests_delete_form.html | 18 -- ...ualification_requests_delete_own_form.html | 21 -- .../qualification_requests_form.html | 12 + .../qualification_requests_list.html | 49 ---- .../qualification_requests_list_own.html | 33 --- .../qualification_requests_update_form.html | 21 -- ephios/plugins/qualification_requests/urls.py | 45 +-- .../plugins/qualification_requests/views.py | 262 +----------------- 16 files changed, 88 insertions(+), 645 deletions(-) delete mode 100644 ephios/plugins/qualification_requests/models.py delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html diff --git a/ephios/core/consequences.py b/ephios/core/consequences.py index 6dbc7c611..558a1505f 100644 --- a/ephios/core/consequences.py +++ b/ephios/core/consequences.py @@ -137,6 +137,7 @@ def create( cls, user: UserProfile, qualification: Qualification, + acquired: datetime = None, expires: datetime = None, shift: Shift = None, ): @@ -146,6 +147,7 @@ def create( data={ "qualification_id": qualification.id, "event_id": None if shift is None else shift.event_id, + "acquired": acquired, "expires": expires, }, ) @@ -189,6 +191,9 @@ def render(cls, consequence): if expires := consequence.data.get("expires"): expires = date_format(expires) + if acquired := consequence.data.get("acquired"): + acquired = date_format(acquired) + user = consequence.user.get_full_name() # build string based on available data @@ -203,6 +208,9 @@ def render(cls, consequence): qualification=qualification_title, ) + if acquired: + s += " " + _("on {acquired_str}").format(acquired_str=acquired) + if expires: s += " " + _("(valid until {expires_str})").format(expires_str=expires) return s diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index 9e0c43bd7..831dfcc59 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -1,6 +1,6 @@ import datetime import json -from calendar import calendar +from calendar import monthrange from dateutil.relativedelta import relativedelta from django.db import models @@ -32,7 +32,7 @@ def apply_to(self, base_date: datetime.date): target_date = base_date + relativedelta(years=self.years) if self.days and self.months: target_date = target_date.replace(month=self.months) - last_day = calendar.monthrange(target_date.year, self.months)[1] + last_day = monthrange(target_date.year, self.months)[1] target_day = min(self.days, last_day) target_date = target_date.replace(day=target_day) return target_date @@ -47,7 +47,7 @@ class RelativeTimeModelField(models.JSONField): def from_db_value(self, value, expression, connection): if value is None: - return RelativeTime() + return None return RelativeTime.from_json(value) def to_python(self, value): diff --git a/ephios/plugins/qualification_management/importing.py b/ephios/plugins/qualification_management/importing.py index 1ae7bb7d5..41125285d 100644 --- a/ephios/plugins/qualification_management/importing.py +++ b/ephios/plugins/qualification_management/importing.py @@ -20,8 +20,12 @@ def __init__(self, validated_data): "includes": validated_data["includes"], "included_by": validated_data["included_by"], } + # TODO the following line fails when importing a dataset where default_expiration_time is not set self.object = Qualification( - **{key: validated_data[key] for key in ("title", "abbreviation", "default_expiration_time", "uuid")}, + **{ + key: validated_data[key] + for key in ("title", "abbreviation", "default_expiration_time", "uuid") + }, ) self.category = QualificationCategory( **{key: validated_data["category"][key] for key in ("title", "uuid")}, diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index b6658dde5..66f40b4b9 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -1,72 +1,41 @@ +from crispy_forms.bootstrap import FormActions +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field, Layout, Submit from django import forms -from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ -from django_select2.forms import Select2Widget +from django.utils.translation import gettext as _ +from ephios.core.consequences import QualificationConsequenceHandler +from ephios.core.models import Qualification from ephios.extra.widgets import CustomDateInput -from ephios.plugins.qualification_requests.models import QualificationRequest -class QualificationRequestCreateForm(ModelForm): - class Meta: - model = QualificationRequest - fields = [ - "qualification", - "qualification_date", - "user_comment", - ] - widgets = { - "qualification": Select2Widget, - "qualification_date": CustomDateInput, - } + +class QualificationRequestForm(forms.Form): + qualification = forms.ModelChoiceField(queryset=Qualification.objects.all()) + acquired = forms.DateField(widget=CustomDateInput) def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.layout = Layout( + Field("qualification"), + Field("acquired"), + FormActions( + Submit("submit", _("Save"), css_class="float-end"), + ), + ) - # Default auf "pending", wenn status nicht existiert - status = getattr(self.instance, "status", "pending") - if status != "pending": - self.disable_fields(self.fields.keys()) - - def disable_fields(self, field_names): - """Helper function to disable multiple fields.""" - for field_name in field_names: - self.fields[field_name].disabled = True - -class QualificationRequestCheckForm(ModelForm): - class Meta: - model = QualificationRequest - fields = [ - "user", - "qualification", - "qualification_date", - "expiration_date", - "user_comment", - "status", - "reason", - ] - widgets = { - "qualification": Select2Widget, - "qualification_date": CustomDateInput, - "expiration_date": CustomDateInput, - } - help_texts = { - "expiration_date": _("Leave empty for no expiration."), - } + def create_consequence(self): + qualification = self.cleaned_data["qualification"] + acquired = self.cleaned_data["acquired"] + expires = None - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + if qualification.default_expiration_time: + expires = qualification.default_expiration_time.apply_to(acquired) - if self.instance.status != "pending": - self.disable_fields(self.fields.keys()) - return - - self.disable_fields([ - "user", - "user_comment", - "status", - ]) - - def disable_fields(self, field_names): - """Helper function to disable multiple fields.""" - for field_name in field_names: - self.fields[field_name].disabled = True \ No newline at end of file + QualificationConsequenceHandler.create( + user=self.user, + qualification=self.cleaned_data["qualification"], + acquired=self.cleaned_data["acquired"] or None, + expires=expires, + ) diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py deleted file mode 100644 index 8dc6022f1..000000000 --- a/ephios/plugins/qualification_requests/models.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.db import models -from django.db.models import ( - CharField, - DateField, - DateTimeField, - ForeignKey, -) -from django.utils.translation import gettext_lazy as _ -from ephios.core.models import UserProfile, Qualification - -class QualificationRequest(models.Model): - user = ForeignKey( - UserProfile, - on_delete=models.CASCADE, - related_name='qualification_requests', - verbose_name=_("User"), - ) - qualification = ForeignKey( - Qualification, - on_delete=models.CASCADE, - related_name='qualification_request', - verbose_name=_("Qualification"), - ) - qualification_date = DateField( - null=False, - blank=False, - verbose_name=_("Qualification Date"), - ) - expiration_date = DateField( - null=True, - blank=True, - verbose_name=_("Expiration Date"), - ) - created_at = DateTimeField( - auto_now_add=True, - verbose_name=_("Created At"), - ) - user_comment = CharField( - null=True, - blank=True, - verbose_name=_("User Comment"), - ) - status = CharField( - max_length=20, - choices=[ - ('pending', _("Pending")), - ('approved', _("Approved")), - ('rejected', _("Rejected")), - ], - default='pending', - verbose_name=_("Status"), - ) - reason = CharField( - null=True, - blank=True, - verbose_name=_("Reason"), - ) - #image_data = models.BinaryField(null=True, blank=True) - #image_content_type = models.CharField(max_length=100, null=True, blank=True) - - def __str__(self): - return _( - "%(user)s requested %(qualification)s on %(created)s (Status: %(status)s)" - ) % { - "user": self.user, - "qualification": self.qualification, - "created": self.created_at.strftime("%d.%m.%Y %H:%M"), - "status": self.status, # zeigt die übersetzte Status-Option - } \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index 888d50931..d23123eac 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -3,37 +3,20 @@ from django.utils.translation import gettext as _ from ephios.core.signals import settings_sections -from ephios.core.views.settings import ( - SETTINGS_PERSONAL_SECTION_KEY, - SETTINGS_MANAGEMENT_SECTION_KEY -) +from ephios.core.views.settings import SETTINGS_PERSONAL_SECTION_KEY + @receiver( - settings_sections, - dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", + settings_sections, + dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", ) def add_navigation_item(sender, request, **kwargs): - return ( - ( - [ - { - "label": _(" Own Qualification Requests"), - "url": reverse("qualification_requests:qualification_requests_list_own"), - "active": request.resolver_match.url_name.startswith("qualification_requests") and request.resolver_match.url_name.endswith("_own"), - "group": SETTINGS_PERSONAL_SECTION_KEY, - }, - ] - ) - + ( - [ - { - "label": _("Qualification Requests"), - "url": reverse("qualification_requests:qualification_requests_list"), - "active": request.resolver_match.url_name.startswith("qualification_requests") and not request.resolver_match.url_name.endswith("_own"), - "group": SETTINGS_MANAGEMENT_SECTION_KEY, - } - ] - if request.user.has_perm("core.view_userprofile") - else [] - ) - ) \ No newline at end of file + return [ + { + "label": _("Request qualification"), + "url": reverse("qualification_requests:qualification_requests_create_own"), + "active": request.resolver_match.url_name.startswith("qualification_requests") + and request.resolver_match.url_name.endswith("_own"), + "group": SETTINGS_PERSONAL_SECTION_KEY, + }, + ] diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html deleted file mode 100644 index 9c80ecf48..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load crispy_forms_tags %} -{% load i18n %} - -{% block settings_content %} -

{% trans "Request Qualification" %}

-

{% trans "Here you can request a qualification." %}

-
- {% csrf_token %} - {{ form|crispy }} -

- {% trans "Back" %} - -

- -{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html deleted file mode 100644 index 04e0a34c1..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load static %} -{% load crispy_forms_tags %} -{% load i18n %} - -{% block settings_content %} -

{% trans "Check Qualificationrequest" %}

-
- {% csrf_token %} -

{% trans "Created at" %}: {{ form.instance.created_at }}

- {{ form|crispy }} -

- {% trans "Back" %} - {% if form.instance.status == "pending" %} - - {% else %} - {% trans "Delete" %} - {% endif %} -

- {% if form.instance.status == "pending" %} -

- - -

- {% endif %} - - - -{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html deleted file mode 100644 index c76cd2351..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load i18n %} - -{% block settings_content %} -

{% trans "Delete Qualification Request" %}

- -

- {% trans "Are you sure you want to delete the qualification request" %} - "{{ object }}"? -

- -
- {% csrf_token %} - {% trans "Cancel" %} - - -{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html deleted file mode 100644 index 237bab0d0..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load crispy_forms_tags %} -{% load i18n %} - -{% block settings_content %} -

{% trans "Delete Qualificationrequest" %}

-
- {% csrf_token %} - {{ form|crispy }} -

- {% trans "Back" %} - {% if form.instance.status != "pending" %} - - {% endif %} -

- - {% if form.instance.status == "pending" %} -

{% trans "You can't delete a request that is still pending." %}

- {% endif %} -{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html new file mode 100644 index 000000000..7e97ad716 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html @@ -0,0 +1,12 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_tags %} +{% load i18n %} + +{% block title %} + {% translate "Request qualification" %} +{% endblock %} + +{% block settings_content %} + + {% crispy form %} +{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html deleted file mode 100644 index 0f8e6076a..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load ephios_crispy %} -{% load i18n %} - -{% block settings_content %} -

{% trans "Qualificationrequests" %}

-

{% trans "Here you see all qualificationsrequests." %}

- - - {% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} - {% crispy_field filter_form.qualification wrapper_class="col-12 col-lg" show_labels=False %} - {% crispy_field filter_form.status wrapper_class="col-12 col-lg" show_labels=False %} - -
- - {% translate "Reset" %} -
- - -
{{ request.qualification }} {{ request.status }} - {% trans "View" %} + {% trans "View" %}
- - - - - - - - - - {% for request in qualificationrequest_list %} - - - - - - - {% empty %} - - - - {% endfor %} - -
{% trans "User" %}{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.user.get_full_name }}{{ request.qualification }}{{ request.status }} - {% trans "Check" %} -
{% trans "No qualificationsrequests found." %}
-{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html deleted file mode 100644 index f75e83a08..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load i18n %} - -{% block settings_content %} -

{% trans "Your Qualificationrequests" %}

-

{% trans "Here you can see your qualificationsrequests." %}

- {% trans "Create Qualificationrequest" %} - - - - - - - - - - {% for request in qualificationrequest_list %} - - - - - - {% empty %} - - - - {% endfor %} - -
{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.qualification }}{{ request.status }} - {% trans "View" %} -
{% trans "No qualificationsrequests found." %}
-{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html deleted file mode 100644 index 4accfbc15..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "core/settings/settings_base.html" %} - -{% load crispy_forms_tags %} -{% load i18n %} - -{% block settings_content %} -

{% trans "Check Qualificationrequest" %}

-
- {% csrf_token %} -

{% trans "Created at" %}: {{ form.instance.created_at }}

- {{ form|crispy }} -

- {% trans "Back" %} - {% if form.instance.status == "pending" %} - - {% else %} - {% trans "Delete" %} - {% endif %} -

-
-{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py index d4350946c..efa613609 100644 --- a/ephios/plugins/qualification_requests/urls.py +++ b/ephios/plugins/qualification_requests/urls.py @@ -1,50 +1,13 @@ from django.urls import path -from ephios.plugins.qualification_requests.views import ( - QualificationRequestListView, - QualificationRequestOwnListView, - QualificationRequestOwnCreateView, - QualificationRequestOwnUpdateView, - QualificationRequestCheckView, - QualificationRequestOwnDeleteView, - QualificationRequestDeleteView, -) + +from ephios.plugins.qualification_requests.views import QualificationRequestView app_name = "qualification_requests" urlpatterns = [ - path( - "settings/qualifications/requests/", - QualificationRequestListView.as_view(), - name="qualification_requests_list", - ), - path( - "settings/qualifications/requests/own/", - QualificationRequestOwnListView.as_view(), - name="qualification_requests_list_own", - ), path( "settings/qualifications/requests/create/", - QualificationRequestOwnCreateView.as_view(), + QualificationRequestView.as_view(), name="qualification_requests_create_own", ), - path( - "settings/qualifications/requests//edit/", - QualificationRequestOwnUpdateView.as_view(), - name="qualification_requests_update_own", - ), - path( - "settings/qualifications/requests//check/", - QualificationRequestCheckView.as_view(), - name="qualification_requests_check", - ), - path( - "settings/qualifications/requests//deleteown/", - QualificationRequestOwnDeleteView.as_view(), - name="qualification_requests_delete_own", - ), - path( - "settings/qualifications/requests//delete/", - QualificationRequestDeleteView.as_view(), - name="qualification_requests_delete", - ), -] \ No newline at end of file +] diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 19fb974f8..9ce3b7ce6 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -1,261 +1,23 @@ -from django import forms from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.models import Group -from django.db.models import Q, QuerySet -from django.http import ( - HttpResponseRedirect, - HttpResponseForbidden, -) -from django.urls import reverse_lazy -from django.utils.functional import cached_property +from django.shortcuts import redirect +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import ( - ListView, - FormView, - DeleteView, -) -from django_select2.forms import ModelSelect2Widget +from django.views.generic import FormView +from guardian.mixins import LoginRequiredMixin -from ephios.core.models import Qualification, QualificationGrant -from ephios.core.services.notifications.types import ConsequenceApprovedNotification, ConsequenceDeniedNotification -from ephios.extra.mixins import CustomPermissionRequiredMixin -from ephios.plugins.qualification_requests.forms import ( - QualificationRequestCreateForm, - QualificationRequestCheckForm, -) -from ephios.plugins.qualification_requests.models import QualificationRequest +from ephios.plugins.qualification_requests.forms import QualificationRequestForm -class UserProfileFilterForm(forms.Form): - query = forms.CharField( - label=_("Search for…"), - widget=forms.TextInput(attrs={"placeholder": _("Search for…"), "autofocus": "autofocus"}), - required=False, - ) - qualification = forms.ModelChoiceField( - label=_("Qualification"), - queryset=Qualification.objects.all(), - required=False, - widget=ModelSelect2Widget( - search_fields=["title__icontains", "abbreviation__icontains"], - attrs={ - "data-placeholder": _("Qualification"), - "classes": "w-auto", - }, - ), - ) - status = forms.ChoiceField( - label=_("Status"), - choices=[ - ("", _("Any status")), - ("pending", _("Pending")), - ("approved", _("Approved")), - ("rejected", _("Rejected")), - ], - required=False, - ) - def __init__(self, *args, **kwargs): - self.request = kwargs.pop("request") - super().__init__(*args, **kwargs) +class QualificationRequestView(LoginRequiredMixin, FormView): + form_class = QualificationRequestForm + template_name = "qualification_requests/qualification_requests_form.html" - def filter(self, qs: QuerySet[QualificationRequest]): - fdata = self.cleaned_data - - if query := fdata.get("query"): - qs = qs.filter(Q(user__display_name__icontains=query) | Q(user__email__icontains=query)) - - if qualification := fdata.get("qualification"): - qs = qs.filter(qualification=qualification) - - if status := fdata.get("status"): - qs = qs.filter(status=status) - - return qs.distinct() - -class QualificationRequestListView(CustomPermissionRequiredMixin, ListView): - model = QualificationRequest - ordering = ("-created_at") - template_name = "qualification_requests/qualification_requests_list.html" - permission_required = "core.view_userprofile" - - @cached_property - def filter_form(self): - return UserProfileFilterForm(data=self.request.GET or None, request=self.request) - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx["filter_form"] = self.filter_form - return ctx - - def get_queryset(self): - qs = QualificationRequest.objects.select_related("user", "qualification") - if self.filter_form.is_valid(): - qs = self.filter_form.filter(qs) - return qs.order_by("-created_at", "-user__display_name") - -class QualificationRequestOwnListView(LoginRequiredMixin, ListView): - model = QualificationRequest - ordering = ("-created_at",) - template_name = "qualification_requests/qualification_requests_list_own.html" - - def get_queryset(self): - return QualificationRequest.objects.filter(user=self.request.user).order_by("-created_at") - -class QualificationRequestOwnCreateView(LoginRequiredMixin, FormView): - model = QualificationRequest - form_class = QualificationRequestCreateForm - template_name = "qualification_requests/qualification_requests_add_form.html" - success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") - - def form_valid(self, form): - QualificationRequest.objects.create( - user=self.request.user, - qualification=form.instance.qualification, - qualification_date=form.instance.qualification_date, - user_comment=form.instance.user_comment, - ) - - return super().form_valid(form) - -class QualificationRequestOwnUpdateView(LoginRequiredMixin, FormView): - model = QualificationRequest - form_class = QualificationRequestCreateForm - template_name = "qualification_requests/qualification_requests_update_form.html" - success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") - - def dispatch(self, request, *args, **kwargs): - self.object = self.get_object() - if self.object.user != request.user: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - def get_object(self): - return QualificationRequest.objects.get(pk=self.kwargs["pk"]) - def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs.update({"instance": self.object}) + kwargs["user"] = self.request.user return kwargs def form_valid(self, form): - if self.object.status != "pending": - messages.error( - self.request, - _("You cannot edit a qualification request that is not pending.") - ) - return self.form_invalid(form) - - self.object.qualification = form.instance.qualification - self.object.qualification_date = form.instance.qualification_date - self.object.user_comment = form.instance.user_comment - self.object.save() - return super().form_valid(form) - -class QualificationRequestCheckView(CustomPermissionRequiredMixin, FormView): - model = QualificationRequest - form_class = QualificationRequestCheckForm - template_name = "qualification_requests/qualification_requests_check_form.html" - success_url = reverse_lazy("qualification_requests:qualification_requests_list") - permission_required = "core.change_userprofile" - - def dispatch(self, request, *args, **kwargs): - self.object = self.get_object() - return super().dispatch(request, *args, **kwargs) - - def get_object(self): - return QualificationRequest.objects.get(pk=self.kwargs["pk"]) - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.update({"instance": self.object}) - return kwargs - - def form_valid(self, form): - if self.object.status != "pending": - messages.error( - self.request, - _("You cannot edit a qualification request that is not pending.") - ) - return self.form_invalid(form) - - self.object.qualification = form.instance.qualification - self.object.qualification_date = form.instance.qualification_date - self.object.expiration_date = form.instance.expiration_date - self.object.reason = form.instance.reason - self.object.save() - - action = self.request.POST.get("action") - if action == "approve": - form.instance.status = "approved" - form.instance.save() - self.grant_qualification() - - ConsequenceApprovedNotification.send(self.object) - - messages.success(self.request, _("Qualification request approved.")) - elif action == "reject": - form.instance.status = "rejected" - form.instance.save() - - ConsequenceDeniedNotification.send(self.object) - - messages.success(self.request, _("Qualification request rejected.")) - return super().form_valid(form) - - def grant_qualification(self): - """Grant the qualification to the user if the request is approved.""" - if self.object.status == "approved": - return QualificationGrant.objects.get_or_create( - user=self.object.user, - qualification=self.object.qualification, - expires=self.object.expiration_date if self.object.expiration_date else None, - ) - -class QualificationRequestOwnDeleteView(LoginRequiredMixin, DeleteView): - model = QualificationRequest - template_name = "qualification_requests/qualification_requests_delete_form.html" - success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") - - def delete(self, request, *args, **kwargs): - self.object = self.get_object() - - if self.object.user != request.user: - return HttpResponseForbidden(_("You have no rights to delete this request.")) - - if self.object.status == "pending": - messages.error( - self.request, - _("You cannot delete a qualification request that is pending.") - ) - return HttpResponseRedirect(self.success_url) - - self.object.delete() - messages.success( - request, - _("Qualification request deleted.") - ) - return HttpResponseRedirect(self.success_url) - -class QualificationRequestDeleteView(CustomPermissionRequiredMixin, DeleteView): - model = QualificationRequest - template_name = "qualification_requests/qualification_requests_delete_form.html" - success_url = reverse_lazy("qualification_requests:qualification_requests_list") - permission_required = "core.change_userprofile" - - def delete(self, request, *args, **kwargs): - self.object = self.get_object() - - if self.object.status == "pending": - messages.error( - self.request, - _("You cannot delete a qualification request that is pending.") - ) - return HttpResponseRedirect(self.success_url) - - self.object.delete() - messages.success( - request, - _("Qualification request deleted.") - ) - return HttpResponseRedirect(self.success_url) \ No newline at end of file + form.create_consequence() + messages.success(self.request, _("Your request has been submitted.")) + return redirect(reverse("core:settings_personal_data")) From 9b863b3f685b780bcba144a4c4ca21036df516c5 Mon Sep 17 00:00:00 2001 From: jeriox Date: Sat, 6 Dec 2025 22:26:08 +0100 Subject: [PATCH 54/55] implement other types --- ...9_qualification_default_expiration_time.py | 25 ++++++++++++ ephios/extra/fields.py | 36 +++++++++++++++-- ephios/extra/relative_time.py | 40 ++++++++++++++----- .../static/extra/js/relative_time_field.js | 9 ++--- .../extra/widgets/relative_time_field.html | 6 +-- ephios/extra/widgets.py | 23 ++++++----- 6 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 ephios/core/migrations/0039_qualification_default_expiration_time.py diff --git a/ephios/core/migrations/0039_qualification_default_expiration_time.py b/ephios/core/migrations/0039_qualification_default_expiration_time.py new file mode 100644 index 000000000..fb8f596ee --- /dev/null +++ b/ephios/core/migrations/0039_qualification_default_expiration_time.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2025-12-04 15:54 + +from django.db import migrations + +import ephios.core.models.users + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0038_eventtype_default_description"), + ] + + operations = [ + migrations.AddField( + model_name="qualification", + name="default_expiration_time", + field=ephios.core.models.users.RelativeTimeModelField( + blank=True, + help_text="The default expiration time for this qualification.", + null=True, + verbose_name="Default expiration time", + ), + ), + ] diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index c14a17c1d..19d7a8c80 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -1,6 +1,8 @@ import datetime from django import forms +from django.core.exceptions import ValidationError +from django.forms import ChoiceField from django.forms.fields import IntegerField from django.forms.utils import from_current_timezone from django.utils.translation import gettext as _ @@ -32,13 +34,39 @@ class RelativeTimeField(forms.MultiValueField): require_all_fields = False widget = RelativeTimeWidget + def clean(self, value): + if value[0] == "after_years" and not value[3]: + raise ValidationError(_("You must specify a number of years.")) + if value[0] == "date_after_years" and not (value[1] and value[2] and value[3]): + raise ValidationError(_("You must specify a date and a number of years.")) + return super().clean(value) + + def validate(self, value): + try: + value.apply_to(datetime.datetime.now()) + except ValueError: + raise forms.ValidationError(_("Not a valid date")) + def __init__(self, **kwargs): fields = ( - IntegerField(label=_("Days"), min_value=1, max_value=31), - IntegerField(label=_("Months"), min_value=1, max_value=12), - IntegerField(label=_("Years"), min_value=0), + ChoiceField( + choices=[ + ("no_expiration", _("No expiration")), + ("after_years", _("After X years")), + ("date_after_years", _("At set date after X years")), + ], + required=True, + ), + IntegerField(label=_("Days"), min_value=1, max_value=31, required=False), + IntegerField(label=_("Months"), min_value=1, max_value=12, required=False), + IntegerField(label=_("Years"), min_value=0, required=False), ) super().__init__(fields, require_all_fields=False) def compress(self, data_list): - return RelativeTime(days=data_list[0], months=data_list[1], years=data_list[2]) + match data_list[0]: + case "after_years": + return RelativeTime(year=f"+{data_list[3]}") + case "date_after_years": + return RelativeTime(day=data_list[1], month=data_list[2], year=f"+{data_list[3]}") + return None diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index 831dfcc59..a5d388618 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -1,5 +1,6 @@ import datetime import json +import re from calendar import monthrange from dateutil.relativedelta import relativedelta @@ -12,13 +13,13 @@ class RelativeTime: Represents a relative time duration. """ - def __init__(self, years=None, months=None, days=None, **kwargs): - self.years = years - self.months = months - self.days = days + def __init__(self, year=None, month=None, day=None, **kwargs): + self.year = year + self.month = month + self.day = day def to_json(self): - return {"years": self.years, "months": self.months, "days": self.days} + return {"year": self.year, "month": self.month, "day": self.day} @classmethod def from_json(cls, data): @@ -29,12 +30,29 @@ def from_json(cls, data): return cls(**data) def apply_to(self, base_date: datetime.date): - target_date = base_date + relativedelta(years=self.years) - if self.days and self.months: - target_date = target_date.replace(month=self.months) - last_day = monthrange(target_date.year, self.months)[1] - target_day = min(self.days, last_day) - target_date = target_date.replace(day=target_day) + if not (self.year or self.month or self.day): + return None + target_date = base_date + if self.year: + if type(self.year) is int: + target_date = target_date.replace(year=self.year) + elif match := re.match(r"^\+(\d+)$", self.year): + target_date = target_date + relativedelta(years=int(match.group(0))) + if self.month: + if type(self.month) is int and 1 <= self.month <= 12: + target_date = target_date.replace(month=self.month) + elif (match := re.match(r"^\+(\d+)$", self.month)) and ( + target_month := int(match.group(0)) + ) < 12: + target_date = target_date + relativedelta(month=target_month) + if self.day: + last_day = monthrange(target_date.year, target_date.month)[1] + if type(self.day) is int: + target_date = target_date.replace(day=min(self.day, last_day)) + elif (match := re.match(r"^\+(\d+)$", self.day)) and ( + target_day := int(match.group(0)) + ) < last_day: + target_date = target_date + relativedelta(day=target_day) return target_date diff --git a/ephios/extra/static/extra/js/relative_time_field.js b/ephios/extra/static/extra/js/relative_time_field.js index cd555134e..800c584d9 100644 --- a/ephios/extra/static/extra/js/relative_time_field.js +++ b/ephios/extra/static/extra/js/relative_time_field.js @@ -8,14 +8,13 @@ document.addEventListener("DOMContentLoaded", () => { const all_fields = [day, month, years]; const relative_time_map = { - 0: [], // no_expiration - 1: [years], // after_x_years - 2: [day, month, years], // at_xy_after_z_years + "no_expiration": [], // no_expiration + "after_years": [years], // after_x_years + "date_after_years": [day, month, years], // at_xy_after_z_years }; function updateVisibility() { - const val = parseInt(select.value); - const show = relative_time_map[val] || []; + const show = relative_time_map[select.value] || []; all_fields.forEach((field) => { if (!field) return; diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html index 4ab009ec8..7fd566375 100644 --- a/ephios/extra/templates/extra/widgets/relative_time_field.html +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -1,7 +1,7 @@ -
+
{% for widget in widget.subwidgets %} -
- +
+ {% include widget.template_name %}
{% endfor %} diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index bb0ae1d11..1e83b4561 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -1,3 +1,5 @@ +import re + from dateutil.rrule import rrulestr from django import forms from django.core.exceptions import ValidationError @@ -73,19 +75,15 @@ def clean(self, value): class RelativeTimeWidget(MultiWidget): - """ - A MultiWidget that renders all registered RelativeTime types dynamically. - """ - template_name = "extra/widgets/relative_time_field.html" def __init__(self, *args, **kwargs): widgets = [ forms.Select( choices=[ - (0, _("No expiration")), - (1, _("After X years")), - (2, _("At set date after X years")), + ("no_expiration", _("No expiration")), + ("after_years", _("After X years")), + ("date_after_years", _("At set date after X years")), ], attrs={ "class": "form-select", @@ -97,21 +95,21 @@ def __init__(self, *args, **kwargs): attrs={ "class": "form-control", "min": 0, - "label": _("Day (1–31)"), + "label": _("At day"), } ), forms.NumberInput( attrs={ "class": "form-control", "min": 0, - "label": _("Months (1–12)"), + "label": _("in month"), } ), forms.NumberInput( attrs={ "class": "form-control", "min": 0, - "label": _("Years"), + "label": _("after years"), } ), ] @@ -120,7 +118,10 @@ def __init__(self, *args, **kwargs): def decompress(self, value): if isinstance(value, RelativeTime): - return [2, value.days, value.months, value.years] + if re.match(r"^\+(\d+)$", value.year) and not (value.month and value.day): + return ["after_years", None, None, value.year.strip("+")] + elif re.match(r"^\+(\d+)$", value.year) and value.month and value.day: + return ["date_after_years", value.day, value.month, value.year.strip("+")] return [None, None, None, None] From c75003fec9649b36b15c93db42f75109812d51bd Mon Sep 17 00:00:00 2001 From: Ben Samuelson <105551996+samuelson-ben@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:37:51 +0100 Subject: [PATCH 55/55] Revert "simplify expiration handling" --- ephios/core/consequences.py | 8 - ...9_qualification_default_expiration_time.py | 25 -- ephios/core/models/users.py | 24 +- ephios/extra/fields.py | 125 ++++++--- ephios/extra/relative_time.py | 153 +++++++--- .../static/extra/js/relative_time_field.js | 9 +- .../extra/widgets/relative_time_field.html | 6 +- ephios/extra/widgets.py | 94 ++++--- .../qualification_management/importing.py | 6 +- .../plugins/qualification_requests/forms.py | 93 ++++--- .../plugins/qualification_requests/models.py | 69 +++++ .../plugins/qualification_requests/signals.py | 43 ++- .../qualification_requests_add_form.html | 17 ++ .../qualification_requests_check_form.html | 30 ++ .../qualification_requests_delete_form.html | 18 ++ ...ualification_requests_delete_own_form.html | 21 ++ .../qualification_requests_form.html | 12 - .../qualification_requests_list.html | 49 ++++ .../qualification_requests_list_own.html | 33 +++ .../qualification_requests_update_form.html | 21 ++ ephios/plugins/qualification_requests/urls.py | 45 ++- .../plugins/qualification_requests/views.py | 262 +++++++++++++++++- 22 files changed, 915 insertions(+), 248 deletions(-) delete mode 100644 ephios/core/migrations/0039_qualification_default_expiration_time.py create mode 100644 ephios/plugins/qualification_requests/models.py create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html delete mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html create mode 100644 ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html diff --git a/ephios/core/consequences.py b/ephios/core/consequences.py index 558a1505f..6dbc7c611 100644 --- a/ephios/core/consequences.py +++ b/ephios/core/consequences.py @@ -137,7 +137,6 @@ def create( cls, user: UserProfile, qualification: Qualification, - acquired: datetime = None, expires: datetime = None, shift: Shift = None, ): @@ -147,7 +146,6 @@ def create( data={ "qualification_id": qualification.id, "event_id": None if shift is None else shift.event_id, - "acquired": acquired, "expires": expires, }, ) @@ -191,9 +189,6 @@ def render(cls, consequence): if expires := consequence.data.get("expires"): expires = date_format(expires) - if acquired := consequence.data.get("acquired"): - acquired = date_format(acquired) - user = consequence.user.get_full_name() # build string based on available data @@ -208,9 +203,6 @@ def render(cls, consequence): qualification=qualification_title, ) - if acquired: - s += " " + _("on {acquired_str}").format(acquired_str=acquired) - if expires: s += " " + _("(valid until {expires_str})").format(expires_str=expires) return s diff --git a/ephios/core/migrations/0039_qualification_default_expiration_time.py b/ephios/core/migrations/0039_qualification_default_expiration_time.py deleted file mode 100644 index fb8f596ee..000000000 --- a/ephios/core/migrations/0039_qualification_default_expiration_time.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.6 on 2025-12-04 15:54 - -from django.db import migrations - -import ephios.core.models.users - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0038_eventtype_default_description"), - ] - - operations = [ - migrations.AddField( - model_name="qualification", - name="default_expiration_time", - field=ephios.core.models.users.RelativeTimeModelField( - blank=True, - help_text="The default expiration time for this qualification.", - null=True, - verbose_name="Default expiration time", - ), - ), - ] diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index bab4d1a19..8e8eca83a 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -32,10 +32,10 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from ephios.extra.fields import EndOfDayDateTimeField +from ephios.extra.fields import EndOfDayDateTimeField, RelativeTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder from ephios.extra.relative_time import RelativeTimeModelField -from ephios.extra.widgets import CustomDateInput +from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget from ephios.modellogging.log import ( ModelFieldsLogConfig, add_log_recorder, @@ -276,6 +276,17 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) +class DefaultExpirationTimeField(RelativeTimeModelField): + """ + A model field whose formfield is a RelativeTimeField + """ + + def formfield(self, **kwargs): + return super().formfield( + widget = RelativeTimeWidget, + form_class=RelativeTimeField, + **kwargs, + ) class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") @@ -295,9 +306,11 @@ class Qualification(Model): symmetrical=False, blank=True, ) - default_expiration_time = RelativeTimeModelField( + default_expiration_time = DefaultExpirationTimeField( verbose_name=_("Default expiration time"), - help_text=_("The default expiration time for this qualification."), + help_text=_( + "The default expiration time for this qualification." + ), null=True, blank=True, ) @@ -324,10 +337,9 @@ def natural_key(self): natural_key.dependencies = ["core.QualificationCategory"] - register_model_for_logging( Qualification, - ModelFieldsLogConfig(unlogged_fields=["default_expiration_time"]), + ModelFieldsLogConfig(), ) diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index 19d7a8c80..0b9e38123 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -1,15 +1,12 @@ import datetime +from django.utils.translation import gettext as _ from django import forms -from django.core.exceptions import ValidationError -from django.forms import ChoiceField -from django.forms.fields import IntegerField from django.forms.utils import from_current_timezone -from django.utils.translation import gettext as _ - -from ephios.extra.relative_time import RelativeTime +from ephios.extra.relative_time import RelativeTimeTypeRegistry from ephios.extra.widgets import RelativeTimeWidget +import json class EndOfDayDateTimeField(forms.DateTimeField): """ @@ -29,44 +26,88 @@ def to_python(self, value): ) ) +class RelativeTimeField(forms.JSONField): + """ + A form field that dynamically adapts to all registered RelativeTime types. + """ -class RelativeTimeField(forms.MultiValueField): - require_all_fields = False widget = RelativeTimeWidget - def clean(self, value): - if value[0] == "after_years" and not value[3]: - raise ValidationError(_("You must specify a number of years.")) - if value[0] == "date_after_years" and not (value[1] and value[2] and value[3]): - raise ValidationError(_("You must specify a date and a number of years.")) - return super().clean(value) + def bound_data(self, data, initial): + if isinstance(data, list): + return data + return super().bound_data(data, initial) + + def to_python(self, value): + if not value: + return None - def validate(self, value): try: - value.apply_to(datetime.datetime.now()) - except ValueError: - raise forms.ValidationError(_("Not a valid date")) - - def __init__(self, **kwargs): - fields = ( - ChoiceField( - choices=[ - ("no_expiration", _("No expiration")), - ("after_years", _("After X years")), - ("date_after_years", _("At set date after X years")), - ], - required=True, - ), - IntegerField(label=_("Days"), min_value=1, max_value=31, required=False), - IntegerField(label=_("Months"), min_value=1, max_value=12, required=False), - IntegerField(label=_("Years"), min_value=0, required=False), - ) - super().__init__(fields, require_all_fields=False) - - def compress(self, data_list): - match data_list[0]: - case "after_years": - return RelativeTime(year=f"+{data_list[3]}") - case "date_after_years": - return RelativeTime(day=data_list[1], month=data_list[2], year=f"+{data_list[3]}") - return None + # Determine all known types and their parameters + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + + if isinstance(value, list): + # first element = type index + type_index = int(value[0]) if value and value[0] is not None else 0 + type_name = type_names[type_index] if 0 <= type_index < len(type_names) else None + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Invalid choice")) + + params = {} + # remaining values correspond to all known parameters + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + for param_name, param_value in zip(all_param_names, value[1:]): + if param_value not in (None, ""): + params[param_name] = int(param_value) + return {"type": type_name, **params} + + if isinstance(value, str): + data = json.loads(value) + else: + data = value + + if not isinstance(data, dict): + raise ValueError("Not a dict") + + type_name = data.get("type") + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Unknown type")) + + # basic validation: ensure required params exist + for param in getattr(handler, "fields", []): + if param not in data: + raise ValueError(_("Missing field: {param}").format(param=param)) + + return data + + except (json.JSONDecodeError, ValueError, TypeError) as e: + raise forms.ValidationError( + _("Invalid format: {error}").format(error=e) + ) from e + + def prepare_value(self, value): + if value is None: + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + if isinstance(value, list): + return value + + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + if not isinstance(value, dict): + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + type_name = value.get("type", "no_expiration") + type_index = type_names.index(type_name) if type_name in type_names else 0 + + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + params = [value.get(p) for p in all_param_names] + + return [type_index] + params \ No newline at end of file diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index a5d388618..7f699ffcf 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -1,60 +1,113 @@ +from calendar import calendar import datetime import json -import re -from calendar import monthrange - -from dateutil.relativedelta import relativedelta from django.db import models from django.utils.translation import gettext as _ +from dateutil.relativedelta import relativedelta + +class RelativeTimeTypeRegistry: + """ + Registry that holds all known relative time types. + """ + + """Global registry for all relative time types.""" + _registry = {} + + @classmethod + def register(cls, name, handler): + if not isinstance(handler, type): + raise TypeError(f"Handler for '{name}' must be a class, got {handler!r}") + cls._registry[name] = handler + return handler + + @classmethod + def get(cls, name): + return cls._registry.get(name) + + @classmethod + def all(cls): + return cls._registry.items() class RelativeTime: """ Represents a relative time duration. """ - def __init__(self, year=None, month=None, day=None, **kwargs): - self.year = year - self.month = month - self.day = day + def __init__(self, type="no_expiration", **kwargs): + self.type = type + self.params = kwargs + def __repr__(self): + return f"RelativeTime(type={self.type}, params={self.params})" + def to_json(self): - return {"year": self.year, "month": self.month, "day": self.day} - + return {"type": self.type, **self.params} + @classmethod def from_json(cls, data): if not data: - return cls() + return cls("no_expiration") if isinstance(data, str): data = json.loads(data) - return cls(**data) - - def apply_to(self, base_date: datetime.date): - if not (self.year or self.month or self.day): - return None - target_date = base_date - if self.year: - if type(self.year) is int: - target_date = target_date.replace(year=self.year) - elif match := re.match(r"^\+(\d+)$", self.year): - target_date = target_date + relativedelta(years=int(match.group(0))) - if self.month: - if type(self.month) is int and 1 <= self.month <= 12: - target_date = target_date.replace(month=self.month) - elif (match := re.match(r"^\+(\d+)$", self.month)) and ( - target_month := int(match.group(0)) - ) < 12: - target_date = target_date + relativedelta(month=target_month) - if self.day: - last_day = monthrange(target_date.year, target_date.month)[1] - if type(self.day) is int: - target_date = target_date.replace(day=min(self.day, last_day)) - elif (match := re.match(r"^\+(\d+)$", self.day)) and ( - target_day := int(match.group(0)) - ) < last_day: - target_date = target_date + relativedelta(day=target_day) - return target_date + data = data.copy() + type_ = data.pop("type", "no_expiration") + return cls(type_, **data) + + def apply_to(self, base_time: datetime.date): + """Delegates to the registered handler.""" + handler = RelativeTimeTypeRegistry.get(self.type) + if not handler: + raise ValueError(f"Unknown relative time type: {self.type}") + return handler.apply(base_time, **self.params) + + # decorator + @classmethod + def register_type(cls, name): + def decorator(handler_cls): + RelativeTimeTypeRegistry.register(name, handler_cls) + return handler_cls + return decorator + + +# --------------------------------------------------------------------- +# Default built-in types +# --------------------------------------------------------------------- +@RelativeTime.register_type("no_expiration") +class NoExpirationType: + fields = [] + + @staticmethod + def apply(base_date, **kwargs): + return None + +@RelativeTime.register_type("after_x_years") +class AfterXYearsType: + fields = ["years"] + + @staticmethod + def apply(base_date, years=0, **kwargs): + return base_date + relativedelta(years=years) + +@RelativeTime.register_type("at_x_y_after_z_years") +class AtXYAfterZYearsType: + fields = ["day", "month", "years"] + + @staticmethod + def apply(base_date, day=None, month=None, years=0, **kwargs): + if not (day and month): + raise ValueError(_("Day and Month must be provided")) + target_date = base_date + relativedelta(years=years) + target_date = target_date.replace(month=month) + last_day = calendar.monthrange(target_date.year, month)[1] + target_day = min(day, last_day) + return target_date.replace(day=target_day) + + +# --------------------------------------------------------------------- +# ModelField integration +# --------------------------------------------------------------------- class RelativeTimeModelField(models.JSONField): """ @@ -65,15 +118,25 @@ class RelativeTimeModelField(models.JSONField): def from_db_value(self, value, expression, connection): if value is None: - return None + return RelativeTime("no_expiration") return RelativeTime.from_json(value) - + def to_python(self, value): - return value.to_json() - + if isinstance(value, RelativeTime): + return value + if value is None: + return RelativeTime("no_expiration") + return RelativeTime.from_json(value) + + def get_prep_value(self, value): + if isinstance(value, RelativeTime): + return value.to_json() + if value is None: + return {"type": "no_expiration"} + return RelativeTime.from_json(value) + def formfield(self, **kwargs): from ephios.extra.fields import RelativeTimeField - - defaults = {"form_class": RelativeTimeField} + defaults = {'form_class': RelativeTimeField} defaults.update(kwargs) - return super().formfield(**defaults) + return super().formfield(**defaults) \ No newline at end of file diff --git a/ephios/extra/static/extra/js/relative_time_field.js b/ephios/extra/static/extra/js/relative_time_field.js index 800c584d9..cd555134e 100644 --- a/ephios/extra/static/extra/js/relative_time_field.js +++ b/ephios/extra/static/extra/js/relative_time_field.js @@ -8,13 +8,14 @@ document.addEventListener("DOMContentLoaded", () => { const all_fields = [day, month, years]; const relative_time_map = { - "no_expiration": [], // no_expiration - "after_years": [years], // after_x_years - "date_after_years": [day, month, years], // at_xy_after_z_years + 0: [], // no_expiration + 1: [years], // after_x_years + 2: [day, month, years], // at_xy_after_z_years }; function updateVisibility() { - const show = relative_time_map[select.value] || []; + const val = parseInt(select.value); + const show = relative_time_map[val] || []; all_fields.forEach((field) => { if (!field) return; diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html index 7fd566375..4ab009ec8 100644 --- a/ephios/extra/templates/extra/widgets/relative_time_field.html +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -1,7 +1,7 @@ -
+
{% for widget in widget.subwidgets %} -
- +
+ {% include widget.template_name %}
{% endfor %} diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 1e83b4561..8ed13c49a 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -1,5 +1,3 @@ -import re - from dateutil.rrule import rrulestr from django import forms from django.core.exceptions import ValidationError @@ -7,7 +5,9 @@ from django.forms.utils import to_current_timezone from django.utils.translation import gettext as _ -from ephios.extra.relative_time import RelativeTime +import json + +from ephios.extra.relative_time import RelativeTimeTypeRegistry class CustomDateInput(DateInput): @@ -75,54 +75,72 @@ def clean(self, value): class RelativeTimeWidget(MultiWidget): - template_name = "extra/widgets/relative_time_field.html" + """ + A MultiWidget that renders all registered RelativeTime types dynamically. + """ + template_name = "extra/widgets/relative_time_field.html" + def __init__(self, *args, **kwargs): + # Generate dynamic choices + choices = [(i, _(name.replace("_", " ").title())) for i, (name, handler) in enumerate(RelativeTimeTypeRegistry.all())] + self.type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + widgets = [ forms.Select( - choices=[ - ("no_expiration", _("No expiration")), - ("after_years", _("After X years")), - ("date_after_years", _("At set date after X years")), - ], + choices=choices, attrs={ "class": "form-select", - "label": _("Type"), + "title": _("Type"), "aria-label": _("Type"), }, - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("At day"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("in month"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("after years"), - } - ), + ) ] + # Collect all possible parameter names across all registered types + field_placeholders = { + "years": _("Years"), + "months": _("Months (1–12)"), + "day": _("Day (1–31)"), + "month": _("Month (1–12)"), + } + + # Build a unified list of NumberInputs for all possible numeric parameters + # (widget values will still be passed as a list) + param_names = sorted({p for name, handler in RelativeTimeTypeRegistry.all() for p in getattr(handler, "fields", [])}) + self.param_names = param_names + + for param in param_names: + widgets.append( + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": field_placeholders.get(param, param.title()), + "min": 0, + "title": field_placeholders.get(param, param.title()), + "aria-label": field_placeholders.get(param, param.title()), + } + ) + ) + super().__init__(widgets, *args, **kwargs) + # Labels: first is the type choice, then one per param + self.labels = [_("Type")] + [param.title() for param in self.param_names] + def decompress(self, value): - if isinstance(value, RelativeTime): - if re.match(r"^\+(\d+)$", value.year) and not (value.month and value.day): - return ["after_years", None, None, value.year.strip("+")] - elif re.match(r"^\+(\d+)$", value.year) and value.month and value.day: - return ["date_after_years", value.day, value.month, value.year.strip("+")] - return [None, None, None, None] + # Expect value as list [choice, param1, param2, ...] + if value is None: + return [0] + [None] * len(self.param_names) + return value # always a list now + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + for idx, subwidget in enumerate(context["widget"]["subwidgets"]): + subwidget["label"] = self.labels[idx] + return context + + class MarkdownTextarea(forms.Textarea): diff --git a/ephios/plugins/qualification_management/importing.py b/ephios/plugins/qualification_management/importing.py index 41125285d..1ae7bb7d5 100644 --- a/ephios/plugins/qualification_management/importing.py +++ b/ephios/plugins/qualification_management/importing.py @@ -20,12 +20,8 @@ def __init__(self, validated_data): "includes": validated_data["includes"], "included_by": validated_data["included_by"], } - # TODO the following line fails when importing a dataset where default_expiration_time is not set self.object = Qualification( - **{ - key: validated_data[key] - for key in ("title", "abbreviation", "default_expiration_time", "uuid") - }, + **{key: validated_data[key] for key in ("title", "abbreviation", "default_expiration_time", "uuid")}, ) self.category = QualificationCategory( **{key: validated_data["category"][key] for key in ("title", "uuid")}, diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index 66f40b4b9..b6658dde5 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -1,41 +1,72 @@ -from crispy_forms.bootstrap import FormActions -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Field, Layout, Submit from django import forms -from django.utils.translation import gettext as _ +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from django_select2.forms import Select2Widget -from ephios.core.consequences import QualificationConsequenceHandler -from ephios.core.models import Qualification from ephios.extra.widgets import CustomDateInput +from ephios.plugins.qualification_requests.models import QualificationRequest - -class QualificationRequestForm(forms.Form): - qualification = forms.ModelChoiceField(queryset=Qualification.objects.all()) - acquired = forms.DateField(widget=CustomDateInput) +class QualificationRequestCreateForm(ModelForm): + class Meta: + model = QualificationRequest + fields = [ + "qualification", + "qualification_date", + "user_comment", + ] + widgets = { + "qualification": Select2Widget, + "qualification_date": CustomDateInput, + } def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user") super().__init__(*args, **kwargs) - self.helper = FormHelper(self) - self.helper.layout = Layout( - Field("qualification"), - Field("acquired"), - FormActions( - Submit("submit", _("Save"), css_class="float-end"), - ), - ) - def create_consequence(self): - qualification = self.cleaned_data["qualification"] - acquired = self.cleaned_data["acquired"] - expires = None + # Default auf "pending", wenn status nicht existiert + status = getattr(self.instance, "status", "pending") + if status != "pending": + self.disable_fields(self.fields.keys()) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True + +class QualificationRequestCheckForm(ModelForm): + class Meta: + model = QualificationRequest + fields = [ + "user", + "qualification", + "qualification_date", + "expiration_date", + "user_comment", + "status", + "reason", + ] + widgets = { + "qualification": Select2Widget, + "qualification_date": CustomDateInput, + "expiration_date": CustomDateInput, + } + help_texts = { + "expiration_date": _("Leave empty for no expiration."), + } - if qualification.default_expiration_time: - expires = qualification.default_expiration_time.apply_to(acquired) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - QualificationConsequenceHandler.create( - user=self.user, - qualification=self.cleaned_data["qualification"], - acquired=self.cleaned_data["acquired"] or None, - expires=expires, - ) + if self.instance.status != "pending": + self.disable_fields(self.fields.keys()) + return + + self.disable_fields([ + "user", + "user_comment", + "status", + ]) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py new file mode 100644 index 000000000..8dc6022f1 --- /dev/null +++ b/ephios/plugins/qualification_requests/models.py @@ -0,0 +1,69 @@ +from django.db import models +from django.db.models import ( + CharField, + DateField, + DateTimeField, + ForeignKey, +) +from django.utils.translation import gettext_lazy as _ +from ephios.core.models import UserProfile, Qualification + +class QualificationRequest(models.Model): + user = ForeignKey( + UserProfile, + on_delete=models.CASCADE, + related_name='qualification_requests', + verbose_name=_("User"), + ) + qualification = ForeignKey( + Qualification, + on_delete=models.CASCADE, + related_name='qualification_request', + verbose_name=_("Qualification"), + ) + qualification_date = DateField( + null=False, + blank=False, + verbose_name=_("Qualification Date"), + ) + expiration_date = DateField( + null=True, + blank=True, + verbose_name=_("Expiration Date"), + ) + created_at = DateTimeField( + auto_now_add=True, + verbose_name=_("Created At"), + ) + user_comment = CharField( + null=True, + blank=True, + verbose_name=_("User Comment"), + ) + status = CharField( + max_length=20, + choices=[ + ('pending', _("Pending")), + ('approved', _("Approved")), + ('rejected', _("Rejected")), + ], + default='pending', + verbose_name=_("Status"), + ) + reason = CharField( + null=True, + blank=True, + verbose_name=_("Reason"), + ) + #image_data = models.BinaryField(null=True, blank=True) + #image_content_type = models.CharField(max_length=100, null=True, blank=True) + + def __str__(self): + return _( + "%(user)s requested %(qualification)s on %(created)s (Status: %(status)s)" + ) % { + "user": self.user, + "qualification": self.qualification, + "created": self.created_at.strftime("%d.%m.%Y %H:%M"), + "status": self.status, # zeigt die übersetzte Status-Option + } \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index d23123eac..888d50931 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -3,20 +3,37 @@ from django.utils.translation import gettext as _ from ephios.core.signals import settings_sections -from ephios.core.views.settings import SETTINGS_PERSONAL_SECTION_KEY - +from ephios.core.views.settings import ( + SETTINGS_PERSONAL_SECTION_KEY, + SETTINGS_MANAGEMENT_SECTION_KEY +) @receiver( - settings_sections, - dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", + settings_sections, + dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", ) def add_navigation_item(sender, request, **kwargs): - return [ - { - "label": _("Request qualification"), - "url": reverse("qualification_requests:qualification_requests_create_own"), - "active": request.resolver_match.url_name.startswith("qualification_requests") - and request.resolver_match.url_name.endswith("_own"), - "group": SETTINGS_PERSONAL_SECTION_KEY, - }, - ] + return ( + ( + [ + { + "label": _(" Own Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list_own"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and request.resolver_match.url_name.endswith("_own"), + "group": SETTINGS_PERSONAL_SECTION_KEY, + }, + ] + ) + + ( + [ + { + "label": _("Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and not request.resolver_match.url_name.endswith("_own"), + "group": SETTINGS_MANAGEMENT_SECTION_KEY, + } + ] + if request.user.has_perm("core.view_userprofile") + else [] + ) + ) \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html new file mode 100644 index 000000000..9c80ecf48 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html @@ -0,0 +1,17 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Request Qualification" %}

+

{% trans "Here you can request a qualification." %}

+
+ {% csrf_token %} + {{ form|crispy }} +

+ {% trans "Back" %} + +

+
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html new file mode 100644 index 000000000..04e0a34c1 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_check_form.html @@ -0,0 +1,30 @@ +{% extends "core/settings/settings_base.html" %} + +{% load static %} +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Check Qualificationrequest" %}

+
+ {% csrf_token %} +

{% trans "Created at" %}: {{ form.instance.created_at }}

+ {{ form|crispy }} +

+ {% trans "Back" %} + {% if form.instance.status == "pending" %} + + {% else %} + {% trans "Delete" %} + {% endif %} +

+ {% if form.instance.status == "pending" %} +

+ + +

+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html new file mode 100644 index 000000000..c76cd2351 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_form.html @@ -0,0 +1,18 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Delete Qualification Request" %}

+ +

+ {% trans "Are you sure you want to delete the qualification request" %} + "{{ object }}"? +

+ +
+ {% csrf_token %} + {% trans "Cancel" %} + +
+{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html new file mode 100644 index 000000000..237bab0d0 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_delete_own_form.html @@ -0,0 +1,21 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Delete Qualificationrequest" %}

+
+ {% csrf_token %} + {{ form|crispy }} +

+ {% trans "Back" %} + {% if form.instance.status != "pending" %} + + {% endif %} +

+ + {% if form.instance.status == "pending" %} +

{% trans "You can't delete a request that is still pending." %}

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html deleted file mode 100644 index 7e97ad716..000000000 --- a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_form.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "core/settings/settings_base.html" %} -{% load crispy_forms_tags %} -{% load i18n %} - -{% block title %} - {% translate "Request qualification" %} -{% endblock %} - -{% block settings_content %} - - {% crispy form %} -{% endblock %} diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html new file mode 100644 index 000000000..0f8e6076a --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list.html @@ -0,0 +1,49 @@ +{% extends "core/settings/settings_base.html" %} + +{% load ephios_crispy %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Qualificationrequests" %}

+

{% trans "Here you see all qualificationsrequests." %}

+ + + {% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.qualification wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.status wrapper_class="col-12 col-lg" show_labels=False %} + +
+ + {% translate "Reset" %} +
+
+ + + + + + + + + + + + {% for request in qualificationrequest_list %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "User" %}{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.user.get_full_name }}{{ request.qualification }}{{ request.status }} + {% trans "Check" %} +
{% trans "No qualificationsrequests found." %}
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html new file mode 100644 index 000000000..f75e83a08 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_list_own.html @@ -0,0 +1,33 @@ +{% extends "core/settings/settings_base.html" %} + +{% load i18n %} + +{% block settings_content %} +

{% trans "Your Qualificationrequests" %}

+

{% trans "Here you can see your qualificationsrequests." %}

+ {% trans "Create Qualificationrequest" %} + + + + + + + + + + {% for request in qualificationrequest_list %} + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Qualification" %}{% trans "Status" %}{% trans "Action" %}
{{ request.qualification }}{{ request.status }} + {% trans "View" %} +
{% trans "No qualificationsrequests found." %}
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html new file mode 100644 index 000000000..4accfbc15 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_update_form.html @@ -0,0 +1,21 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Check Qualificationrequest" %}

+
+ {% csrf_token %} +

{% trans "Created at" %}: {{ form.instance.created_at }}

+ {{ form|crispy }} +

+ {% trans "Back" %} + {% if form.instance.status == "pending" %} + + {% else %} + {% trans "Delete" %} + {% endif %} +

+
+{% endblock %} \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py index efa613609..d4350946c 100644 --- a/ephios/plugins/qualification_requests/urls.py +++ b/ephios/plugins/qualification_requests/urls.py @@ -1,13 +1,50 @@ from django.urls import path - -from ephios.plugins.qualification_requests.views import QualificationRequestView +from ephios.plugins.qualification_requests.views import ( + QualificationRequestListView, + QualificationRequestOwnListView, + QualificationRequestOwnCreateView, + QualificationRequestOwnUpdateView, + QualificationRequestCheckView, + QualificationRequestOwnDeleteView, + QualificationRequestDeleteView, +) app_name = "qualification_requests" urlpatterns = [ + path( + "settings/qualifications/requests/", + QualificationRequestListView.as_view(), + name="qualification_requests_list", + ), + path( + "settings/qualifications/requests/own/", + QualificationRequestOwnListView.as_view(), + name="qualification_requests_list_own", + ), path( "settings/qualifications/requests/create/", - QualificationRequestView.as_view(), + QualificationRequestOwnCreateView.as_view(), name="qualification_requests_create_own", ), -] + path( + "settings/qualifications/requests//edit/", + QualificationRequestOwnUpdateView.as_view(), + name="qualification_requests_update_own", + ), + path( + "settings/qualifications/requests//check/", + QualificationRequestCheckView.as_view(), + name="qualification_requests_check", + ), + path( + "settings/qualifications/requests//deleteown/", + QualificationRequestOwnDeleteView.as_view(), + name="qualification_requests_delete_own", + ), + path( + "settings/qualifications/requests//delete/", + QualificationRequestDeleteView.as_view(), + name="qualification_requests_delete", + ), +] \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/views.py b/ephios/plugins/qualification_requests/views.py index 9ce3b7ce6..19fb974f8 100644 --- a/ephios/plugins/qualification_requests/views.py +++ b/ephios/plugins/qualification_requests/views.py @@ -1,23 +1,261 @@ +from django import forms from django.contrib import messages -from django.shortcuts import redirect -from django.urls import reverse +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import Group +from django.db.models import Q, QuerySet +from django.http import ( + HttpResponseRedirect, + HttpResponseForbidden, +) +from django.urls import reverse_lazy +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.views.generic import FormView -from guardian.mixins import LoginRequiredMixin +from django.views.generic import ( + ListView, + FormView, + DeleteView, +) +from django_select2.forms import ModelSelect2Widget -from ephios.plugins.qualification_requests.forms import QualificationRequestForm +from ephios.core.models import Qualification, QualificationGrant +from ephios.core.services.notifications.types import ConsequenceApprovedNotification, ConsequenceDeniedNotification +from ephios.extra.mixins import CustomPermissionRequiredMixin +from ephios.plugins.qualification_requests.forms import ( + QualificationRequestCreateForm, + QualificationRequestCheckForm, +) +from ephios.plugins.qualification_requests.models import QualificationRequest +class UserProfileFilterForm(forms.Form): + query = forms.CharField( + label=_("Search for…"), + widget=forms.TextInput(attrs={"placeholder": _("Search for…"), "autofocus": "autofocus"}), + required=False, + ) + qualification = forms.ModelChoiceField( + label=_("Qualification"), + queryset=Qualification.objects.all(), + required=False, + widget=ModelSelect2Widget( + search_fields=["title__icontains", "abbreviation__icontains"], + attrs={ + "data-placeholder": _("Qualification"), + "classes": "w-auto", + }, + ), + ) + status = forms.ChoiceField( + label=_("Status"), + choices=[ + ("", _("Any status")), + ("pending", _("Pending")), + ("approved", _("Approved")), + ("rejected", _("Rejected")), + ], + required=False, + ) -class QualificationRequestView(LoginRequiredMixin, FormView): - form_class = QualificationRequestForm - template_name = "qualification_requests/qualification_requests_form.html" + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + def filter(self, qs: QuerySet[QualificationRequest]): + fdata = self.cleaned_data + + if query := fdata.get("query"): + qs = qs.filter(Q(user__display_name__icontains=query) | Q(user__email__icontains=query)) + + if qualification := fdata.get("qualification"): + qs = qs.filter(qualification=qualification) + + if status := fdata.get("status"): + qs = qs.filter(status=status) + + return qs.distinct() + +class QualificationRequestListView(CustomPermissionRequiredMixin, ListView): + model = QualificationRequest + ordering = ("-created_at") + template_name = "qualification_requests/qualification_requests_list.html" + permission_required = "core.view_userprofile" + + @cached_property + def filter_form(self): + return UserProfileFilterForm(data=self.request.GET or None, request=self.request) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["filter_form"] = self.filter_form + return ctx + + def get_queryset(self): + qs = QualificationRequest.objects.select_related("user", "qualification") + if self.filter_form.is_valid(): + qs = self.filter_form.filter(qs) + return qs.order_by("-created_at", "-user__display_name") + +class QualificationRequestOwnListView(LoginRequiredMixin, ListView): + model = QualificationRequest + ordering = ("-created_at",) + template_name = "qualification_requests/qualification_requests_list_own.html" + + def get_queryset(self): + return QualificationRequest.objects.filter(user=self.request.user).order_by("-created_at") + +class QualificationRequestOwnCreateView(LoginRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestCreateForm + template_name = "qualification_requests/qualification_requests_add_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def form_valid(self, form): + QualificationRequest.objects.create( + user=self.request.user, + qualification=form.instance.qualification, + qualification_date=form.instance.qualification_date, + user_comment=form.instance.user_comment, + ) + + return super().form_valid(form) + +class QualificationRequestOwnUpdateView(LoginRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestCreateForm + template_name = "qualification_requests/qualification_requests_update_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + if self.object.user != request.user: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + return QualificationRequest.objects.get(pk=self.kwargs["pk"]) + def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user + kwargs.update({"instance": self.object}) return kwargs def form_valid(self, form): - form.create_consequence() - messages.success(self.request, _("Your request has been submitted.")) - return redirect(reverse("core:settings_personal_data")) + if self.object.status != "pending": + messages.error( + self.request, + _("You cannot edit a qualification request that is not pending.") + ) + return self.form_invalid(form) + + self.object.qualification = form.instance.qualification + self.object.qualification_date = form.instance.qualification_date + self.object.user_comment = form.instance.user_comment + self.object.save() + return super().form_valid(form) + +class QualificationRequestCheckView(CustomPermissionRequiredMixin, FormView): + model = QualificationRequest + form_class = QualificationRequestCheckForm + template_name = "qualification_requests/qualification_requests_check_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list") + permission_required = "core.change_userprofile" + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + return super().dispatch(request, *args, **kwargs) + + def get_object(self): + return QualificationRequest.objects.get(pk=self.kwargs["pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({"instance": self.object}) + return kwargs + + def form_valid(self, form): + if self.object.status != "pending": + messages.error( + self.request, + _("You cannot edit a qualification request that is not pending.") + ) + return self.form_invalid(form) + + self.object.qualification = form.instance.qualification + self.object.qualification_date = form.instance.qualification_date + self.object.expiration_date = form.instance.expiration_date + self.object.reason = form.instance.reason + self.object.save() + + action = self.request.POST.get("action") + if action == "approve": + form.instance.status = "approved" + form.instance.save() + self.grant_qualification() + + ConsequenceApprovedNotification.send(self.object) + + messages.success(self.request, _("Qualification request approved.")) + elif action == "reject": + form.instance.status = "rejected" + form.instance.save() + + ConsequenceDeniedNotification.send(self.object) + + messages.success(self.request, _("Qualification request rejected.")) + return super().form_valid(form) + + def grant_qualification(self): + """Grant the qualification to the user if the request is approved.""" + if self.object.status == "approved": + return QualificationGrant.objects.get_or_create( + user=self.object.user, + qualification=self.object.qualification, + expires=self.object.expiration_date if self.object.expiration_date else None, + ) + +class QualificationRequestOwnDeleteView(LoginRequiredMixin, DeleteView): + model = QualificationRequest + template_name = "qualification_requests/qualification_requests_delete_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list_own") + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.object.user != request.user: + return HttpResponseForbidden(_("You have no rights to delete this request.")) + + if self.object.status == "pending": + messages.error( + self.request, + _("You cannot delete a qualification request that is pending.") + ) + return HttpResponseRedirect(self.success_url) + + self.object.delete() + messages.success( + request, + _("Qualification request deleted.") + ) + return HttpResponseRedirect(self.success_url) + +class QualificationRequestDeleteView(CustomPermissionRequiredMixin, DeleteView): + model = QualificationRequest + template_name = "qualification_requests/qualification_requests_delete_form.html" + success_url = reverse_lazy("qualification_requests:qualification_requests_list") + permission_required = "core.change_userprofile" + + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.object.status == "pending": + messages.error( + self.request, + _("You cannot delete a qualification request that is pending.") + ) + return HttpResponseRedirect(self.success_url) + + self.object.delete() + messages.success( + request, + _("Qualification request deleted.") + ) + return HttpResponseRedirect(self.success_url) \ No newline at end of file