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