Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ephios/core/consequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def create(
cls,
user: UserProfile,
qualification: Qualification,
acquired: datetime = None,
expires: datetime = None,
shift: Shift = None,
):
Expand All @@ -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,
},
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
24 changes: 6 additions & 18 deletions ephios/core/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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,
)
Expand All @@ -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"]),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should log this, but need some code to make our custom type serializable

)


Expand Down
125 changes: 42 additions & 83 deletions ephios/extra/fields.py
Original file line number Diff line number Diff line change
@@ -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):
"""
Expand All @@ -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
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
Loading