LdapClient: add support to config lib
This commit is contained in:
parent
dc0f17dd20
commit
73c19816f7
1 changed files with 189 additions and 31 deletions
218
mylib/ldap.py
218
mylib/ldap.py
|
@ -5,6 +5,7 @@
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import pytz
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
import dateutil.tz
|
import dateutil.tz
|
||||||
|
@ -12,11 +13,11 @@ import ldap
|
||||||
from ldap.controls import SimplePagedResultsControl
|
from ldap.controls import SimplePagedResultsControl
|
||||||
from ldap.controls.simple import RelaxRulesControl
|
from ldap.controls.simple import RelaxRulesControl
|
||||||
import ldap.modlist as modlist
|
import ldap.modlist as modlist
|
||||||
import pytz
|
|
||||||
|
|
||||||
from mylib import pretty_format_dict
|
from mylib import pretty_format_dict
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
DEFAULT_ENCODING = 'utf-8'
|
||||||
|
|
||||||
|
|
||||||
class LdapServer:
|
class LdapServer:
|
||||||
|
@ -81,6 +82,7 @@ class LdapServer:
|
||||||
|
|
||||||
def search(self, basedn, filterstr=None, attrs=None, sizelimit=0, scope=None):
|
def search(self, basedn, filterstr=None, attrs=None, sizelimit=0, scope=None):
|
||||||
""" Run a search on LDAP server """
|
""" Run a search on LDAP server """
|
||||||
|
assert self.con or self.connect()
|
||||||
res_id = self.con.search(
|
res_id = self.con.search(
|
||||||
basedn,
|
basedn,
|
||||||
self.get_scope(scope if scope else 'sub'),
|
self.get_scope(scope if scope else 'sub'),
|
||||||
|
@ -106,6 +108,7 @@ class LdapServer:
|
||||||
def paged_search(self, basedn, filterstr, attrs, scope='sub', pagesize=500):
|
def paged_search(self, basedn, filterstr, attrs, scope='sub', pagesize=500):
|
||||||
""" Run a paged search on LDAP server """
|
""" Run a paged search on LDAP server """
|
||||||
assert not self.v2, "Paged search is not available on LDAP version 2"
|
assert not self.v2, "Paged search is not available on LDAP version 2"
|
||||||
|
assert self.con or self.connect()
|
||||||
# Initialize SimplePagedResultsControl object
|
# Initialize SimplePagedResultsControl object
|
||||||
page_control = SimplePagedResultsControl(
|
page_control = SimplePagedResultsControl(
|
||||||
True,
|
True,
|
||||||
|
@ -177,6 +180,7 @@ class LdapServer:
|
||||||
def add_object(self, dn, attrs):
|
def add_object(self, dn, attrs):
|
||||||
""" Add an object in LDAP directory """
|
""" Add an object in LDAP directory """
|
||||||
ldif = modlist.addModlist(attrs)
|
ldif = modlist.addModlist(attrs)
|
||||||
|
assert self.con or self.connect()
|
||||||
try:
|
try:
|
||||||
self.logger.debug("LdapServer - Add %s", dn)
|
self.logger.debug("LdapServer - Add %s", dn)
|
||||||
self.con.add_s(dn, ldif)
|
self.con.add_s(dn, ldif)
|
||||||
|
@ -195,6 +199,7 @@ class LdapServer:
|
||||||
)
|
)
|
||||||
if ldif == []:
|
if ldif == []:
|
||||||
return True
|
return True
|
||||||
|
assert self.con or self.connect()
|
||||||
try:
|
try:
|
||||||
if relax:
|
if relax:
|
||||||
self.con.modify_ext_s(dn, ldif, serverctrls=[RelaxRulesControl()])
|
self.con.modify_ext_s(dn, ldif, serverctrls=[RelaxRulesControl()])
|
||||||
|
@ -256,6 +261,7 @@ class LdapServer:
|
||||||
new_dn_parts = new_rdn.split(',')
|
new_dn_parts = new_rdn.split(',')
|
||||||
new_sup = ','.join(new_dn_parts[1:])
|
new_sup = ','.join(new_dn_parts[1:])
|
||||||
new_rdn = new_dn_parts[0]
|
new_rdn = new_dn_parts[0]
|
||||||
|
assert self.con or self.connect()
|
||||||
try:
|
try:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"LdapServer - Rename %s in %s (new superior: %s, delete old: %s)",
|
"LdapServer - Rename %s in %s (new superior: %s, delete old: %s)",
|
||||||
|
@ -282,6 +288,7 @@ class LdapServer:
|
||||||
|
|
||||||
def drop_object(self, dn):
|
def drop_object(self, dn):
|
||||||
""" Drop an object in LDAP directory """
|
""" Drop an object in LDAP directory """
|
||||||
|
assert self.con or self.connect()
|
||||||
try:
|
try:
|
||||||
self.logger.debug("LdapServer - Delete %s", dn)
|
self.logger.debug("LdapServer - Delete %s", dn)
|
||||||
self.con.delete_s(dn)
|
self.con.delete_s(dn)
|
||||||
|
@ -331,52 +338,139 @@ class LdapClient:
|
||||||
|
|
||||||
""" LDAP Client (based on python-mylib.LdapServer) """
|
""" LDAP Client (based on python-mylib.LdapServer) """
|
||||||
|
|
||||||
options = {}
|
_options = {}
|
||||||
|
_config = None
|
||||||
|
_config_section = None
|
||||||
|
|
||||||
|
# Connection
|
||||||
|
_conn = None
|
||||||
|
|
||||||
# Cache objects
|
# Cache objects
|
||||||
_cached_objects = dict()
|
_cached_objects = dict()
|
||||||
|
|
||||||
def __init__(self, options):
|
def __init__(self, options=None, options_prefix=None, config=None, config_section=None, initialize=False):
|
||||||
self.options = options
|
self._options = options if options else {}
|
||||||
log.info("Connect to LDAP server %s as %s", options.ldap_uri, options.ldap_binddn)
|
self._options_prefix = options_prefix if options_prefix else 'ldap_'
|
||||||
self.cnx = LdapServer(options.ldap_uri, dn=options.ldap_binddn, pwd=options.ldap_bindpwd, raiseOnError=True)
|
self._config = config if config else None
|
||||||
self.cnx.connect()
|
self._config_section = config_section if config_section else 'ldap'
|
||||||
|
if initialize:
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
@classmethod
|
def __get_option(self, option, default=None, required=False):
|
||||||
def decode(cls, value):
|
""" Retreive option value """
|
||||||
|
if self._options and hasattr(self._options, self._options_prefix + option):
|
||||||
|
return getattr(self._options, self._options_prefix + option)
|
||||||
|
|
||||||
|
if self._config and self._config.defined(self._config_section, option):
|
||||||
|
return self._config.get(self._config_section, option)
|
||||||
|
|
||||||
|
assert not required, "Options %s not defined" % option
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
def configure(self, comment=None, **kwargs):
|
||||||
|
""" Configure options on registered mylib.Config object """
|
||||||
|
assert self._config, "mylib.Config object not registered. Must be passed to __init__ as config keyword argument."
|
||||||
|
|
||||||
|
from mylib.config import StringOption, PasswordOption
|
||||||
|
|
||||||
|
section = self._config.add_section(
|
||||||
|
self._config_section,
|
||||||
|
comment=comment if comment else 'LDAP connection',
|
||||||
|
loaded_callback=self.initialize, **kwargs)
|
||||||
|
|
||||||
|
section.add_option(
|
||||||
|
StringOption, 'uri', default='ldap://localhost',
|
||||||
|
comment='LDAP server URI')
|
||||||
|
section.add_option(
|
||||||
|
StringOption, 'binddn', comment='LDAP Bind DN')
|
||||||
|
section.add_option(
|
||||||
|
PasswordOption, 'bindpwd',
|
||||||
|
comment='LDAP Bind password (set to "keyring" to use XDG keyring)',
|
||||||
|
username_option='binddn', keyring_value='keyring')
|
||||||
|
|
||||||
|
return section
|
||||||
|
|
||||||
|
def initialize(self, loaded_config=None):
|
||||||
|
""" Initialize LDAP connection """
|
||||||
|
if loaded_config:
|
||||||
|
self.config = loaded_config
|
||||||
|
uri = self.__get_option('uri', required=True)
|
||||||
|
binddn = self.__get_option('binddn')
|
||||||
|
log.info("Connect to LDAP server %s as %s", uri, binddn if binddn else 'annonymous')
|
||||||
|
self._conn = LdapServer(
|
||||||
|
uri, dn=binddn, pwd=self.__get_option('bindpwd'),
|
||||||
|
raiseOnError=True
|
||||||
|
)
|
||||||
|
return self._conn.connect()
|
||||||
|
|
||||||
|
def decode(self, value):
|
||||||
|
""" Decode LDAP attribute value """
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [cls.decode(v) for v in value]
|
return [self.decode(v) for v in value]
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
return value.decode('utf-8', 'ignore')
|
return value.decode(
|
||||||
|
self.__get_option('encoding', default=DEFAULT_ENCODING),
|
||||||
|
self.__get_option('encoding_error_policy', default='ignore')
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
def encode(self, value):
|
||||||
def encode(cls, value):
|
""" Encode LDAP attribute value """
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [cls.encode(v) for v in value]
|
return [self.encode(v) for v in value]
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
return value
|
return value
|
||||||
return value.encode('utf-8')
|
return value.encode(self.__get_option('encoding', default=DEFAULT_ENCODING))
|
||||||
|
|
||||||
def get_attrs(self, dn, attrs):
|
def _get_obj(self, dn, attrs):
|
||||||
|
"""
|
||||||
|
Build and return LDAP object as dict
|
||||||
|
|
||||||
|
:param dn: The object DN
|
||||||
|
:param attrs: The object attributes as return by python-ldap search
|
||||||
|
"""
|
||||||
obj = dict(dn=dn)
|
obj = dict(dn=dn)
|
||||||
for attr in attrs:
|
for attr in attrs:
|
||||||
obj[attr] = [self.decode(v) for v in self.cnx.get_attr(attrs, attr, all=True)]
|
obj[attr] = [self.decode(v) for v in self._conn.get_attr(attrs, attr, all=True)]
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_attr(obj, attr, default="", all_values=False):
|
def get_attr(obj, attr, default="", all_values=False):
|
||||||
|
"""
|
||||||
|
Get LDAP object attribute value(s)
|
||||||
|
|
||||||
|
:param obj: The LDAP object as returned by get_object()/get_objects
|
||||||
|
:param attr: The attribute name
|
||||||
|
:param all_values: If True, all values of the attribute will be
|
||||||
|
returned instead of the first value only
|
||||||
|
(optinal, default: False)
|
||||||
|
"""
|
||||||
vals = obj.get(attr, [])
|
vals = obj.get(attr, [])
|
||||||
if vals:
|
if vals:
|
||||||
return vals if all_values else vals[0]
|
return vals if all_values else vals[0]
|
||||||
return default if default or not all_values else []
|
return default if default or not all_values else []
|
||||||
|
|
||||||
def get_objects(self, name, filterstr, basedn, attrs, key_attr=None, warn=True):
|
def get_objects(self, name, filterstr, basedn, attrs, key_attr=None, warn=True):
|
||||||
|
"""
|
||||||
|
Retrieve objects from LDAP
|
||||||
|
|
||||||
|
:param name: The object type name
|
||||||
|
:param filterstr: The LDAP filter to use to search objects on LDAP directory
|
||||||
|
:param basedn: The base DN of the search
|
||||||
|
:param attrs: The list of attribute names to retreive
|
||||||
|
:param key_attr: The attribute name or 'dn' to use as key in result
|
||||||
|
(optional, if leave to None, the result will be a list)
|
||||||
|
:param warn: If True, a warning message will be logged if no object is found
|
||||||
|
in LDAP directory (otherwise, it will be just a debug message)
|
||||||
|
(optional, default: True)
|
||||||
|
"""
|
||||||
if name in self._cached_objects:
|
if name in self._cached_objects:
|
||||||
log.debug('Retreived %s objects from cache', name)
|
log.debug('Retreived %s objects from cache', name)
|
||||||
else:
|
else:
|
||||||
|
assert self._conn or self.initialize()
|
||||||
log.debug('Looking for LDAP %s with (filter="%s" / basedn="%s")', name, filterstr, basedn)
|
log.debug('Looking for LDAP %s with (filter="%s" / basedn="%s")', name, filterstr, basedn)
|
||||||
ldap_data = self.cnx.search(
|
ldap_data = self._conn.search(
|
||||||
basedn=basedn,
|
basedn=basedn,
|
||||||
filterstr=filterstr,
|
filterstr=filterstr,
|
||||||
attrs=attrs
|
attrs=attrs
|
||||||
|
@ -391,7 +485,7 @@ class LdapClient:
|
||||||
|
|
||||||
objects = {}
|
objects = {}
|
||||||
for obj_dn, obj_attrs in ldap_data.items():
|
for obj_dn, obj_attrs in ldap_data.items():
|
||||||
objects[obj_dn] = self.get_attrs(obj_dn, obj_attrs)
|
objects[obj_dn] = self._get_obj(obj_dn, obj_attrs)
|
||||||
self._cached_objects[name] = objects
|
self._cached_objects[name] = objects
|
||||||
if not key_attr or key_attr == 'dn':
|
if not key_attr or key_attr == 'dn':
|
||||||
return self._cached_objects[name]
|
return self._cached_objects[name]
|
||||||
|
@ -401,8 +495,24 @@ class LdapClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_object(self, type_name, object_name, filterstr, basedn, attrs, warn=True):
|
def get_object(self, type_name, object_name, filterstr, basedn, attrs, warn=True):
|
||||||
|
"""
|
||||||
|
Retrieve an object from LDAP specified using LDAP search parameters
|
||||||
|
|
||||||
|
Only one object is excepted to be returned by the LDAP search, otherwise, one
|
||||||
|
LdapClientException will be raised.
|
||||||
|
|
||||||
|
:param type_name: The object type name
|
||||||
|
:param object_name: The object name (only use in log messages)
|
||||||
|
:param filterstr: The LDAP filter to use to search the object on LDAP directory
|
||||||
|
:param basedn: The base DN of the search
|
||||||
|
:param attrs: The list of attribute names to retreive
|
||||||
|
:param warn: If True, a warning message will be logged if no object is found
|
||||||
|
in LDAP directory (otherwise, it will be just a debug message)
|
||||||
|
(optional, default: True)
|
||||||
|
"""
|
||||||
|
assert self._conn or self.initialize()
|
||||||
log.debug('Looking for LDAP %s "%s" with (filter="%s" / basedn="%s")', type_name, object_name, filterstr, basedn)
|
log.debug('Looking for LDAP %s "%s" with (filter="%s" / basedn="%s")', type_name, object_name, filterstr, basedn)
|
||||||
ldap_data = self.cnx.search(
|
ldap_data = self._conn.search(
|
||||||
basedn=basedn, filterstr=filterstr,
|
basedn=basedn, filterstr=filterstr,
|
||||||
attrs=attrs
|
attrs=attrs
|
||||||
)
|
)
|
||||||
|
@ -418,9 +528,21 @@ class LdapClient:
|
||||||
raise LdapClientException('More than one %s "%s": %s' % (type_name, object_name, ' / '.join(ldap_data.keys())))
|
raise LdapClientException('More than one %s "%s": %s' % (type_name, object_name, ' / '.join(ldap_data.keys())))
|
||||||
|
|
||||||
dn = next(iter(ldap_data))
|
dn = next(iter(ldap_data))
|
||||||
return self.get_attrs(dn, ldap_data[dn])
|
return self._get_obj(dn, ldap_data[dn])
|
||||||
|
|
||||||
def get_object_by_dn(self, type_name, dn, populate_cache_method=None, warn=True):
|
def get_object_by_dn(self, type_name, dn, populate_cache_method=None, warn=True):
|
||||||
|
"""
|
||||||
|
Retrieve an LDAP object specified by its DN from cache
|
||||||
|
|
||||||
|
:param type_name: The object type name
|
||||||
|
:param dn: The object DN
|
||||||
|
:param populate_cache_method: The method to use is cache of LDAP object type
|
||||||
|
is not already populated (optional, default,
|
||||||
|
False is returned)
|
||||||
|
:param warn: If True, a warning message will be logged if object is not found
|
||||||
|
in cache (otherwise, it will be just a debug message)
|
||||||
|
(optional, default: True)
|
||||||
|
"""
|
||||||
if type_name not in self._cached_objects:
|
if type_name not in self._cached_objects:
|
||||||
if not populate_cache_method:
|
if not populate_cache_method:
|
||||||
return False
|
return False
|
||||||
|
@ -441,11 +563,35 @@ class LdapClient:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def object_attr_mached(cls, obj, attr, value, case_sensitive=False):
|
def object_attr_mached(cls, obj, attr, value, case_sensitive=False):
|
||||||
|
"""
|
||||||
|
Determine if object's attribute matched with specified value
|
||||||
|
|
||||||
|
:param obj: The LDAP object (as returned by get_object/get_objects)
|
||||||
|
:param attr: The attribute name
|
||||||
|
:param value: The value for the match test
|
||||||
|
:param case_sensitive: If True, the match test will be case-sensitive
|
||||||
|
(optional, default: False)
|
||||||
|
"""
|
||||||
if case_sensitive:
|
if case_sensitive:
|
||||||
return value in cls.get_attr(obj, attr, all_values=True)
|
return value in cls.get_attr(obj, attr, all_values=True)
|
||||||
return value.lower() in [v.lower() for v in cls.get_attr(obj, attr, all_values=True)]
|
return value.lower() in [v.lower() for v in cls.get_attr(obj, attr, all_values=True)]
|
||||||
|
|
||||||
def get_object_by_attr(self, type_name, attr, value, populate_cache_method=None, case_sensitive=False, warn=True):
|
def get_object_by_attr(self, type_name, attr, value, populate_cache_method=None, case_sensitive=False, warn=True):
|
||||||
|
"""
|
||||||
|
Retrieve an LDAP object specified by one of its attribute
|
||||||
|
|
||||||
|
:param type_name: The object type name
|
||||||
|
:param attr: The attribute name
|
||||||
|
:param value: The value for the match test
|
||||||
|
:param populate_cache_method: The method to use is cache of LDAP object type
|
||||||
|
is not already populated (optional, default,
|
||||||
|
False is returned)
|
||||||
|
:param case_sensitive: If True, the match test will be case-sensitive
|
||||||
|
(optional, default: False)
|
||||||
|
:param warn: If True, a warning message will be logged if object is not found
|
||||||
|
in cache (otherwise, it will be just a debug message)
|
||||||
|
(optional, default: True)
|
||||||
|
"""
|
||||||
if type_name not in self._cached_objects:
|
if type_name not in self._cached_objects:
|
||||||
if not populate_cache_method:
|
if not populate_cache_method:
|
||||||
return False
|
return False
|
||||||
|
@ -504,7 +650,15 @@ class LdapClient:
|
||||||
return (old, new)
|
return (old, new)
|
||||||
|
|
||||||
def format_changes(self, changes, protected_attrs=None, prefix=None):
|
def format_changes(self, changes, protected_attrs=None, prefix=None):
|
||||||
return self.cnx.format_changes(
|
"""
|
||||||
|
Format changes as string
|
||||||
|
|
||||||
|
:param changes: The changes as returned by get_changes
|
||||||
|
:param protected_attrs: Optional list of protected attributes
|
||||||
|
:param prefix: Optional prefix string for each line of the returned string
|
||||||
|
"""
|
||||||
|
assert self._conn or self.initialize()
|
||||||
|
return self._conn.format_changes(
|
||||||
changes[0], changes[1],
|
changes[0], changes[1],
|
||||||
ignore_attrs=protected_attrs, prefix=prefix
|
ignore_attrs=protected_attrs, prefix=prefix
|
||||||
)
|
)
|
||||||
|
@ -521,10 +675,11 @@ class LdapClient:
|
||||||
for attr, values in attrs.items()
|
for attr, values in attrs.items()
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if self.options.just_try:
|
if self.__get_option('just_try', default=False):
|
||||||
log.debug('Just-try mode : do not really add object in LDAP')
|
log.debug('Just-try mode : do not really add object in LDAP')
|
||||||
return True
|
return True
|
||||||
return self.cnx.add_object(dn, attrs)
|
assert self._conn or self.initialize()
|
||||||
|
return self._conn.add_object(dn, attrs)
|
||||||
except LdapServerException:
|
except LdapServerException:
|
||||||
log.error(
|
log.error(
|
||||||
"An error occurred adding object %s in LDAP:\n%s\n",
|
"An error occurred adding object %s in LDAP:\n%s\n",
|
||||||
|
@ -589,10 +744,11 @@ class LdapClient:
|
||||||
log.debug('%s: No change detected on RDN attibute %s', ldap_obj['dn'], rdn_attr)
|
log.debug('%s: No change detected on RDN attibute %s', ldap_obj['dn'], rdn_attr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.options.just_try:
|
if self.__get_option('just_try', default=False):
|
||||||
log.debug('Just-try mode : do not really update object in LDAP')
|
log.debug('Just-try mode : do not really update object in LDAP')
|
||||||
return True
|
return True
|
||||||
return self.cnx.update_object(
|
assert self._conn or self.initialize()
|
||||||
|
return self._conn.update_object(
|
||||||
ldap_obj['dn'],
|
ldap_obj['dn'],
|
||||||
changes[0],
|
changes[0],
|
||||||
changes[1],
|
changes[1],
|
||||||
|
@ -614,10 +770,11 @@ class LdapClient:
|
||||||
:param new_dn_or_rdn: The new LDAP object's DN (or RDN)
|
:param new_dn_or_rdn: The new LDAP object's DN (or RDN)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if self.options.just_try:
|
if self.__get_option('just_try', default=False):
|
||||||
log.debug('Just-try mode : do not really move object in LDAP')
|
log.debug('Just-try mode : do not really move object in LDAP')
|
||||||
return True
|
return True
|
||||||
return self.cnx.rename_object(ldap_obj['dn'], new_dn_or_rdn)
|
assert self._conn or self.initialize()
|
||||||
|
return self._conn.rename_object(ldap_obj['dn'], new_dn_or_rdn)
|
||||||
except LdapServerException:
|
except LdapServerException:
|
||||||
log.error(
|
log.error(
|
||||||
"An error occurred moving object %s in LDAP (destination: %s)",
|
"An error occurred moving object %s in LDAP (destination: %s)",
|
||||||
|
@ -632,10 +789,11 @@ class LdapClient:
|
||||||
:param ldap_obj: The original LDAP object to delete/drop
|
:param ldap_obj: The original LDAP object to delete/drop
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if self.options.just_try:
|
if self.__get_option('just_try', default=False):
|
||||||
log.debug('Just-try mode : do not really drop object in LDAP')
|
log.debug('Just-try mode : do not really drop object in LDAP')
|
||||||
return True
|
return True
|
||||||
return self.cnx.drop_object(ldap_obj['dn'])
|
assert self._conn or self.initialize()
|
||||||
|
return self._conn.drop_object(ldap_obj['dn'])
|
||||||
except LdapServerException:
|
except LdapServerException:
|
||||||
log.error(
|
log.error(
|
||||||
"An error occurred removing object %s in LDAP",
|
"An error occurred removing object %s in LDAP",
|
||||||
|
|
Loading…
Reference in a new issue