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

{% trans "Request Qualification" %}

-

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

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

- {% trans "Back" %} - -

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

{% trans "Check Qualificationrequest" %}

-
- {% csrf_token %} -

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

- {{ form|crispy }} -

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

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

- - -

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

{% trans "Delete Qualification Request" %}

- -

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

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

{% trans "Delete Qualificationrequest" %}

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

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

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

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

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

{% trans "Qualificationrequests" %}

-

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

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

{% trans "Your Qualificationrequests" %}

-

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

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

{% trans "Check Qualificationrequest" %}

-
- {% csrf_token %} -

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

- {{ form|crispy }} -

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

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