# -*- coding: utf-8 -*-
#
# Copyright 2019-2022 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.
"""
|UDM| type definitions.
"""
from __future__ import absolute_import
import inspect
import time
import datetime
from typing import Optional, Sequence, Type, Union # noqa: F401
import six
import ldap.dn
import univention.admin.uexceptions
from univention.admin import localization
import univention.debug as ud
translation = localization.translation('univention/admin')
_ = translation.translate
if six.PY3:
unicode = str
long = int
_Types = Union[Type[object], Sequence[Type[object]]]
[docs]class TypeHint(object):
"""
"""
_python_types = object # type: _Types
@property
def _json_type(self):
# in most cases, the python type is equivalent to the JSON type
return self._python_types
_openapi_type = None # type: Optional[str]
_openapi_format = None # type: Optional[str]
_openapi_regex = None # type: Optional[str]
_openapi_example = None # type: Optional[str]
_openapi_readonly = None # type: Optional[bool]
_openapi_writeonly = None # type: Optional[bool]
_openapi_nullable = True # everything which can be removed is nullable
_html_element = None
_html_input_type = None
_encoding = None # type: Optional[str]
_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):
self.property = property
self.property_name = property_name
self.syntax = 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 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 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:
ud.debug(ud.ADMIN, ud.WARN, 'ignoring invalid property %s value=%r is invalid: %s' % (self.property_name, value, 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__
ud.debug(ud.ADMIN, ud.WARN, '%r: Value=%r %r' % (self.property_name, value, 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
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:
ud.debug(ud.ADMIN, ud.WARN, 'Unknown type for property %r: %s' % (name, 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 = bool # type: _Types
_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
ud.debug(ud.ADMIN, ud.WARN, '%s: %s: not a boolean: %r' % (self.property_name, self.syntax.name, value,))
return value
[docs]class TriBooleanType(BooleanType):
_encodes_none = True
_python_types = (bool, type(None))
[docs]class IntegerType(TypeHint):
_python_types = (int, long)
_openapi_type = 'integer'
# _openapi_format: int32, int64
[docs] def decode_value(self, value):
try:
value = int(value)
except ValueError:
ud.debug(ud.ADMIN, ud.WARN, '%s: %s: not a integer: %r' % (self.property_name, self.syntax.name, value,))
return value
[docs]class NumberType(TypeHint):
_python_types = float
_openapi_type = 'number'
_openapi_format = 'double' # or 'float'
[docs]class StringType(TypeHint):
_python_types = unicode # type: _Types
_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 PasswordType(StringType):
_openapi_format = 'password'
_openapi_example = 'univention' # :-D
_openapi_readonly = True
[docs]class DistinguishedNameType(StringType):
_openapi_format = 'ldap-dn'
_openapi_example = 'dc=example,dc=net'
[docs] def encode_value(self, value):
value = super(DistinguishedNameType, self).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 = unicode
_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 = unicode
_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): # type: (datetime.date) -> unicode
return unicode(value.isoformat())
def _from_json_type(self, value): # type: (unicode) -> datetime.date
try:
return datetime.date(*time.strptime(value, '%Y-%m-%d')[0:3])
except ValueError:
ud.debug(ud.ADMIN, ud.INFO, 'Wrong date format: %r' % (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 = unicode
_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): # type: (datetime.time) -> unicode
return unicode(value.replace(microsecond=0).isoformat())
def _from_json_type(self, value): # type: (unicode) -> datetime.time
try:
return datetime.time(*time.strptime(value, '%H:%M:%S')[3:6])
except ValueError:
ud.debug(ud.ADMIN, ud.INFO, 'Wrong time format: %r' % (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-01 00:00:00'
"""
_python_types = datetime.datetime
_json_type = unicode
_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): # type: (datetime.datetime) -> unicode
return u' '.join((value.date().isoformat(), value.time().replace(microsecond=0).isoformat()))
def _from_json_type(self, value): # type: (unicode) -> datetime.datetime
try:
return datetime.datetime(*time.strptime(value, '%Y-%m-%dT%H:%M:%S')[:6]) # FIXME: parse Z at the end
except ValueError:
ud.debug(ud.ADMIN, ud.INFO, 'Wrong datetime format: %r' % (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 = None # type: Optional[Type[TypeHint]] # 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(ListType, self).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(SetType, self).encode_value(value)
if len(value) != len(set(value)):
raise univention.admin.uexceptions.valueInvalidSyntax(_('Duplicated entries.'))
return value
[docs]class ListOfItems(ArrayType):
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(ListOfItems, self).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(DictionaryType, self).openapi_definition()
definition['properties'] = []
definition['required'] = []
if not definition['properties']:
definition['additionalProperties'] = True
definition['minProperties'] = self._minimum
definition['maxProperties'] = self._maximum
definition.pop('properties', None)
definition.pop('required', None)
return definition
[docs]class KeyValueDictionaryType(DictionaryType):
key_type = None # type: Optional[_Types]
value_type = None # type: Optional[_Types]
[docs]class SambaLogonHours(ListType):
item_type = StringType
_weekdays = ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat')
[docs] def decode_value(self, value):
return ['{} {}-{}'.format(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):
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 = ['[{}] {}'.format(k, v) for k, v in value.items()]
return super(AppcenterTranslation, self).encode_value(value)
[docs]class UnixTimeinterval(IntegerType):
[docs] def decode_value(self, value):
return self.syntax.to_integer(value)
[docs] def encode_value(self, value):
return self.syntax.from_integer(value)