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 @@ -