# SPDX-FileCopyrightText: 2019-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
"""|UDM| type definitions."""
import datetime
import inspect
import time
from collections.abc import Sequence
from typing import Any
import ldap.dn
import univention.admin.uexceptions
from univention.admin import localization
from univention.admin.log import log
translation = localization.translation('univention/admin')
_ = translation.translate
_Types = type[Any] | Sequence[type[Any]]
[docs]
class TypeHint:
_python_types: _Types = object
@property
def _json_type(self):
# in most cases, the Python type is equivalent to the JSON type
return self._python_types
_openapi_type: str | None = None
_openapi_format: str | None = None
_openapi_regex: str | None = None
_openapi_example: str | None = None
_openapi_readonly: bool | None = None
_openapi_writeonly: bool | None = None
_openapi_nullable = True # everything which can be removed is nullable
_html_element = None
_html_input_type = None
_encoding: str | None = None
_minimum = float('-inf')
_maximum = float('inf')
_required = False
_default_value = None
_default_search_value = None
_only_printable = False
_allow_empty_value = False
_encodes_none = False
"""None is a valid value for the syntax class, otherwise None means remove"""
_blacklist = ()
# _error_message
_dependencies = None
def __init__(self, property, property_name, syntax=None):
self.property = property
self.property_name = property_name
self.syntax = syntax or self._syntax
@property
def _syntax(self):
# ensure we have an instance of the syntax class and not the type
syntax = self.property.syntax
return syntax() if isinstance(syntax, type) else syntax
[docs]
def decode(self, value):
"""
Decode the given value from an UDM object's property into a Python type.
This must be graceful. Invalid values set at UDM object properties should not cause an exception!
.. note:: Do not overwrite in subclass!
.. seealso:: overwrite :func:`univention.admin.types.TypeHint.decode_value` instead.
"""
if value is None:
return
return self.decode_value(value)
[docs]
def encode(self, value):
"""
Encode a value of Python type into a string / list / None / etc. suitable for setting at the UDM object.
.. note:: Do not overwrite in subclass!
.. seealso:: overwrite :func:`univention.admin.types.TypeHint.encode_value` instead.
"""
if value is None and not self._encodes_none:
return
self.type_check(value)
self.type_check_subitems(value)
return self.encode_value(value)
[docs]
def decode_json(self, value):
return self.to_json_type(self.decode(value))
[docs]
def encode_json(self, value):
return self.encode(self.from_json_type(value))
[docs]
def to_json_type(self, value):
"""
Transform the value resulting from :func:`self.decode` into something suitable to transmit via JSON.
For example, a Python :class:`datetime.date` object into the JSON string with a date format "2019-08-30".
"""
if value is None:
return
value = self._to_json_type(value)
if isinstance(value, bytes):
# fallback for wrong implemented types
# JSON cannot handle non-UTF-8 bytes
value = value.decode('utf-8', 'strict')
return value
[docs]
def from_json_type(self, value):
"""
Transform a value from a JSON object into the internal Python type.
For example, converts a JSON string "2019-08-30" into a Python :class:`datetime.date` object.
.. warning:: When overwriting the type must be checked!
"""
if value is None:
return
self.type_check_json(value)
value = self._from_json_type(value)
return value
[docs]
def decode_value(self, value):
"""
Decode the value into a Python object.
.. note:: suitable for subclassing.
"""
try:
return self.syntax.parse(value)
except univention.admin.uexceptions.valueError as exc:
log.warning('ignoring invalid property', property=self.property_name, value=repr(value), error=exc)
return value
[docs]
def encode_value(self, value):
"""
Encode the value into a UDM property value.
.. note:: suitable for subclassing.
"""
return self.syntax.parse(value)
def _from_json_type(self, value):
return value
def _to_json_type(self, value):
return value
[docs]
def type_check(self, value, types=None):
"""Checks if the value has the correct Python type."""
if not isinstance(value, types or self._python_types):
must = '%s (%s)' % (self._openapi_type, self._openapi_format) if self._openapi_format else '%s' % (self._openapi_type,)
actual = type(value).__name__
log.warning('invalid type for property', property=self.property_name, value=repr(value), type=type(self).__name__)
raise univention.admin.uexceptions.valueInvalidSyntax(_('Value must be of type %s not %s.') % (must, actual))
[docs]
def type_check_json(self, value):
self.type_check(value, self._json_type)
[docs]
def type_check_subitems(self, value):
pass
[docs]
def tostring(self, value):
"""A printable representation for e.g. the CLI or grid columns in UMC"""
if self.property.multivalue:
return [self.syntax.tostring(val) for val in value]
else:
return self.syntax.tostring(value)
[docs]
def parse_command_line(self, value):
"""Parse a string from the command line"""
return self.syntax.parse_command_line(value)
[docs]
def get_openapi_definition(self):
return {key: value for key, value in self.openapi_definition().items() if value is not None and value not in (float('inf'), -float('inf'))}
[docs]
def openapi_definition(self):
definition = {
'type': self._openapi_type,
}
if self._openapi_type in ('string', 'number', 'integer'):
definition['format'] = self._openapi_format
if self._openapi_type == 'string':
definition['pattern'] = self._openapi_regex
definition['minLength'] = self._minimum
definition['maxLength'] = self._maximum
definition['example'] = self._openapi_example
definition['readOnly'] = self._openapi_readonly
definition['writeOnly'] = self._openapi_writeonly
definition['nullable'] = self._openapi_nullable
# if self._openapi_type == 'string' and not self._openapi_example and isinstance(self.syntax, univention.admin.syntax.select) and getattr(self.syntax, 'choices', None):
# definition['example'] = self.syntax.choices[0][0]
return definition
[docs]
def get_choices(self, lo, options):
return self.syntax.get_choices(lo, options)
[docs]
def has_choices(self):
opts = self.syntax.get_widget_options()
return opts.get('dynamicValues') or opts.get('staticValues') or opts.get('type') == 'umc/modules/udm/MultiObjectSelect'
[docs]
@classmethod
def detect(cls, property, name):
"""
Detect the :class:`univention.admin.types.TypeHint` type of a property automatically.
We need this to be backwards compatible, with handlers, we don't influence.
First considered is the `property.type_class` which can be explicit set in the module handler.
Otherwise, it depends on wheather the field is multivalue or not:
multivalue: A unordered :class:`Set` of `syntax.type_class` items
singlevalue: `syntax.type_class` is used.
"""
if property.type_class:
return property.type_class(property, name)
syntax = property.syntax() if inspect.isclass(property.syntax) else property.syntax
type_class = syntax.type_class
if not type_class:
log.warning('Unknown type for property', property=name, syntax=syntax.name)
type_class = cls
if not property.multivalue:
return type_class(property, name)
else:
if syntax.type_class_multivalue:
return syntax.type_class_multivalue(property, name)
# create a default type inheriting from a set
# (LDAP attributes do not have a defined order - unless the "ordered" overlay module is activated and the attribute schema defines it)
class MultivaluePropertyType(SetType):
item_type = type_class
return MultivaluePropertyType(property, name)
[docs]
class NoneType(TypeHint):
_python_types = type(None)
_openapi_type = 'void'
_encodes_none = True
[docs]
class BooleanType(TypeHint):
_python_types: _Types = bool
_openapi_type = 'boolean'
[docs]
def decode_value(self, value):
try:
if self.syntax.parse(True) == value:
return True
elif self.syntax.parse(False) == value:
return False
elif self.syntax.parse(None) == value:
return None
except univention.admin.uexceptions.valueError:
pass
log.warning('invalid boolean', property=self.property_name, syntax=self.syntax.name, value=repr(value))
return value
[docs]
class TriBooleanType(BooleanType):
_encodes_none = True
_python_types = (bool, type(None))
[docs]
class IntegerType(TypeHint):
_python_types = int
_openapi_type = 'integer'
# _openapi_format: int32, int64
[docs]
def decode_value(self, value):
try:
value = int(value)
except ValueError:
log.warning('invalid integer', property=self.property_name, syntax=self.syntax.name, value=repr(value))
return value
[docs]
class NumberType(TypeHint):
_python_types = float
_openapi_type = 'number'
_openapi_format = 'double' # or 'float'
[docs]
class StringType(TypeHint):
_python_types: _Types = str
_encoding = 'UTF-8'
_openapi_type = 'string'
[docs]
def decode_value(self, value):
if isinstance(value, bytes):
value = value.decode(self._encoding, 'strict')
return value
[docs]
class Base64Type(StringType):
_openapi_format = 'byte'
[docs]
class UUID(StringType):
_openapi_format = 'uuid'
[docs]
class PasswordType(StringType):
_openapi_format = 'password'
_openapi_example = 'univention' # :-D
_openapi_readonly = True
[docs]
class DistinguishedNameType(StringType):
_openapi_format = 'dn'
_openapi_example = 'dc=example,dc=net'
_openapi_regex = '^.+=.+$'
_minimum = 3
[docs]
def encode_value(self, value):
value = super().encode_value(value)
try:
return ldap.dn.dn2str(ldap.dn.str2dn(value))
except ldap.DECODING_ERROR:
raise univention.admin.uexceptions.valueInvalidSyntax(_('The LDAP DN is invalid.'))
[docs]
class LDAPFilterType(StringType):
_openapi_format = 'ldap-filter'
[docs]
class EMailAddressType(StringType):
_openapi_format = 'email'
_minimum = 3
[docs]
class BinaryType(TypeHint):
"""
.. warning:: Using this type bloats up the JSON value with a high factor for non ascii data.
.. seealso:: use `univention.admin.types.Base64Type` instead
"""
_python_types = bytes
_encoding = 'ISO8859-1'
# It is not possible to transmit binary data via JSON. in JSON everything needs to be UTF-8!
_json_type = str
_json_encoding = 'ISO8859-1'
_openapi_type = 'string'
_openapi_format = 'binary'
def _to_json_type(self, value):
return value.decode(self._json_encoding, 'strict')
def _from_json_type(self, value):
try:
return value.encode(self._json_encoding, 'strict')
except UnicodeEncodeError:
raise univention.admin.uexceptions.valueInvalidSyntax(_('Binary data have invalid encoding (expected: %s).') % (self._encoding,))
[docs]
class DateType(StringType):
"""
>>> x = DateType(univention.admin.property(syntax=univention.admin.syntax.string), 'a_date_time')
>>> import datetime
>>> now = datetime.date(2020, 1, 1)
>>> x.to_json_type(now) # doctest: +ALLOW_UNICODE
'2020-01-01'
"""
_python_types = datetime.date
_json_type = str
_openapi_format = 'date'
[docs]
def decode_value(self, value):
if value == '':
return
return self.syntax.to_datetime(value)
[docs]
def encode_value(self, value):
return self.syntax.from_datetime(value)
def _to_json_type(self, value: datetime.date) -> str:
return str(value.isoformat())
def _from_json_type(self, value: str) -> datetime.date:
try:
return datetime.date(*time.strptime(value, '%Y-%m-%d')[0:3])
except ValueError:
log.debug('Wrong date format', date=repr(value))
raise univention.admin.uexceptions.valueInvalidSyntax(_('Date does not match format "%Y-%m-%d".'))
[docs]
class TimeType(StringType):
"""
>>> x = TimeType(univention.admin.property(syntax=univention.admin.syntax.string), 'a_date_time')
>>> import datetime
>>> now = datetime.time(10, 30, 0, 500)
>>> x.to_json_type(now) # doctest: +ALLOW_UNICODE
'10:30:00'
"""
_python_types = datetime.time
_json_type = str
_openapi_format = 'time'
[docs]
def decode_value(self, value):
if value == '':
return
return self.syntax.to_datetime(value)
[docs]
def encode_value(self, value):
return self.syntax.from_datetime(value)
def _to_json_type(self, value: datetime.time) -> str:
return str(value.replace(microsecond=0).isoformat())
def _from_json_type(self, value: str) -> datetime.time:
try:
return datetime.time(*time.strptime(value, '%H:%M:%S')[3:6])
except ValueError:
log.debug('invalid time format', time=repr(value))
raise univention.admin.uexceptions.valueInvalidSyntax(_('Time does not match format "%H:%M:%S".'))
[docs]
class DateTimeType(StringType):
"""
A DateTime
syntax classes using this type must support the method from_datetime(), which returns something valid for syntax.parse()
>>> x = DateTimeType(univention.admin.property(syntax=univention.admin.syntax.string), 'a_date_time')
>>> import datetime
>>> now = datetime.datetime(2020, 1, 1)
>>> x.to_json_type(now) # doctest: +ALLOW_UNICODE
'2020-01-01T00:00:00'
"""
_python_types = datetime.datetime
_json_type = str
_openapi_format = 'date-time'
[docs]
def decode_value(self, value):
if value == '':
return
return self.syntax.to_datetime(value)
[docs]
def encode_value(self, value):
return self.syntax.from_datetime(value)
def _to_json_type(self, value: datetime.datetime) -> str:
return value.replace(microsecond=0).isoformat()
def _from_json_type(self, value: str) -> datetime.datetime:
try:
return datetime.datetime(*time.strptime(value, '%Y-%m-%dT%H:%M:%S')[:6]) # FIXME: parse Z at the end
except ValueError:
log.debug('invalid datetime format', datetime=repr(value))
raise univention.admin.uexceptions.valueInvalidSyntax(_('Datetime does not match format "%Y-%m-%dT%H:%M:%S".'))
[docs]
class ArrayType(TypeHint):
_python_types = list
_openapi_type = 'array'
_openapi_unique = False
[docs]
class ListType(ArrayType):
item_type: type[TypeHint] | None = None # must be set in subclasses
[docs]
def type_check_subitems(self, value):
item_type = self.item_type(self.property, self.property_name)
for item in value:
item_type.type_check(item)
[docs]
def openapi_definition(self):
definition = super().openapi_definition()
definition['items'] = self.item_type(self.property, self.property_name).get_openapi_definition()
definition['minItems'] = self._minimum
definition['maxItems'] = self._maximum
definition['uniqueItems'] = self._openapi_unique
return definition
[docs]
def encode_value(self, value):
item_type = self.item_type(self.property, self.property_name)
value = [item_type.encode(val) for val in value]
return [val for val in value if val is not None]
[docs]
def decode_value(self, value):
item_type = self.item_type(self.property, self.property_name)
return [item_type.decode(val) for val in value]
[docs]
class SetType(ListType):
_openapi_unique = True
# FIXME: this must be done after applying the mapping from property to attribute value
def __encode_value(self, value):
# disallow duplicates without re-arranging the order
# This should prevent that we run into "Type or value exists: attributename: value #0 provided more than once" errors
# we can't do it completely because equality is defined in the LDAP server schema (e.g. DN syntax: 'dc = foo' equals 'dc=foo' equals 'DC=Foo')
value = super().encode_value(value)
if len(value) != len(set(value)):
raise univention.admin.uexceptions.valueInvalidSyntax(_('Duplicated entries.'))
return value
[docs]
class ListOfItems(ArrayType):
"""Array"""
item_types = None # must be set in subclasses
@property
def minimum(self):
return len(self.item_types)
@property
def maximum(self):
return len(self.item_types)
[docs]
def type_check_subitems(self, value):
if not (self._minimum <= len(value) <= self._maximum):
univention.admin.uexceptions.valueInvalidSyntax(_('Must have at least %d values.') % (self._minimum,))
for item_type, item in zip(self.item_types, value):
item_type = item_type(self.property, self.property_name)
item_type.type_check(item)
[docs]
def encode_value(self, value):
return [
item_type(self.property, self.property_name).encode(val)
for item_type, val in zip(self.item_types, value)
]
[docs]
def decode_value(self, value):
return [
item_type(self.property, self.property_name).decode(val)
for item_type, val in zip(self.item_types, value)
]
[docs]
def openapi_definition(self):
definition = super().openapi_definition()
definition['minItems'] = self._minimum
definition['maxItems'] = self._maximum
definition['uniqueItems'] = self._openapi_unique
items = [item(self.property, self.property_name).get_openapi_definition() for item in self.item_types]
_items = [tuple(item.items()) if isinstance(item, dict) else item for item in items]
if len(set(_items)) == 1:
definition['items'] = items[0]
else:
definition['items'] = {
'oneOf': items,
}
return definition
[docs]
class DictionaryType(TypeHint):
_python_types = dict
_openapi_type = 'object'
properties = None
[docs]
def decode_value(self, value):
return self.syntax.todict(value)
# if not self.syntax.subsyntax_key_value and self.property.multivalue and isinstance(value, (list, tuple)):
# value = [self.syntax.todict(val) for val in value]
# else:
# value = self.syntax.todict(value)
# return value
[docs]
def encode_value(self, value):
return self.syntax.fromdict(value)
[docs]
def openapi_definition(self):
definition = super().openapi_definition()
definition['additionalProperties'] = True
definition['minProperties'] = self._minimum
definition['maxProperties'] = self._maximum
if self.properties:
definition['properties'] = {
name: prop(self.property, self.property_name).get_openapi_definition() if prop else {'description': '%s:%s has no definition' % (self.property_name, name)}
for name, prop in self.properties.items()
}
definition['additionalProperties'] = False
if self.syntax.all_required:
definition['required'] = list(definition['properties'])
return definition
[docs]
class KeyValueDictionaryType(DictionaryType):
key_type: _Types | None = None
value_type: _Types | None = None
[docs]
def openapi_definition(self):
definition = super(DictionaryType, self).openapi_definition()
definition['additionalProperties'] = self.value_type(self.property, self.property_name).get_openapi_definition()
definition.pop('properties', None)
return definition
[docs]
class SambaLogonHours(ListType):
"""Samba Logon Hours"""
item_type = StringType
_weekdays = ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat')
[docs]
def decode_value(self, value):
return [f'{self._weekdays[v // 24]} {v % 24}-{v % 24 + 1}' for v in value]
[docs]
def encode_value(self, value):
try:
values = [v.split() for v in value]
return [self._weekdays.index(w) * 24 + int(h.split('-', 1)[0]) for w, h in values]
except (IndexError, ValueError):
raise univention.admin.uexceptions.valueInvalidSyntax(_('Invalid format for SambaLogonHours.'))
[docs]
class AppcenterTranslation(KeyValueDictionaryType):
"""Appcenter Translation"""
key_type = StringType
value_type = StringType
[docs]
def decode_value(self, value):
value = [x.partition(' ')[::2] for x in value]
return {k.lstrip('[').rstrip(']'): v for k, v in value}
[docs]
def encode_value(self, value):
value = [f'[{k}] {v}' for k, v in value.items()]
return super().encode_value(value)
[docs]
class UnixTimeinterval(IntegerType):
"""UNIX Time inerval"""
[docs]
def decode_value(self, value):
return self.syntax.to_integer(value)
[docs]
def encode_value(self, value):
return self.syntax.from_integer(value)