# SPDX-FileCopyrightText: 2004-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
"""|UDM| basic functionality"""
from __future__ import annotations
import copy
import re
import sys
import time
from re import Match
from typing import TYPE_CHECKING, Any
import unidecode
from ldap.filter import filter_format
import univention.admin.localization
import univention.config_registry
import univention.logging
from univention.admin._ucr import configRegistry
if TYPE_CHECKING:
from collections.abc import Callable, Container, Iterable
from univention.admin.handlers import simpleLdap
from univention.admin.layout import Tab
from univention.admin.types import TypeHint
from univention.admin.log import log
__all__ = ('configRegistry', 'extended_attribute', 'hook', 'mapping', 'modules', 'objects', 'option', 'pattern_replace', 'policiesGroup', 'property', 'syntax', 'ucr_overwrite_layout', 'ucr_overwrite_module_layout', 'ucr_overwrite_properties')
ucr_property_prefix = 'directory/manager/web/modules/%s/properties/'
[docs]
def ucr_overwrite_properties(module: Any, lo: univention.admin.uldap.access) -> None:
"""Overwrite properties in property_descriptions by UCR variables"""
ucr_prefix = ucr_property_prefix % module.module
if not module:
return
for var in configRegistry.keys():
if not var.startswith(ucr_prefix):
continue
try:
prop_name, attr = var[len(ucr_prefix):].split('/', 1)
# ignore internal attributes
log.trace('property overwrite: found variable', ucr=var)
if attr.startswith('__'):
continue
if attr == 'default':
# a property object is instantiated with default=...
# but internally uses "base_default" as member variable
# "default" is an instance_method...
attr = 'base_default'
if prop_name in module.property_descriptions:
prop = module.property_descriptions[prop_name]
if hasattr(prop, attr):
new_prop_val = configRegistry[var]
old_prop_val = getattr(prop, attr)
if old_prop_val is None:
# if the attribute was None the type cast
# will fail. best bet is str as type
old_prop_val = ''
prop_val_type = type(old_prop_val)
log.trace('property overwrite: set property attribute', attr=attr, value=new_prop_val)
if attr in ('syntax',):
if hasattr(univention.admin.syntax, new_prop_val):
syntax = getattr(univention.admin.syntax, new_prop_val)
setattr(prop, attr, syntax())
else:
if lo.authz_connection.searchDn(filter=filter_format(univention.admin.syntax.LDAP_Search.FILTER_PATTERN, [new_prop_val])):
syntax = univention.admin.syntax.LDAP_Search(new_prop_val)
syntax._load(lo)
setattr(prop, attr, syntax)
else:
log.error("property overwrite: Unknown UDM syntax", ucr=var, syntax=new_prop_val)
elif prop_val_type is bool:
setattr(prop, attr, configRegistry.is_true(None, None, new_prop_val))
else:
setattr(prop, attr, prop_val_type(new_prop_val))
log.trace('property overwrite: get property attribute: %s (type %s)', old_prop_val, prop_val_type)
except Exception:
log.exception('property overwrite: failed to set property attribute')
continue
[docs]
def pattern_replace(pattern: str, obj: dict | simpleLdap) -> str:
"""
Replaces patterns like `<attribute:command,...>[range]` with values
of the specified UDM attribute.
"""
global_commands: list[str] = []
def modify_text(text: str, commands: list[str]) -> str:
# apply all string commands
for iCmd in commands:
if iCmd == 'lower':
text = text.lower()
elif iCmd == 'upper':
text = text.upper()
elif iCmd == 'umlauts':
# We need this to handle german umlauts, e.g. ä -> ae
for umlaut, code in property.UMLAUTS.items():
text = text.replace(umlaut, code)
text = unidecode.unidecode(text)
elif iCmd == 'alphanum':
whitelist = configRegistry.get('directory/manager/templates/alphanum/whitelist', '')
text = ''.join([c for c in text if (c.isalnum() or c in whitelist)])
elif iCmd in ('trim', 'strip'):
text = text.strip()
return text
def repl(match: Match[str]) -> str:
key = match.group('key')
ext = match.group('ext')
strCommands = []
# check within the key for additional commands to be applied on the string
# (e.g., 'firstname:lower,umlaut') these commands are found after a ':'
if ':' in key:
# get the corrected key without following commands
key, tmpStr = key.rsplit(':', 1)
# get all commands in lower case and without leading/trailing spaces
strCommands = [iCmd.lower().strip() for iCmd in tmpStr.split(',')]
# if this is a list of global commands store the
# commands and return an empty string
if not key:
global_commands.extend(strCommands)
return ''
# make sure the key value exists
if key in obj and obj[key]: # noqa: RUF019
val = modify_text(obj[key], strCommands)
# try to apply the indexing instructions, indicated through '[...]'
if ext:
try:
return eval('val%s' % (ext)) # noqa: S307
except SyntaxError:
return val
return val
elif key == 'dn' and obj.dn:
return obj.dn
return ''
regex = re.compile(r'<(?P<key>[^>]+)>(?P<ext>\[[\d:]+\])?')
value = regex.sub(repl, pattern, 0)
if global_commands:
value = modify_text(value, global_commands)
return value
[docs]
class property:
UMLAUTS = {
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'Þ': 'P',
'ð': 'o',
'þ': 'p',
}
def __init__(
self,
short_description: str = '',
long_description: str = '',
*,
syntax: type | Any = None,
module_search: None = None,
multivalue: bool = False,
one_only: bool = False,
parent: None = None,
options: list[str] = [],
license: list[str] = [],
required: bool = False,
may_change: bool = True,
identifies: bool = False,
unique: bool = False,
default: bool | int | str | list[str] | tuple[Any, list[str]] | tuple[Callable, list[str], Any] | None = None,
prevent_umc_default_popup: bool = False,
dontsearch: bool = False,
show_in_lists: bool = True,
cli_enabled: bool = True,
editable: bool = True,
configObjectPosition: None = None,
configAttributeName: None = None,
include_in_default_search: bool = False,
nonempty_is_default: bool = False,
readonly_when_synced: bool = False,
size: str | None = None,
copyable: bool = False,
type_class: type[TypeHint] | None = None,
lazy_loading_fn: str | None = None,
) -> None:
"""
|UDM| property.
:param short_description: a short descriptive text - shown below the input filed in |UMC| by default.
:param long_description: a long descriptive text - shown only on demand in |UMC|.
:param syntax: a syntax class or instance to validate the value.
:param module_search: UNUSED?
:param multivalue: allow only a single value (`False`) or multiple values (`True`) .
:param one_only: UNUSED?
:param parent: UNUSED?
:param options: List of options, which enable this property.
:param license: List of license strings, which are required to use this property.
:param required: `True` for a required property, `False` for an optional property.
:param may_change: `True` if the property can be changed after the object has been created, `False` when the property can only be specified when the object is created.
:param identifies: `True` if the property is part of the set of properties, which are required to uniquely identify the object. The properties are used by default to build |RDN| for a new object.
:param unique: `True` if the property must be unique for all object instances.
:param default: The default value for the property when a new object is created.
:param prevent_umc_default_popup: `True` to prevent a pop-up dialog in |UMC| when the default value is not set.
:param dontsearch: `True` to prevent searches using the property.
:param show_in_lists: `False` to prevent it from being shown in the CLI.
:param cli_enabled: `True` to be able to set the attribute in the CLI.
:param editable: `False` prevents the property from being modified by the user; it still can be modified by code.
:param configObjectPosition: UNUSED?
:param configAttributeName: UNUSED?
:param include_in_default_search: The default search searches this property when set to `True`.
:param nonempty_is_default: `True` selects the first non-empty value as the default. `False` always selects the first default value, even if it is empty.
:param readonly_when_synced: `True` only shows the value as read-only when synchronized from some upstream database.
:param size: The |UMC| widget size; one of :py:data:`univention.admin.syntax.SIZES`.
:param copyable: With `True` the property is copied when the object is cloned; with `False` the new object will use the default value.
:param type_class: An optional Typing class which overwrites the syntax class specific type.
:param lazy_loading_fn: An optional function name that implements loading additional expensive properties if requested.
"""
self.short_description = short_description
self.long_description = long_description
if isinstance(syntax, type):
self.syntax = syntax()
else:
self.syntax = syntax
self.module_search = module_search
self.multivalue = multivalue
self.one_only = one_only
self.parent = parent
self.options = options or []
self.license = license or []
self.required = required
self.may_change = may_change
self.identifies = identifies
self.unique = unique
self.base_default = default
self.prevent_umc_default_popup = prevent_umc_default_popup
self.dontsearch = dontsearch
self.show_in_lists = show_in_lists
self.cli_enabled = cli_enabled
self.editable = editable
self.configObjectPosition = configObjectPosition
self.configAttributeName = configAttributeName
self.templates: list[simpleLdap] = []
self.include_in_default_search = include_in_default_search
self.threshold = int(configRegistry.get('directory/manager/web/sizelimit', '2000') or 2000)
self.nonempty_is_default = nonempty_is_default
self.readonly_when_synced = readonly_when_synced
self.size = size
self.copyable = copyable
self.type_class = type_class
self.lazy_loading_fn = lazy_loading_fn
[docs]
def new(self) -> list[str] | None:
return [] if self.multivalue else None
def _replace(self, res, obj):
return pattern_replace(copy.copy(res), obj)
[docs]
def default(self, obj: simpleLdap) -> Any:
base_default: bool | int | str | list[str] | tuple[Any, list[str]] | tuple[Callable, list[str], Any] | None = copy.copy(self.base_default)
if not obj.set_defaults:
return [] if self.multivalue else ''
if not base_default:
return self.new()
if isinstance(base_default, str):
return self._replace(base_default, obj)
bd0 = base_default[0]
# we can not import univention.admin.syntax here (recursive import) so we need to find another way to identify a complex syntax
if getattr(self.syntax, 'subsyntaxes', None) is not None and isinstance(bd0, list | tuple) and not self.multivalue:
return bd0
if isinstance(bd0, str):
# multivalue defaults will only be a part of templates, so not multivalue is the common way for modules
if not self.multivalue: # default=(template-str, [list-of-required-properties])
if all(obj[p] for p in base_default[1]):
for p in base_default[1]:
bd0 = bd0.replace('<%s>' % (p,), obj[p])
return bd0
return self.new()
else: # multivalue
if all(isinstance(bd, str) for bd in base_default):
return [self._replace(bd, obj) for bd in base_default]
# must be a list of loaded extended attributes then, so we return it if it has content
# return the first element, this is only related to empty extended attributes which are loaded wrong, needs to be fixed elsewhere
if bd0:
return [bd0]
return self.new()
if callable(bd0): # default=(func_obj_extra, [list-of-required-properties], extra-arg)
if all(obj[p] for p in base_default[1]):
return bd0(obj, base_default[2])
return self.new()
return self.new()
[docs]
def safe_default(self, obj: simpleLdap) -> Any:
def safe_parse(default):
if not default:
return False
try:
self.syntax.parse(default)
return True
except Exception:
return False
defaults = self.default(obj)
if isinstance(defaults, list):
return [self.syntax.parse(d) for d in defaults if safe_parse(d)]
elif safe_parse(defaults):
return self.syntax.parse(defaults)
return defaults
[docs]
def check_default(self, obj: simpleLdap) -> None:
defaults = self.default(obj)
try:
if isinstance(defaults, list):
for d in defaults:
if d:
self.syntax.parse(d)
elif defaults:
self.syntax.parse(defaults)
except univention.admin.uexceptions.valueError:
raise univention.admin.uexceptions.templateSyntaxError([t['name'] for t in self.templates])
[docs]
def matches(self, options: Iterable[str]) -> bool:
if not self.options:
return True
return bool(set(self.options).intersection(set(options)))
[docs]
def lazy_load(self, obj):
if self.lazy_loading_fn:
getattr(obj, self.lazy_loading_fn)()
[docs]
class option:
"""|UDM| option to make properties conditional."""
def __init__(
self,
*,
short_description: str = '',
long_description: str = '',
default: int = 0,
editable: bool = False,
disabled: bool = False,
objectClasses: Iterable[str] | None = None,
is_app_option: bool = False,
) -> None:
self.short_description = short_description
self.long_description = long_description
self.default = default
self.editable = editable
self.disabled = disabled
self.is_app_option = is_app_option
self.objectClasses = set()
if objectClasses:
self.objectClasses = set(objectClasses)
[docs]
def matches(self, objectClasses: Container[str]) -> bool:
if not self.objectClasses:
return True
return all(not oc not in objectClasses for oc in self.objectClasses)
[docs]
def ucr_overwrite_layout(module: Any, ucr_property: str, tab: Tab) -> bool | None:
"""Overwrite the advanced setting in the layout"""
desc = tab['name']
if hasattr(tab['name'], 'data'):
desc = tab.tab['name'].data
# replace invalid characters by underscores
desc = re.sub(univention.config_registry.invalid_key_chars, '_', desc).replace('/', '_')
return configRegistry.is_true('directory/manager/web/modules/%s/layout/%s/%s' % (module, desc, ucr_property), None)
[docs]
def ucr_overwrite_module_layout(module: Any) -> None:
"""Overwrite the tab layout through |UCR| variables."""
# there are modules without a layout definition
if not hasattr(module, 'layout'):
return
new_layout = []
for tab in module.layout[:]:
desc = tab.label
if hasattr(tab.label, 'data'):
desc = tab.label.data
# replace invalid characters by underscores
desc = re.sub(univention.config_registry.invalid_key_chars, '_', desc).replace('/', '_')
tab_layout = configRegistry.get('directory/manager/web/modules/%s/layout/%s' % (module.module, desc))
tab_name = configRegistry.get('directory/manager/web/modules/%s/layout/%s/name' % (module.module, desc))
tab_descr = configRegistry.get('directory/manager/web/modules/%s/layout/%s/description' % (module.module, desc))
log.trace('layout overwrite', tab_name=tab_name, tab_layout=tab_layout, tab_descr=tab_descr)
if tab_name:
tab['name'] = tab_name
if tab_descr:
tab['description'] = tab_descr
# for now the layout modification from UCS 2.4 is disabled (see Bug #26673)
# (this piece of code does not respect the tab-group-hierarchie of UCS 3.0)
# if tab_layout and tab_layout.lower() != 'none':
# layout = []
# for row in tab_layout.split( ';' ):
# line = []
# for col in row.split( ',' ):
# col = col.strip()
# if not col:
# continue
# if col in module.property_descriptions:
# line.append( col )
# else:
# log.error("layout overwrite: unknown property: %s", col )
# layout.append( line )
# tab[ 'layout' ] = { 'label' : _( 'General' ), 'layout' : layout }
if not tab_layout or tab_layout.lower() != 'none':
# disable specified properties via UCR
ucr_prefix = ucr_property_prefix % module.module
for var in configRegistry.keys():
if not var.startswith(ucr_prefix):
continue
prop, attr = var[len(ucr_prefix):].split('/', 1)
# ignore invalid/unknown UCR variables
if '/' in attr:
continue
if attr in ('__hidden') and configRegistry.is_true(var):
removed, layout = tab.remove(prop)
log.debug('layout overwrite: tried to hide property', property=prop, found=removed)
new_layout.append(tab)
module.layout = new_layout
# sort tabs: All apps occur alphabetical after the "Apps" / "Options" tab
app_tabs = [x for x in module.layout if x.is_app_tab]
app_tabs.sort(key=lambda x: x.label.lower())
layout = [x for x in module.layout if not x.is_app_tab]
pos = ([i for i, x in enumerate(layout, 1) if x.label == 'Apps'] or [len(layout)])[0]
layout[pos:pos] = app_tabs
module.layout = layout
[docs]
class extended_attribute:
"""Extended attributes extend |UDM| and |UMC| with additional properties defined in |LDAP|."""
def __init__(self, name: str, objClass: str, ldapMapping: Any, deleteObjClass: bool = False, syntax: str = 'string', hook: Any = None) -> None:
self.name = name
self.objClass = objClass
self.ldapMapping = ldapMapping
self.deleteObjClass = deleteObjClass
self.syntax = syntax
self.hook = hook
def __repr__(self):
hook = None
if self.hook:
hook = self.hook.type
return " univention.admin.extended_attribute: { name: '%s', oc: '%s', attr: '%s', delOC: '%s', syntax: '%s', hook: '%s' }" % (
self.name,
self.objClass,
self.ldapMapping,
self.deleteObjClass,
self.syntax,
hook,
)
[docs]
class policiesGroup:
"""A policies group"""
def __init__(self, id: Any, short_description: str | None = None, long_description: str = '', members: Any = []) -> None:
self.id = id
if short_description is None:
self.short_description = id
else:
self.short_description = short_description
self.long_description = long_description
self.members = members
def _ldap_cache(ttl=10, cache_none=True):
def _decorator(func):
func._cache = {}
func._last_called = None
def _decorated(lo, *args):
cache = func._cache
key = (id(lo), *list(args))
now = time.time()
if func._last_called and (now - func._last_called) > ttl:
for cache_key, cache_val in list(cache.items()):
if cache_val['expire'] < now:
cache.pop(cache_key, True)
func._last_called = now
if key not in cache or cache[key]['expire'] < now:
value = {'value': func(lo, *args), 'expire': time.time() + ttl}
if value['value'] is None and not cache_none:
return
cache[key] = value
return cache[key]['value']
def cache_clear():
func._cache = {}
_decorated.cache_clear = cache_clear
return _decorated
return _decorator
univention.admin = sys.modules[__name__]
from univention.admin import hook, mapping, modules, objects, syntax # noqa: E402
syntax.import_syntax_files()
hook.import_hook_files()
import univention.admin.handlers # noqa: E402
if __name__ == '__main__':
prop = property('_replace')
for pattern in (
'<firstname>',
'<firstname> <lastname>',
'<firstname:upper>',
'<:trim,upper><firstname> <lastname> ',
'<:lower><firstname> <lastname>',
'<:umlauts><firstname> <lastname>',
):
print("pattern: '%s'" % pattern)
print(" -> '%s'" % prop._replace(pattern, {'firstname': 'Andreas', 'lastname': 'Büsching'}))