diff --git a/ephios/core/consequences.py b/ephios/core/consequences.py index 558a1505f..6dbc7c611 100644 --- a/ephios/core/consequences.py +++ b/ephios/core/consequences.py @@ -137,7 +137,6 @@ def create( cls, user: UserProfile, qualification: Qualification, - acquired: datetime = None, expires: datetime = None, shift: Shift = None, ): @@ -147,7 +146,6 @@ def create( data={ "qualification_id": qualification.id, "event_id": None if shift is None else shift.event_id, - "acquired": acquired, "expires": expires, }, ) @@ -191,9 +189,6 @@ def render(cls, consequence): if expires := consequence.data.get("expires"): expires = date_format(expires) - if acquired := consequence.data.get("acquired"): - acquired = date_format(acquired) - user = consequence.user.get_full_name() # build string based on available data @@ -208,9 +203,6 @@ def render(cls, consequence): qualification=qualification_title, ) - if acquired: - s += " " + _("on {acquired_str}").format(acquired_str=acquired) - if expires: s += " " + _("(valid until {expires_str})").format(expires_str=expires) return s diff --git a/ephios/core/migrations/0039_qualification_default_expiration_time.py b/ephios/core/migrations/0039_qualification_default_expiration_time.py deleted file mode 100644 index fb8f596ee..000000000 --- a/ephios/core/migrations/0039_qualification_default_expiration_time.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.6 on 2025-12-04 15:54 - -from django.db import migrations - -import ephios.core.models.users - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0038_eventtype_default_description"), - ] - - operations = [ - migrations.AddField( - model_name="qualification", - name="default_expiration_time", - field=ephios.core.models.users.RelativeTimeModelField( - blank=True, - help_text="The default expiration time for this qualification.", - null=True, - verbose_name="Default expiration time", - ), - ), - ] diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index bab4d1a19..8e8eca83a 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -32,10 +32,10 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from ephios.extra.fields import EndOfDayDateTimeField +from ephios.extra.fields import EndOfDayDateTimeField, RelativeTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder from ephios.extra.relative_time import RelativeTimeModelField -from ephios.extra.widgets import CustomDateInput +from ephios.extra.widgets import CustomDateInput, RelativeTimeWidget from ephios.modellogging.log import ( ModelFieldsLogConfig, add_log_recorder, @@ -276,6 +276,17 @@ class QualificationManager(models.Manager): def get_by_natural_key(self, qualification_uuid, *args): return self.get(uuid=qualification_uuid) +class DefaultExpirationTimeField(RelativeTimeModelField): + """ + A model field whose formfield is a RelativeTimeField + """ + + def formfield(self, **kwargs): + return super().formfield( + widget = RelativeTimeWidget, + form_class=RelativeTimeField, + **kwargs, + ) class Qualification(Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, verbose_name="UUID") @@ -295,9 +306,11 @@ class Qualification(Model): symmetrical=False, blank=True, ) - default_expiration_time = RelativeTimeModelField( + default_expiration_time = DefaultExpirationTimeField( verbose_name=_("Default expiration time"), - help_text=_("The default expiration time for this qualification."), + help_text=_( + "The default expiration time for this qualification." + ), null=True, blank=True, ) @@ -324,10 +337,9 @@ def natural_key(self): natural_key.dependencies = ["core.QualificationCategory"] - register_model_for_logging( Qualification, - ModelFieldsLogConfig(unlogged_fields=["default_expiration_time"]), + ModelFieldsLogConfig(), ) diff --git a/ephios/extra/fields.py b/ephios/extra/fields.py index 19d7a8c80..0b9e38123 100644 --- a/ephios/extra/fields.py +++ b/ephios/extra/fields.py @@ -1,15 +1,12 @@ import datetime +from django.utils.translation import gettext as _ from django import forms -from django.core.exceptions import ValidationError -from django.forms import ChoiceField -from django.forms.fields import IntegerField from django.forms.utils import from_current_timezone -from django.utils.translation import gettext as _ - -from ephios.extra.relative_time import RelativeTime +from ephios.extra.relative_time import RelativeTimeTypeRegistry from ephios.extra.widgets import RelativeTimeWidget +import json class EndOfDayDateTimeField(forms.DateTimeField): """ @@ -29,44 +26,88 @@ def to_python(self, value): ) ) +class RelativeTimeField(forms.JSONField): + """ + A form field that dynamically adapts to all registered RelativeTime types. + """ -class RelativeTimeField(forms.MultiValueField): - require_all_fields = False widget = RelativeTimeWidget - def clean(self, value): - if value[0] == "after_years" and not value[3]: - raise ValidationError(_("You must specify a number of years.")) - if value[0] == "date_after_years" and not (value[1] and value[2] and value[3]): - raise ValidationError(_("You must specify a date and a number of years.")) - return super().clean(value) + def bound_data(self, data, initial): + if isinstance(data, list): + return data + return super().bound_data(data, initial) + + def to_python(self, value): + if not value: + return None - def validate(self, value): try: - value.apply_to(datetime.datetime.now()) - except ValueError: - raise forms.ValidationError(_("Not a valid date")) - - def __init__(self, **kwargs): - fields = ( - ChoiceField( - choices=[ - ("no_expiration", _("No expiration")), - ("after_years", _("After X years")), - ("date_after_years", _("At set date after X years")), - ], - required=True, - ), - IntegerField(label=_("Days"), min_value=1, max_value=31, required=False), - IntegerField(label=_("Months"), min_value=1, max_value=12, required=False), - IntegerField(label=_("Years"), min_value=0, required=False), - ) - super().__init__(fields, require_all_fields=False) - - def compress(self, data_list): - match data_list[0]: - case "after_years": - return RelativeTime(year=f"+{data_list[3]}") - case "date_after_years": - return RelativeTime(day=data_list[1], month=data_list[2], year=f"+{data_list[3]}") - return None + # Determine all known types and their parameters + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + + if isinstance(value, list): + # first element = type index + type_index = int(value[0]) if value and value[0] is not None else 0 + type_name = type_names[type_index] if 0 <= type_index < len(type_names) else None + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Invalid choice")) + + params = {} + # remaining values correspond to all known parameters + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + for param_name, param_value in zip(all_param_names, value[1:]): + if param_value not in (None, ""): + params[param_name] = int(param_value) + return {"type": type_name, **params} + + if isinstance(value, str): + data = json.loads(value) + else: + data = value + + if not isinstance(data, dict): + raise ValueError("Not a dict") + + type_name = data.get("type") + handler = RelativeTimeTypeRegistry.get(type_name) + if not handler: + raise ValueError(_("Unknown type")) + + # basic validation: ensure required params exist + for param in getattr(handler, "fields", []): + if param not in data: + raise ValueError(_("Missing field: {param}").format(param=param)) + + return data + + except (json.JSONDecodeError, ValueError, TypeError) as e: + raise forms.ValidationError( + _("Invalid format: {error}").format(error=e) + ) from e + + def prepare_value(self, value): + if value is None: + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + if isinstance(value, list): + return value + + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + if not isinstance(value, dict): + return [0] + [None] * len({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + + type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + type_name = value.get("type", "no_expiration") + type_index = type_names.index(type_name) if type_name in type_names else 0 + + all_param_names = sorted({p for _, h in RelativeTimeTypeRegistry.all() for p in getattr(h, "fields", [])}) + params = [value.get(p) for p in all_param_names] + + return [type_index] + params \ No newline at end of file diff --git a/ephios/extra/relative_time.py b/ephios/extra/relative_time.py index a5d388618..7f699ffcf 100644 --- a/ephios/extra/relative_time.py +++ b/ephios/extra/relative_time.py @@ -1,60 +1,113 @@ +from calendar import calendar import datetime import json -import re -from calendar import monthrange - -from dateutil.relativedelta import relativedelta from django.db import models from django.utils.translation import gettext as _ +from dateutil.relativedelta import relativedelta + +class RelativeTimeTypeRegistry: + """ + Registry that holds all known relative time types. + """ + + """Global registry for all relative time types.""" + _registry = {} + + @classmethod + def register(cls, name, handler): + if not isinstance(handler, type): + raise TypeError(f"Handler for '{name}' must be a class, got {handler!r}") + cls._registry[name] = handler + return handler + + @classmethod + def get(cls, name): + return cls._registry.get(name) + + @classmethod + def all(cls): + return cls._registry.items() class RelativeTime: """ Represents a relative time duration. """ - def __init__(self, year=None, month=None, day=None, **kwargs): - self.year = year - self.month = month - self.day = day + def __init__(self, type="no_expiration", **kwargs): + self.type = type + self.params = kwargs + def __repr__(self): + return f"RelativeTime(type={self.type}, params={self.params})" + def to_json(self): - return {"year": self.year, "month": self.month, "day": self.day} - + return {"type": self.type, **self.params} + @classmethod def from_json(cls, data): if not data: - return cls() + return cls("no_expiration") if isinstance(data, str): data = json.loads(data) - return cls(**data) - - def apply_to(self, base_date: datetime.date): - if not (self.year or self.month or self.day): - return None - target_date = base_date - if self.year: - if type(self.year) is int: - target_date = target_date.replace(year=self.year) - elif match := re.match(r"^\+(\d+)$", self.year): - target_date = target_date + relativedelta(years=int(match.group(0))) - if self.month: - if type(self.month) is int and 1 <= self.month <= 12: - target_date = target_date.replace(month=self.month) - elif (match := re.match(r"^\+(\d+)$", self.month)) and ( - target_month := int(match.group(0)) - ) < 12: - target_date = target_date + relativedelta(month=target_month) - if self.day: - last_day = monthrange(target_date.year, target_date.month)[1] - if type(self.day) is int: - target_date = target_date.replace(day=min(self.day, last_day)) - elif (match := re.match(r"^\+(\d+)$", self.day)) and ( - target_day := int(match.group(0)) - ) < last_day: - target_date = target_date + relativedelta(day=target_day) - return target_date + data = data.copy() + type_ = data.pop("type", "no_expiration") + return cls(type_, **data) + + def apply_to(self, base_time: datetime.date): + """Delegates to the registered handler.""" + handler = RelativeTimeTypeRegistry.get(self.type) + if not handler: + raise ValueError(f"Unknown relative time type: {self.type}") + return handler.apply(base_time, **self.params) + + # decorator + @classmethod + def register_type(cls, name): + def decorator(handler_cls): + RelativeTimeTypeRegistry.register(name, handler_cls) + return handler_cls + return decorator + + +# --------------------------------------------------------------------- +# Default built-in types +# --------------------------------------------------------------------- +@RelativeTime.register_type("no_expiration") +class NoExpirationType: + fields = [] + + @staticmethod + def apply(base_date, **kwargs): + return None + +@RelativeTime.register_type("after_x_years") +class AfterXYearsType: + fields = ["years"] + + @staticmethod + def apply(base_date, years=0, **kwargs): + return base_date + relativedelta(years=years) + +@RelativeTime.register_type("at_x_y_after_z_years") +class AtXYAfterZYearsType: + fields = ["day", "month", "years"] + + @staticmethod + def apply(base_date, day=None, month=None, years=0, **kwargs): + if not (day and month): + raise ValueError(_("Day and Month must be provided")) + target_date = base_date + relativedelta(years=years) + target_date = target_date.replace(month=month) + last_day = calendar.monthrange(target_date.year, month)[1] + target_day = min(day, last_day) + return target_date.replace(day=target_day) + + +# --------------------------------------------------------------------- +# ModelField integration +# --------------------------------------------------------------------- class RelativeTimeModelField(models.JSONField): """ @@ -65,15 +118,25 @@ class RelativeTimeModelField(models.JSONField): def from_db_value(self, value, expression, connection): if value is None: - return None + return RelativeTime("no_expiration") return RelativeTime.from_json(value) - + def to_python(self, value): - return value.to_json() - + if isinstance(value, RelativeTime): + return value + if value is None: + return RelativeTime("no_expiration") + return RelativeTime.from_json(value) + + def get_prep_value(self, value): + if isinstance(value, RelativeTime): + return value.to_json() + if value is None: + return {"type": "no_expiration"} + return RelativeTime.from_json(value) + def formfield(self, **kwargs): from ephios.extra.fields import RelativeTimeField - - defaults = {"form_class": RelativeTimeField} + defaults = {'form_class': RelativeTimeField} defaults.update(kwargs) - return super().formfield(**defaults) + return super().formfield(**defaults) \ No newline at end of file diff --git a/ephios/extra/static/extra/js/relative_time_field.js b/ephios/extra/static/extra/js/relative_time_field.js index 800c584d9..cd555134e 100644 --- a/ephios/extra/static/extra/js/relative_time_field.js +++ b/ephios/extra/static/extra/js/relative_time_field.js @@ -8,13 +8,14 @@ document.addEventListener("DOMContentLoaded", () => { const all_fields = [day, month, years]; const relative_time_map = { - "no_expiration": [], // no_expiration - "after_years": [years], // after_x_years - "date_after_years": [day, month, years], // at_xy_after_z_years + 0: [], // no_expiration + 1: [years], // after_x_years + 2: [day, month, years], // at_xy_after_z_years }; function updateVisibility() { - const show = relative_time_map[select.value] || []; + const val = parseInt(select.value); + const show = relative_time_map[val] || []; all_fields.forEach((field) => { if (!field) return; diff --git a/ephios/extra/templates/extra/widgets/relative_time_field.html b/ephios/extra/templates/extra/widgets/relative_time_field.html index 7fd566375..4ab009ec8 100644 --- a/ephios/extra/templates/extra/widgets/relative_time_field.html +++ b/ephios/extra/templates/extra/widgets/relative_time_field.html @@ -1,7 +1,7 @@ -
+
{% for widget in widget.subwidgets %} -
- +
+ {% include widget.template_name %}
{% endfor %} diff --git a/ephios/extra/widgets.py b/ephios/extra/widgets.py index 1e83b4561..8ed13c49a 100644 --- a/ephios/extra/widgets.py +++ b/ephios/extra/widgets.py @@ -1,5 +1,3 @@ -import re - from dateutil.rrule import rrulestr from django import forms from django.core.exceptions import ValidationError @@ -7,7 +5,9 @@ from django.forms.utils import to_current_timezone from django.utils.translation import gettext as _ -from ephios.extra.relative_time import RelativeTime +import json + +from ephios.extra.relative_time import RelativeTimeTypeRegistry class CustomDateInput(DateInput): @@ -75,54 +75,72 @@ def clean(self, value): class RelativeTimeWidget(MultiWidget): - template_name = "extra/widgets/relative_time_field.html" + """ + A MultiWidget that renders all registered RelativeTime types dynamically. + """ + template_name = "extra/widgets/relative_time_field.html" + def __init__(self, *args, **kwargs): + # Generate dynamic choices + choices = [(i, _(name.replace("_", " ").title())) for i, (name, handler) in enumerate(RelativeTimeTypeRegistry.all())] + self.type_names = [name for name, _ in RelativeTimeTypeRegistry.all()] + widgets = [ forms.Select( - choices=[ - ("no_expiration", _("No expiration")), - ("after_years", _("After X years")), - ("date_after_years", _("At set date after X years")), - ], + choices=choices, attrs={ "class": "form-select", - "label": _("Type"), + "title": _("Type"), "aria-label": _("Type"), }, - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("At day"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("in month"), - } - ), - forms.NumberInput( - attrs={ - "class": "form-control", - "min": 0, - "label": _("after years"), - } - ), + ) ] + # Collect all possible parameter names across all registered types + field_placeholders = { + "years": _("Years"), + "months": _("Months (1–12)"), + "day": _("Day (1–31)"), + "month": _("Month (1–12)"), + } + + # Build a unified list of NumberInputs for all possible numeric parameters + # (widget values will still be passed as a list) + param_names = sorted({p for name, handler in RelativeTimeTypeRegistry.all() for p in getattr(handler, "fields", [])}) + self.param_names = param_names + + for param in param_names: + widgets.append( + forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": field_placeholders.get(param, param.title()), + "min": 0, + "title": field_placeholders.get(param, param.title()), + "aria-label": field_placeholders.get(param, param.title()), + } + ) + ) + super().__init__(widgets, *args, **kwargs) + # Labels: first is the type choice, then one per param + self.labels = [_("Type")] + [param.title() for param in self.param_names] + def decompress(self, value): - if isinstance(value, RelativeTime): - if re.match(r"^\+(\d+)$", value.year) and not (value.month and value.day): - return ["after_years", None, None, value.year.strip("+")] - elif re.match(r"^\+(\d+)$", value.year) and value.month and value.day: - return ["date_after_years", value.day, value.month, value.year.strip("+")] - return [None, None, None, None] + # Expect value as list [choice, param1, param2, ...] + if value is None: + return [0] + [None] * len(self.param_names) + return value # always a list now + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + for idx, subwidget in enumerate(context["widget"]["subwidgets"]): + subwidget["label"] = self.labels[idx] + return context + + class MarkdownTextarea(forms.Textarea): diff --git a/ephios/plugins/qualification_management/importing.py b/ephios/plugins/qualification_management/importing.py index 41125285d..1ae7bb7d5 100644 --- a/ephios/plugins/qualification_management/importing.py +++ b/ephios/plugins/qualification_management/importing.py @@ -20,12 +20,8 @@ def __init__(self, validated_data): "includes": validated_data["includes"], "included_by": validated_data["included_by"], } - # TODO the following line fails when importing a dataset where default_expiration_time is not set self.object = Qualification( - **{ - key: validated_data[key] - for key in ("title", "abbreviation", "default_expiration_time", "uuid") - }, + **{key: validated_data[key] for key in ("title", "abbreviation", "default_expiration_time", "uuid")}, ) self.category = QualificationCategory( **{key: validated_data["category"][key] for key in ("title", "uuid")}, diff --git a/ephios/plugins/qualification_requests/forms.py b/ephios/plugins/qualification_requests/forms.py index 66f40b4b9..b6658dde5 100644 --- a/ephios/plugins/qualification_requests/forms.py +++ b/ephios/plugins/qualification_requests/forms.py @@ -1,41 +1,72 @@ -from crispy_forms.bootstrap import FormActions -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Field, Layout, Submit from django import forms -from django.utils.translation import gettext as _ +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from django_select2.forms import Select2Widget -from ephios.core.consequences import QualificationConsequenceHandler -from ephios.core.models import Qualification from ephios.extra.widgets import CustomDateInput +from ephios.plugins.qualification_requests.models import QualificationRequest - -class QualificationRequestForm(forms.Form): - qualification = forms.ModelChoiceField(queryset=Qualification.objects.all()) - acquired = forms.DateField(widget=CustomDateInput) +class QualificationRequestCreateForm(ModelForm): + class Meta: + model = QualificationRequest + fields = [ + "qualification", + "qualification_date", + "user_comment", + ] + widgets = { + "qualification": Select2Widget, + "qualification_date": CustomDateInput, + } def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user") super().__init__(*args, **kwargs) - self.helper = FormHelper(self) - self.helper.layout = Layout( - Field("qualification"), - Field("acquired"), - FormActions( - Submit("submit", _("Save"), css_class="float-end"), - ), - ) - def create_consequence(self): - qualification = self.cleaned_data["qualification"] - acquired = self.cleaned_data["acquired"] - expires = None + # Default auf "pending", wenn status nicht existiert + status = getattr(self.instance, "status", "pending") + if status != "pending": + self.disable_fields(self.fields.keys()) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True + +class QualificationRequestCheckForm(ModelForm): + class Meta: + model = QualificationRequest + fields = [ + "user", + "qualification", + "qualification_date", + "expiration_date", + "user_comment", + "status", + "reason", + ] + widgets = { + "qualification": Select2Widget, + "qualification_date": CustomDateInput, + "expiration_date": CustomDateInput, + } + help_texts = { + "expiration_date": _("Leave empty for no expiration."), + } - if qualification.default_expiration_time: - expires = qualification.default_expiration_time.apply_to(acquired) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - QualificationConsequenceHandler.create( - user=self.user, - qualification=self.cleaned_data["qualification"], - acquired=self.cleaned_data["acquired"] or None, - expires=expires, - ) + if self.instance.status != "pending": + self.disable_fields(self.fields.keys()) + return + + self.disable_fields([ + "user", + "user_comment", + "status", + ]) + + def disable_fields(self, field_names): + """Helper function to disable multiple fields.""" + for field_name in field_names: + self.fields[field_name].disabled = True \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/models.py b/ephios/plugins/qualification_requests/models.py new file mode 100644 index 000000000..8dc6022f1 --- /dev/null +++ b/ephios/plugins/qualification_requests/models.py @@ -0,0 +1,69 @@ +from django.db import models +from django.db.models import ( + CharField, + DateField, + DateTimeField, + ForeignKey, +) +from django.utils.translation import gettext_lazy as _ +from ephios.core.models import UserProfile, Qualification + +class QualificationRequest(models.Model): + user = ForeignKey( + UserProfile, + on_delete=models.CASCADE, + related_name='qualification_requests', + verbose_name=_("User"), + ) + qualification = ForeignKey( + Qualification, + on_delete=models.CASCADE, + related_name='qualification_request', + verbose_name=_("Qualification"), + ) + qualification_date = DateField( + null=False, + blank=False, + verbose_name=_("Qualification Date"), + ) + expiration_date = DateField( + null=True, + blank=True, + verbose_name=_("Expiration Date"), + ) + created_at = DateTimeField( + auto_now_add=True, + verbose_name=_("Created At"), + ) + user_comment = CharField( + null=True, + blank=True, + verbose_name=_("User Comment"), + ) + status = CharField( + max_length=20, + choices=[ + ('pending', _("Pending")), + ('approved', _("Approved")), + ('rejected', _("Rejected")), + ], + default='pending', + verbose_name=_("Status"), + ) + reason = CharField( + null=True, + blank=True, + verbose_name=_("Reason"), + ) + #image_data = models.BinaryField(null=True, blank=True) + #image_content_type = models.CharField(max_length=100, null=True, blank=True) + + def __str__(self): + return _( + "%(user)s requested %(qualification)s on %(created)s (Status: %(status)s)" + ) % { + "user": self.user, + "qualification": self.qualification, + "created": self.created_at.strftime("%d.%m.%Y %H:%M"), + "status": self.status, # zeigt die übersetzte Status-Option + } \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/signals.py b/ephios/plugins/qualification_requests/signals.py index d23123eac..888d50931 100644 --- a/ephios/plugins/qualification_requests/signals.py +++ b/ephios/plugins/qualification_requests/signals.py @@ -3,20 +3,37 @@ from django.utils.translation import gettext as _ from ephios.core.signals import settings_sections -from ephios.core.views.settings import SETTINGS_PERSONAL_SECTION_KEY - +from ephios.core.views.settings import ( + SETTINGS_PERSONAL_SECTION_KEY, + SETTINGS_MANAGEMENT_SECTION_KEY +) @receiver( - settings_sections, - dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", + settings_sections, + dispatch_uid="ephios.plugins.qualification_requests.signals.add_navigation_item", ) def add_navigation_item(sender, request, **kwargs): - return [ - { - "label": _("Request qualification"), - "url": reverse("qualification_requests:qualification_requests_create_own"), - "active": request.resolver_match.url_name.startswith("qualification_requests") - and request.resolver_match.url_name.endswith("_own"), - "group": SETTINGS_PERSONAL_SECTION_KEY, - }, - ] + return ( + ( + [ + { + "label": _(" Own Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list_own"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and request.resolver_match.url_name.endswith("_own"), + "group": SETTINGS_PERSONAL_SECTION_KEY, + }, + ] + ) + + ( + [ + { + "label": _("Qualification Requests"), + "url": reverse("qualification_requests:qualification_requests_list"), + "active": request.resolver_match.url_name.startswith("qualification_requests") and not request.resolver_match.url_name.endswith("_own"), + "group": SETTINGS_MANAGEMENT_SECTION_KEY, + } + ] + if request.user.has_perm("core.view_userprofile") + else [] + ) + ) \ No newline at end of file diff --git a/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html new file mode 100644 index 000000000..9c80ecf48 --- /dev/null +++ b/ephios/plugins/qualification_requests/templates/qualification_requests/qualification_requests_add_form.html @@ -0,0 +1,17 @@ +{% extends "core/settings/settings_base.html" %} + +{% load crispy_forms_tags %} +{% load i18n %} + +{% block settings_content %} +

{% trans "Request Qualification" %}

+

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

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

+ {% trans "Back" %} + +

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

{% trans "Check Qualificationrequest" %}

+
+ {% csrf_token %} +

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

+ {{ form|crispy }} +

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

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

+ + +

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

{% trans "Delete Qualification Request" %}

+ +

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

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

{% trans "Delete Qualificationrequest" %}

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

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

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

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

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

{% trans "Qualificationrequests" %}

+

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

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

{% trans "Your Qualificationrequests" %}

+

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

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

{% trans "Check Qualificationrequest" %}

+
+ {% csrf_token %} +

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

+ {{ form|crispy }} +

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

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