mirror of
https://github.com/samba-team/samba.git
synced 2025-07-21 12:59:09 +03:00
r26628: python: Add more documentation, simplify code in Samba3 module.
(This used to be commit 3c329ee73d
)
This commit is contained in:
committed by
Stefan Metzmacher
parent
ac65321a46
commit
6817c5d885
@ -30,7 +30,7 @@ realdistclean::
|
||||
|
||||
pythonmods: $(PYTHON_DSOS)
|
||||
|
||||
PYDOCTOR_MODULES=bin/python/ldb.py bin/python/auth.py bin/python/credentials.py bin/python/registry.py bin/python/tdb.py bin/python/security.py
|
||||
PYDOCTOR_MODULES=bin/python/ldb.py bin/python/auth.py bin/python/credentials.py bin/python/registry.py bin/python/tdb.py bin/python/security.py bin/python/events.py bin/python/net.py
|
||||
|
||||
pydoctor:: pythonmods
|
||||
LD_LIBRARY_PATH=bin/shared PYTHONPATH=bin/python pydoctor --make-html --docformat=restructuredtext --add-package scripting/python/samba/ $(addprefix --add-module , $(PYDOCTOR_MODULES))
|
||||
|
@ -159,11 +159,19 @@ class Ldb(ldb.Ldb):
|
||||
self.add_ldif(open(ldif_path, 'r').read())
|
||||
|
||||
def add_ldif(self, ldif):
|
||||
"""Add data based on a LDIF string.
|
||||
|
||||
:param ldif: LDIF text.
|
||||
"""
|
||||
for changetype, msg in self.parse_ldif(ldif):
|
||||
assert changetype == ldb.CHANGETYPE_NONE
|
||||
self.add(msg)
|
||||
|
||||
def modify_ldif(self, ldif):
|
||||
"""Modify database based on a LDIF string.
|
||||
|
||||
:param ldif: LDIF text.
|
||||
"""
|
||||
for (changetype, msg) in self.parse_ldif(ldif):
|
||||
assert changetype == ldb.CHANGETYPE_MODIFY
|
||||
self.modify(msg)
|
||||
|
@ -58,7 +58,7 @@ class CredentialsOptions(optparse.OptionGroup):
|
||||
self.creds.set_password(arg)
|
||||
|
||||
def set_simple_bind_dn(self, option, opt_str, arg, parser):
|
||||
self.creds.set_simple_bind_dn(arg)
|
||||
self.creds.set_bind_dn(arg)
|
||||
|
||||
def get_credentials(self):
|
||||
return self.creds
|
||||
|
@ -74,6 +74,14 @@ def findnss(nssfn, *names):
|
||||
|
||||
|
||||
def open_ldb(session_info, credentials, lp, dbname):
|
||||
"""Open a LDB, thrashing it if it is corrupt.
|
||||
|
||||
:param session_info: auth session information
|
||||
:param credentials: credentials
|
||||
:param lp: Loadparm context
|
||||
:param dbname: Path of the database to open.
|
||||
:return: a Ldb object
|
||||
"""
|
||||
assert session_info is not None
|
||||
try:
|
||||
return Ldb(dbname, session_info=session_info, credentials=credentials,
|
||||
@ -86,7 +94,12 @@ def open_ldb(session_info, credentials, lp, dbname):
|
||||
|
||||
|
||||
def setup_add_ldif(ldb, ldif_path, subst_vars=None):
|
||||
"""Setup a ldb in the private dir."""
|
||||
"""Setup a ldb in the private dir.
|
||||
|
||||
:param ldb: LDB file to import data into
|
||||
:param ldif_path: Path of the LDIF file to load
|
||||
:param subst_vars: Optional variables to subsitute in LDIF.
|
||||
"""
|
||||
assert isinstance(ldif_path, str)
|
||||
|
||||
data = open(ldif_path, 'r').read()
|
||||
@ -126,7 +139,12 @@ def setup_ldb(ldb, ldif_path, subst_vars):
|
||||
|
||||
|
||||
def setup_file(template, fname, substvars):
|
||||
"""Setup a file in the private dir."""
|
||||
"""Setup a file in the private dir.
|
||||
|
||||
:param template: Path of the template file.
|
||||
:param fname: Path of the file to create.
|
||||
:param substvars: Substitution variables.
|
||||
"""
|
||||
f = fname
|
||||
|
||||
if os.path.exists(f):
|
||||
@ -179,7 +197,17 @@ def provision_paths_from_lp(lp, dnsdomain):
|
||||
|
||||
def setup_name_mappings(ldb, sid, domaindn, root, nobody, nogroup, users,
|
||||
wheel, backup):
|
||||
"""setup reasonable name mappings for sam names to unix names."""
|
||||
"""setup reasonable name mappings for sam names to unix names.
|
||||
|
||||
:param ldb: SamDB object.
|
||||
:param sid: The domain sid.
|
||||
:param domaindn: The domain DN.
|
||||
:param root: Name of the UNIX root user.
|
||||
:param nobody: Name of the UNIX nobody user.
|
||||
:param nogroup: Name of the unix nobody group.
|
||||
:param users: Name of the unix users group.
|
||||
:param wheel: Name of the wheel group (users that can become root).
|
||||
:param backup: Name of the backup group."""
|
||||
# add some foreign sids if they are not present already
|
||||
ldb.add_foreign(domaindn, "S-1-5-7", "Anonymous")
|
||||
ldb.add_foreign(domaindn, "S-1-1-0", "World")
|
||||
@ -591,7 +619,8 @@ def provision(lp, setup_dir, message, blank, paths, session_info,
|
||||
if nogroup is None:
|
||||
nogroup = findnss(grp.getgrnam, "nogroup", "nobody")[2]
|
||||
if users is None:
|
||||
users = findnss(grp.getgrnam, "users", "guest", "other", "unknown", "usr")[2]
|
||||
users = findnss(grp.getgrnam, "users", "guest", "other", "unknown",
|
||||
"usr")[2]
|
||||
if wheel is None:
|
||||
wheel = findnss(grp.getgrnam, "wheel", "root", "staff", "adm")[2]
|
||||
if backup is None:
|
||||
@ -748,13 +777,32 @@ def provision(lp, setup_dir, message, blank, paths, session_info,
|
||||
return domaindn
|
||||
|
||||
def create_phplpapdadmin_config(path, setup_path, s4_ldapi_path):
|
||||
"""Create a PHP LDAP admin configuration file.
|
||||
|
||||
:param path: Path to write the configuration to.
|
||||
:param setup_path: Function to generate setup paths.
|
||||
:param s4_ldapi_path: Path to Samba 4 LDAPI socket.
|
||||
"""
|
||||
setup_file(setup_path("phpldapadmin-config.php"),
|
||||
path, {"S4_LDAPI_URI": "ldapi://%s" % s4_ldapi_path.replace("/", "%2F")})
|
||||
|
||||
|
||||
def create_zone_file(path, setup_path, samdb, dnsdomain, domaindn,
|
||||
hostip, hostname, dnspass, realm, domainguid, hostguid):
|
||||
"""Write out a DNS zone file, from the info in the current database."""
|
||||
"""Write out a DNS zone file, from the info in the current database.
|
||||
|
||||
:param path: Path of the new file.
|
||||
:param setup_path": Setup path function.
|
||||
:param samdb: SamDB object
|
||||
:param dnsdomain: DNS Domain name
|
||||
:param domaindn: DN of the Domain
|
||||
:param hostip: Local IP
|
||||
:param hostname: Local hostname
|
||||
:param dnspass: Password for DNS
|
||||
:param realm: Realm name
|
||||
:param domainguid: GUID of the domain.
|
||||
:param hostguid: GUID of the host.
|
||||
"""
|
||||
|
||||
setup_file(setup_path("provision.zone"), path, {
|
||||
"DNSPASS_B64": b64encode(dnspass),
|
||||
@ -795,7 +843,14 @@ def provision_ldapbase(setup_dir, message, paths):
|
||||
|
||||
|
||||
def load_schema(setup_path, samdb, schemadn, netbiosname, configdn):
|
||||
"""Load schema."""
|
||||
"""Load schema.
|
||||
|
||||
:param samdb: Load a schema into a SamDB.
|
||||
:param setup_path: Setup path function.
|
||||
:param schemadn: DN of the schema
|
||||
:param netbiosname: NetBIOS name of the host.
|
||||
:param configdn: DN of the configuration
|
||||
"""
|
||||
schema_data = open(setup_path("schema.ldif"), 'r').read()
|
||||
schema_data += open(setup_path("schema_samba4.ldif"), 'r').read()
|
||||
schema_data = substitute_var(schema_data, {"SCHEMADN": schemadn})
|
||||
@ -807,32 +862,3 @@ def load_schema(setup_path, samdb, schemadn, netbiosname, configdn):
|
||||
"DEFAULTSITE": DEFAULTSITE})
|
||||
samdb.attach_schema_from_ldif(head_data, schema_data)
|
||||
|
||||
|
||||
def join_domain(domain, netbios_name, join_type, creds):
|
||||
ctx = NetContext(creds)
|
||||
joindom = object()
|
||||
joindom.domain = domain
|
||||
joindom.join_type = join_type
|
||||
joindom.netbios_name = netbios_name
|
||||
if not ctx.JoinDomain(joindom):
|
||||
raise Exception("Domain Join failed: " + joindom.error_string)
|
||||
|
||||
|
||||
def vampire(domain, session_info, credentials, message):
|
||||
"""Vampire a remote domain.
|
||||
|
||||
Session info and credentials are required for for
|
||||
access to our local database (might be remote ldap)
|
||||
"""
|
||||
ctx = NetContext(credentials)
|
||||
machine_creds = Credentials()
|
||||
machine_creds.set_domain(form.domain)
|
||||
if not machine_creds.set_machine_account():
|
||||
raise Exception("Failed to access domain join information!")
|
||||
vampire_ctx.machine_creds = machine_creds
|
||||
vampire_ctx.session_info = session_info
|
||||
if not ctx.SamSyncLdb(vampire_ctx):
|
||||
raise Exception("Migration of remote domain to Samba failed: %s " % vampire_ctx.error_string)
|
||||
|
||||
|
||||
|
||||
|
@ -25,14 +25,33 @@ REGISTRY_DB_VERSION = 1
|
||||
import os
|
||||
import tdb
|
||||
|
||||
class Registry:
|
||||
"""Simple read-only support for reading the Samba3 registry."""
|
||||
|
||||
class TdbDatabase:
|
||||
"""Simple Samba 3 TDB database reader."""
|
||||
def __init__(self, file):
|
||||
"""Open a file.
|
||||
|
||||
:param file: Path of the file to open.
|
||||
"""
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
self._check_version()
|
||||
|
||||
def _check_version(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""Close resources associated with this object."""
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
class Registry(TdbDatabase):
|
||||
"""Simple read-only support for reading the Samba3 registry.
|
||||
|
||||
:note: This object uses the same syntax for registry key paths as
|
||||
Samba 3. This particular format uses forward slashes for key path
|
||||
separators and abbreviations for the predefined key names.
|
||||
e.g.: HKLM/Software/Bar.
|
||||
"""
|
||||
def __len__(self):
|
||||
"""Return the number of keys."""
|
||||
return len(self.keys())
|
||||
@ -42,6 +61,11 @@ class Registry:
|
||||
return [k.rstrip("\x00") for k in self.tdb.keys() if not k.startswith(REGISTRY_VALUE_PREFIX)]
|
||||
|
||||
def subkeys(self, key):
|
||||
"""Retrieve the subkeys for the specified key.
|
||||
|
||||
:param key: Key path.
|
||||
:return: list with key names
|
||||
"""
|
||||
data = self.tdb.get("%s\x00" % key)
|
||||
if data is None:
|
||||
return []
|
||||
@ -54,7 +78,11 @@ class Registry:
|
||||
return keys
|
||||
|
||||
def values(self, key):
|
||||
"""Return a dictionary with the values set for a specific key."""
|
||||
"""Return a dictionary with the values set for a specific key.
|
||||
|
||||
:param key: Key to retrieve values for.
|
||||
:return: Dictionary with value names as key, tuple with type and
|
||||
data as value."""
|
||||
data = self.tdb.get("%s/%s\x00" % (REGISTRY_VALUE_PREFIX, key))
|
||||
if data is None:
|
||||
return {}
|
||||
@ -77,9 +105,14 @@ class Registry:
|
||||
return ret
|
||||
|
||||
|
||||
class PolicyDatabase:
|
||||
class PolicyDatabase(TdbDatabase):
|
||||
"""Samba 3 Account Policy database reader."""
|
||||
def __init__(self, file):
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
"""Open a policy database
|
||||
|
||||
:param file: Path to the file to open.
|
||||
"""
|
||||
super(PolicyDatabase, self).__init__(file)
|
||||
self.min_password_length = self.tdb.fetch_uint32("min password length\x00")
|
||||
self.password_history = self.tdb.fetch_uint32("password history\x00")
|
||||
self.user_must_logon_to_change_password = self.tdb.fetch_uint32("user must logon to change pasword\x00")
|
||||
@ -93,9 +126,6 @@ class PolicyDatabase:
|
||||
|
||||
# FIXME: Read privileges as well
|
||||
|
||||
def close(self):
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
GROUPDB_DATABASE_VERSION_V1 = 1 # native byte format.
|
||||
GROUPDB_DATABASE_VERSION_V2 = 2 # le format.
|
||||
@ -108,17 +138,27 @@ GROUP_PREFIX = "UNIXGROUP/"
|
||||
# hanging of the member as key.
|
||||
MEMBEROF_PREFIX = "MEMBEROF/"
|
||||
|
||||
class GroupMappingDatabase:
|
||||
def __init__(self, file):
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
class GroupMappingDatabase(TdbDatabase):
|
||||
"""Samba 3 group mapping database reader."""
|
||||
def _check_version(self):
|
||||
assert self.tdb.fetch_int32("INFO/version\x00") in (GROUPDB_DATABASE_VERSION_V1, GROUPDB_DATABASE_VERSION_V2)
|
||||
|
||||
def groupsids(self):
|
||||
"""Retrieve the SIDs for the groups in this database.
|
||||
|
||||
:return: List with sids as strings.
|
||||
"""
|
||||
for k in self.tdb.keys():
|
||||
if k.startswith(GROUP_PREFIX):
|
||||
yield k[len(GROUP_PREFIX):].rstrip("\0")
|
||||
|
||||
def get_group(self, sid):
|
||||
"""Retrieve the group mapping information for a particular group.
|
||||
|
||||
:param sid: SID of the group
|
||||
:return: None if the group can not be found, otherwise
|
||||
a tuple with gid, sid_name_use, the NT name and comment.
|
||||
"""
|
||||
data = self.tdb.get("%s%s\0" % (GROUP_PREFIX, sid))
|
||||
if data is None:
|
||||
return data
|
||||
@ -128,13 +168,11 @@ class GroupMappingDatabase:
|
||||
return (gid, sid_name_use, nt_name, comment)
|
||||
|
||||
def aliases(self):
|
||||
"""Retrieve the aliases in this database."""
|
||||
for k in self.tdb.keys():
|
||||
if k.startswith(MEMBEROF_PREFIX):
|
||||
yield k[len(MEMBEROF_PREFIX):].rstrip("\0")
|
||||
|
||||
def close(self):
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
# High water mark keys
|
||||
IDMAP_HWM_GROUP = "GROUP HWM\0"
|
||||
@ -146,22 +184,29 @@ IDMAP_USER_PREFIX = "UID "
|
||||
# idmap version determines auto-conversion
|
||||
IDMAP_VERSION_V2 = 2
|
||||
|
||||
class IdmapDatabase:
|
||||
def __init__(self, file):
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
class IdmapDatabase(TdbDatabase):
|
||||
"""Samba 3 ID map database reader."""
|
||||
def _check_version(self):
|
||||
assert self.tdb.fetch_int32("IDMAP_VERSION\0") == IDMAP_VERSION_V2
|
||||
|
||||
def uids(self):
|
||||
"""Retrieve a list of all uids in this database."""
|
||||
for k in self.tdb.keys():
|
||||
if k.startswith(IDMAP_USER_PREFIX):
|
||||
yield int(k[len(IDMAP_USER_PREFIX):].rstrip("\0"))
|
||||
|
||||
def gids(self):
|
||||
"""Retrieve a list of all gids in this database."""
|
||||
for k in self.tdb.keys():
|
||||
if k.startswith(IDMAP_GROUP_PREFIX):
|
||||
yield int(k[len(IDMAP_GROUP_PREFIX):].rstrip("\0"))
|
||||
|
||||
def get_user_sid(self, uid):
|
||||
"""Retrieve the SID associated with a particular uid.
|
||||
|
||||
:param uid: UID to retrieve SID for.
|
||||
:return: A SID or None if no mapping was found.
|
||||
"""
|
||||
data = self.tdb.get("%s%d\0" % (IDMAP_USER_PREFIX, uid))
|
||||
if data is None:
|
||||
return data
|
||||
@ -174,19 +219,15 @@ class IdmapDatabase:
|
||||
return data.rstrip("\0")
|
||||
|
||||
def get_user_hwm(self):
|
||||
"""Obtain the user high-water mark."""
|
||||
return self.tdb.fetch_uint32(IDMAP_HWM_USER)
|
||||
|
||||
def get_group_hwm(self):
|
||||
"""Obtain the group high-water mark."""
|
||||
return self.tdb.fetch_uint32(IDMAP_HWM_GROUP)
|
||||
|
||||
def close(self):
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
class SecretsDatabase:
|
||||
def __init__(self, file):
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
|
||||
class SecretsDatabase(TdbDatabase):
|
||||
def get_auth_password(self):
|
||||
return self.tdb.get("SECRETS/AUTH_PASSWORD")
|
||||
|
||||
@ -241,16 +282,12 @@ class SecretsDatabase:
|
||||
def get_sid(self, host):
|
||||
return self.tdb.get("SECRETS/SID/%s" % host.upper())
|
||||
|
||||
def close(self):
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
SHARE_DATABASE_VERSION_V1 = 1
|
||||
SHARE_DATABASE_VERSION_V2 = 2
|
||||
|
||||
class ShareInfoDatabase:
|
||||
def __init__(self, file):
|
||||
self.tdb = tdb.Tdb(file, flags=os.O_RDONLY)
|
||||
class ShareInfoDatabase(TdbDatabase):
|
||||
def _check_version(self):
|
||||
assert self.tdb.fetch_int32("INFO/version\0") in (SHARE_DATABASE_VERSION_V1, SHARE_DATABASE_VERSION_V2)
|
||||
|
||||
def get_secdesc(self, name):
|
||||
@ -258,9 +295,6 @@ class ShareInfoDatabase:
|
||||
# FIXME: Run ndr_pull_security_descriptor
|
||||
return secdesc
|
||||
|
||||
def close(self):
|
||||
self.tdb.close()
|
||||
|
||||
|
||||
class Shares:
|
||||
def __init__(self, lp, shareinfo):
|
||||
|
@ -20,13 +20,20 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Convenience functions for using the SAM."""
|
||||
|
||||
import samba
|
||||
import misc
|
||||
import ldb
|
||||
|
||||
class SamDB(samba.Ldb):
|
||||
"""The SAM database."""
|
||||
def __init__(self, url=None, session_info=None, credentials=None,
|
||||
modules_dir=None, lp=None):
|
||||
"""Open the Sam Database.
|
||||
|
||||
:param url: URL of the database.
|
||||
"""
|
||||
super(SamDB, self).__init__(session_info=session_info, credentials=credentials,
|
||||
modules_dir=modules_dir, lp=lp)
|
||||
assert misc.dsdb_set_global_schema(self) == 0
|
||||
@ -47,7 +54,12 @@ description: %s
|
||||
self.add(msg[1])
|
||||
|
||||
def setup_name_mapping(self, domaindn, sid, unixname):
|
||||
"""Setup a mapping between a sam name and a unix name."""
|
||||
"""Setup a mapping between a sam name and a unix name.
|
||||
|
||||
:param domaindn: DN of the domain.
|
||||
:param sid: SID of the NT-side of the mapping.
|
||||
:param unixname: Unix name to map to.
|
||||
"""
|
||||
res = self.search(ldb.Dn(self, domaindn), ldb.SCOPE_SUBTREE,
|
||||
"objectSid=%s" % sid, ["dn"])
|
||||
assert len(res) == 1, "Failed to find record for objectSid %s" % sid
|
||||
@ -61,7 +73,7 @@ unixName: %s
|
||||
self.modify(self.parse_ldif(mod).next()[1])
|
||||
|
||||
def enable_account(self, user_dn):
|
||||
"""enable the account.
|
||||
"""Enable an account.
|
||||
|
||||
:param user_dn: Dn of the account to enable.
|
||||
"""
|
||||
@ -75,10 +87,15 @@ changetype: modify
|
||||
replace: userAccountControl
|
||||
userAccountControl: %u
|
||||
""" % (user_dn, userAccountControl)
|
||||
self.modify(mod)
|
||||
self.modify_ldif(mod)
|
||||
|
||||
def newuser(self, username, unixname, password, message):
|
||||
"""add a new user record"""
|
||||
def newuser(self, username, unixname, password):
|
||||
"""add a new user record.
|
||||
|
||||
:param username: Name of the new user.
|
||||
:param unixname: Name of the unix user to map to.
|
||||
:param password: Password for the new user
|
||||
"""
|
||||
# connect to the sam
|
||||
self.transaction_start()
|
||||
|
||||
@ -97,13 +114,13 @@ userAccountControl: %u
|
||||
# the new user record. note the reliance on the samdb module to fill
|
||||
# in a sid, guid etc
|
||||
#
|
||||
ldif = """
|
||||
dn: %s
|
||||
sAMAccountName: %s
|
||||
unixName: %s
|
||||
sambaPassword: %s
|
||||
objectClass: user
|
||||
""" % (user_dn, username, unixname, password)
|
||||
# now the real work
|
||||
self.add({"dn": user_dn,
|
||||
"sAMAccountName": username,
|
||||
"unixName": unixname,
|
||||
"sambaPassword": password,
|
||||
"objectClass": "user"})
|
||||
|
||||
# add the user to the users group as well
|
||||
modgroup = """
|
||||
dn: %s
|
||||
@ -113,11 +130,6 @@ member: %s
|
||||
""" % (dom_users, user_dn)
|
||||
|
||||
|
||||
# now the real work
|
||||
message("Adding user %s" % user_dn)
|
||||
self.add(ldif)
|
||||
|
||||
message("Modifying group %s" % dom_users)
|
||||
self.modify(modgroup)
|
||||
|
||||
# modify the userAccountControl to remove the disabled bit
|
||||
@ -125,6 +137,10 @@ member: %s
|
||||
self.transaction_commit()
|
||||
|
||||
def set_domain_sid(self, sid):
|
||||
"""Change the domain SID used by this SamDB.
|
||||
|
||||
:param sid: The new domain sid to use.
|
||||
"""
|
||||
misc.samdb_set_domain_sid(self, sid)
|
||||
|
||||
def attach_schema_from_ldif(self, pf, df):
|
||||
|
@ -17,6 +17,8 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Samba Python tests."""
|
||||
|
||||
import os
|
||||
import ldb
|
||||
import samba
|
||||
@ -24,11 +26,13 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
class LdbTestCase(unittest.TestCase):
|
||||
"""Trivial test case for running tests against a LDB."""
|
||||
def setUp(self):
|
||||
self.filename = os.tempnam()
|
||||
self.ldb = samba.Ldb(self.filename)
|
||||
|
||||
def set_modules(self, modules=[]):
|
||||
"""Change the modules for this Ldb."""
|
||||
m = ldb.Message()
|
||||
m.dn = ldb.Dn(self.ldb, "@MODULES")
|
||||
m["@LIST"] = ",".join(modules)
|
||||
|
Reference in New Issue
Block a user