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 @@ +
+ {% 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 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." %}

+
+ {% 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_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 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"):