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 1e0a57c94..8e8eca83a 100644
--- a/ephios/core/models/users.py
+++ b/ephios/core/models/users.py
@@ -32,9 +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.widgets import CustomDateInput
+from ephios.extra.relative_time import RelativeTimeModelField
+from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget
from ephios.modellogging.log import (
ModelFieldsLogConfig,
add_log_recorder,
@@ -275,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")
@@ -294,6 +306,14 @@ class Qualification(Model):
symmetrical=False,
blank=True,
)
+ default_expiration_time = DefaultExpirationTimeField(
+ verbose_name=_("Default expiration time"),
+ help_text=_(
+ "The default expiration time for this qualification."
+ ),
+ null=True,
+ blank=True,
+ )
is_imported = models.BooleanField(verbose_name=_("imported"), default=True)
objects = QualificationManager()
@@ -317,7 +337,6 @@ def natural_key(self):
natural_key.dependencies = ["core.QualificationCategory"]
-
register_model_for_logging(
Qualification,
ModelFieldsLogConfig(),
diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py
index 8605fc932..0b9e38123 100644
--- a/ephios/extra/fields.py
+++ b/ephios/extra/fields.py
@@ -1,8 +1,12 @@
import datetime
+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
class EndOfDayDateTimeField(forms.DateTimeField):
"""
@@ -21,3 +25,89 @@ def to_python(self, value):
day=result.day,
)
)
+
+class RelativeTimeField(forms.JSONField):
+ """
+ A form field that dynamically adapts to all registered RelativeTime types.
+ """
+
+ 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]
+
+ return [type_index] + params
\ No newline at end of file
diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py
new file mode 100644
index 000000000..7f699ffcf
--- /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_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):
+ """
+ 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
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
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..4ab009ec8
--- /dev/null
+++ b/ephios/extra/templates/extra/widgets/relative_time_field.html
@@ -0,0 +1,11 @@
+
+
+{% load static %}
+
\ No newline at end of file
diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py
index 529af3f39..8ed13c49a 100644
--- a/ephios/extra/widgets.py
+++ b/ephios/extra/widgets.py
@@ -5,6 +5,10 @@
from django.forms.utils import to_current_timezone
from django.utils.translation import gettext as _
+import json
+
+from ephios.extra.relative_time import RelativeTimeTypeRegistry
+
class CustomDateInput(DateInput):
template_name = "extra/widgets/custom_date_input.html"
@@ -70,6 +74,75 @@ def clean(self, value):
raise ValidationError(_("Invalid recurrence rule: {error}").format(error=e)) from e
+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):
+ # 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=choices,
+ attrs={
+ "class": "form-select",
+ "title": _("Type"),
+ "aria-label": _("Type"),
+ },
+ )
+ ]
+
+ # 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
+
+
+
+
class MarkdownTextarea(forms.Textarea):
"""
Textarea widget that might be extended in the future
diff --git a/ephios/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po
index 1ee472ba0..d4675913d 100644
--- a/ephios/locale/de/LC_MESSAGES/django.po
+++ b/ephios/locale/de/LC_MESSAGES/django.po
@@ -2,14 +2,14 @@
# 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-07-24 23:17+0200\n"
+"POT-Creation-Date: 2025-09-15 17:46+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:411
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:546
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:549
#: 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:306
msgid "title"
msgstr "Titel"
@@ -1096,255 +1094,263 @@ 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:409
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:307
#: 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:312
msgid "category"
msgstr "Kategorie"
-#: ephios/core/models/users.py:292
+#: ephios/core/models/users.py:317
msgid "Included"
msgstr "Enthaltene"
-#: ephios/core/models/users.py:293
+#: ephios/core/models/users.py:318
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:341
+msgid "Default expiration time"
+msgstr "Standart Ablaufzeit"
+
+#: 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:348
msgid "imported"
msgstr "importiert"
-#: ephios/core/models/users.py:308 ephios/core/models/users.py:352
+#: ephios/core/models/users.py:359 ephios/core/models/users.py:402
msgid "qualification"
msgstr "Qualifikation"
-#: ephios/core/models/users.py:309
+#: ephios/core/models/users.py:360
msgid "qualifications"
msgstr "Qualifikationen"
-#: ephios/core/models/users.py:363
+#: 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:365
+#: 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:377
+#: ephios/core/models/users.py:427
#, python-brace-format
msgid "{qualification} for {user}"
msgstr "{qualification} für {user}"
-#: ephios/core/models/users.py:383
+#: ephios/core/models/users.py:433
msgid "Qualification grant"
msgstr "Qualifikationserteilung"
-#: ephios/core/models/users.py:384
+#: ephios/core/models/users.py:434
msgid "Qualification grants"
msgstr "Qualifikationserteilungen"
-#: ephios/core/models/users.py:400 ephios/core/models/users.py:516
+#: ephios/core/models/users.py:450 ephios/core/models/users.py:566
msgid "affected user"
msgstr "betroffener Benutzer"
-#: ephios/core/models/users.py:406
+#: ephios/core/models/users.py:456
msgid "needs confirmation"
msgstr "benötigt Bestätigung"
-#: ephios/core/models/users.py:407
+#: ephios/core/models/users.py:457
msgid "executed"
msgstr "ausgeführt"
-#: ephios/core/models/users.py:408
+#: ephios/core/models/users.py:458
msgid "failed"
msgstr "fehlgeschlagen"
-#: ephios/core/models/users.py:409
+#: ephios/core/models/users.py:459
msgid "denied"
msgstr "abgelehnt"
-#: ephios/core/models/users.py:415 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:420
+#: ephios/core/models/users.py:470
msgid "Consequence"
msgstr "Konsequenz"
-#: ephios/core/models/users.py:421
+#: ephios/core/models/users.py:471
msgid "Consequences"
msgstr "Konsequenzen"
-#: ephios/core/models/users.py:437
+#: ephios/core/models/users.py:487
msgid "Consequence was executed already."
msgstr "Konsequenz wurde bereits ausgeführt."
-#: ephios/core/models/users.py:451
+#: ephios/core/models/users.py:501
#: ephios/core/templates/core/userprofile_workinghours.html:26
msgid "Reason"
msgstr "Anlass"
-#: ephios/core/models/users.py:465
+#: 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:497
+#: ephios/core/models/users.py:547
msgid "Hours of work"
msgstr "Arbeitsstunden"
-#: ephios/core/models/users.py:498
+#: ephios/core/models/users.py:548
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: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
#: 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:569
msgid "read"
msgstr "gelesen"
-#: ephios/core/models/users.py:522
+#: ephios/core/models/users.py:572
msgid "processing completed"
msgstr "Verarbeitung abgeschlossen"
-#: ephios/core/models/users.py:524
+#: 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:532
+#: 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:560
+#: ephios/core/models/users.py:610
#, 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:610
msgid "Guest"
msgstr "Gast"
-#: ephios/core/models/users.py:578
+#: ephios/core/models/users.py:631
msgid "internal name"
msgstr "Interner Name"
-#: ephios/core/models/users.py:579
+#: ephios/core/models/users.py:632
msgid "Internal name for this provider."
msgstr "Interner Name für diesen Provider."
-#: ephios/core/models/users.py:584
+#: ephios/core/models/users.py:637
msgid "label"
msgstr "Titel"
-#: ephios/core/models/users.py:585
+#: 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:589
+#: ephios/core/models/users.py:642
msgid "client id"
msgstr "Client-ID"
-#: ephios/core/models/users.py:590
+#: 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:594
+#: ephios/core/models/users.py:647
msgid "client secret"
msgstr "Client-Secret"
-#: ephios/core/models/users.py:595
+#: 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:600
+#: ephios/core/models/users.py:653
msgid "scopes"
msgstr "Geltungsbereich"
-#: ephios/core/models/users.py:602
+#: 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."
@@ -1353,45 +1359,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:659
msgid "authorization endpoint"
msgstr "authorization endpoint"
-#: ephios/core/models/users.py:606
+#: ephios/core/models/users.py:659
msgid "The OIDC authorization endpoint."
msgstr "Der Endpunkt zur Authentifizierung."
-#: ephios/core/models/users.py:609
+#: ephios/core/models/users.py:662
msgid "token endpoint"
msgstr "token endpoint"
-#: ephios/core/models/users.py:609
+#: ephios/core/models/users.py:662
msgid "The OIDC token endpoint."
msgstr "Der Token-Endpoint."
-#: ephios/core/models/users.py:612
+#: ephios/core/models/users.py:665
msgid "user endpoint"
msgstr "Benutzer-Endpoint"
-#: ephios/core/models/users.py:612
+#: ephios/core/models/users.py:665
msgid "The OIDC user endpoint."
msgstr "Der Endpunkt für Benutzerprofile."
-#: ephios/core/models/users.py:617
+#: ephios/core/models/users.py:670
msgid "end session endpoint"
msgstr "end session endpoint"
-#: ephios/core/models/users.py:618
+#: 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:623
+#: ephios/core/models/users.py:676
msgid "JWKS endpoint"
msgstr "JWKS endpoint"
-#: ephios/core/models/users.py:625
+#: ephios/core/models/users.py:678
msgid ""
"The OIDC JWKS endpoint. A less secure signing method will be used if this is "
"not provided."
@@ -1399,21 +1405,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:684
msgid "default groups"
msgstr "Standard-Gruppen"
-#: ephios/core/models/users.py:632
+#: 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:637
+#: ephios/core/models/users.py:690
msgid "group claim"
msgstr "OIDC-Claim für Gruppen"
-#: ephios/core/models/users.py:639
+#: 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 "
@@ -1423,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:645
+#: ephios/core/models/users.py:698
msgid "create missing groups"
msgstr "Fehlende Gruppen erstellen"
-#: ephios/core/models/users.py:647
+#: ephios/core/models/users.py:700
msgid ""
"If enabled, groups from the claim defined above that do not exist yet will "
"be created automatically."
@@ -1435,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:653
+#: ephios/core/models/users.py:706
msgid "qualification claim"
msgstr "Qualifikations-Claim"
-#: ephios/core/models/users.py:655
+#: 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 "
@@ -1450,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:661
+#: ephios/core/models/users.py:714
msgid "qualification codename to uuid"
msgstr "Qualifikations-Codename zu UUID"
-#: ephios/core/models/users.py:663
+#: 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 "
@@ -1464,23 +1470,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:724
#, python-brace-format
msgid "Identity provider {label}"
msgstr "Identitätsprovider {label}"
-#: ephios/core/models/users.py:674
+#: ephios/core/models/users.py:727
msgid "Identity provider"
msgstr "Identitätsprovider"
-#: ephios/core/models/users.py:675
+#: ephios/core/models/users.py:728
#: 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 +1628,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 +1671,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 +1704,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 +1721,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 +1862,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 +2012,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 +2064,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 +2075,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 +2087,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 +2125,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 +2162,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 +2259,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 +2380,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 +2506,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 +2547,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 +2643,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"
@@ -3185,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"
@@ -3289,11 +3299,51 @@ 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:88 ephios/extra/widgets.py:89
+#: ephios/extra/widgets.py:124
+msgid "Choice"
+msgstr "Auswahl"
+
+#: ephios/extra/widgets.py:95 ephios/extra/widgets.py:98
+#: ephios/extra/widgets.py:99
+msgid "Days (1-31)"
+msgstr "Tage (1-31)"
+
+#: ephios/extra/widgets.py:105 ephios/extra/widgets.py:108
+#: ephios/extra/widgets.py:109
+msgid "Months (1-12)"
+msgstr "Monate (1-12)"
+
+#: ephios/extra/widgets.py:115 ephios/extra/widgets.py:117
+#: ephios/extra/widgets.py:118 ephios/extra/widgets.py:127
+msgid "Years"
+msgstr "Jahre"
+
+#: ephios/extra/widgets.py:125
+msgid "Day"
+msgstr "Tag"
+
+#: ephios/extra/widgets.py:126
+msgid "Month"
+msgstr "Monat"
+
#: ephios/modellogging/models.py:48
msgid "Log entry"
msgstr "Änderungseintrag"
@@ -3521,7 +3571,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 +3728,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 +3864,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 +4317,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 +4361,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 +4818,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 +4828,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 +4959,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 +5021,20 @@ 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"
+
#~ msgid "Optional"
#~ msgstr "optional"
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": _(
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",
]
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/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py
new file mode 100644
index 000000000..b6658dde5
--- /dev/null
+++ b/ephios/plugins/qualification_requests/forms.py
@@ -0,0 +1,72 @@
+from django import forms
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+from django_select2.forms import Select2Widget
+
+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,
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # 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 __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ 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
new file mode 100644
index 000000000..888d50931
--- /dev/null
+++ b/ephios/plugins/qualification_requests/signals.py
@@ -0,0 +1,39 @@
+from django.dispatch import receiver
+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
+)
+
+@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") 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/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..17a497fba
--- /dev/null
+++ b/ephios/plugins/qualification_requests/static/qualification_requests/qualification_requests_check_script.js
@@ -0,0 +1,56 @@
+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){
+ field.addEventListener("input", updateExpirationDate)
+ field.addEventListener("change", updateExpirationDate)
+ }
+
+ observeField(qualificationDateField);
+
+ 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
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." %}
+
+{% 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" %}
+
+
+
+{% 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 }}"?
+
+
+
+{% 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" %}
+
+
+
+
+
+ | {% trans "User" %} |
+ {% trans "Qualification" %} |
+ {% trans "Status" %} |
+ {% trans "Action" %} |
+
+
+
+ {% for request in qualificationrequest_list %}
+
+ | {{ request.user.get_full_name }} |
+ {{ request.qualification }} |
+ {{ request.status }} |
+
+ {% trans "Check" %}
+ |
+
+ {% empty %}
+
+ | {% trans "No qualificationsrequests found." %} |
+
+ {% endfor %}
+
+
+{% 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" %}
+
+
+
+ | {% trans "Qualification" %} |
+ {% trans "Status" %} |
+ {% trans "Action" %} |
+
+
+
+ {% for request in qualificationrequest_list %}
+
+ | {{ request.qualification }} |
+ {{ request.status }} |
+
+ {% trans "View" %}
+ |
+
+ {% empty %}
+
+ | {% trans "No qualificationsrequests found." %} |
+
+ {% endfor %}
+
+
+{% 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" %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/ephios/plugins/qualification_requests/urls.py b/ephios/plugins/qualification_requests/urls.py
new file mode 100644
index 000000000..d4350946c
--- /dev/null
+++ b/ephios/plugins/qualification_requests/urls.py
@@ -0,0 +1,50 @@
+from django.urls import path
+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/",
+ 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
new file mode 100644
index 000000000..19fb974f8
--- /dev/null
+++ b/ephios/plugins/qualification_requests/views.py
@@ -0,0 +1,261 @@
+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,
+ DeleteView,
+)
+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,
+ 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,
+ )
+
+ 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.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()
+
+ 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
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"):