refactor(usermodel): turn validation methods into assertions
This commit is contained in:
parent
7b1196f09d
commit
001e80977e
@ -4,3 +4,27 @@ class MissingConfigKeyError(RuntimeError):
|
|||||||
"""Raised when an expected appconfig key-value pair is not found."""
|
"""Raised when an expected appconfig key-value pair is not found."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidStringFormatException(Exception):
|
||||||
|
"""Exception raised when an invalid string format is encountered."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidImageException(Exception):
|
||||||
|
"""Exception raised when an invalid image is encountered."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeNotFoundException(Exception):
|
||||||
|
"""Exception raised when an entry's is unexpectedly not set."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAttributeException(Exception):
|
||||||
|
"""Exception raised when an entry's is unexpectedly not set."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
@ -17,12 +17,7 @@ from ldap3 import Connection, Server, ALL, Reader, Writer, ObjectDef, MODIFY_REP
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from lumi2.usermodel import User, Group
|
from lumi2.usermodel import User, Group
|
||||||
from lumi2.exceptions import MissingConfigKeyError
|
from lumi2.exceptions import *
|
||||||
|
|
||||||
|
|
||||||
class InvalidStringFormatException(Exception):
|
|
||||||
"""Exception raised when an invalid string format is encountered."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidConnectionException(Exception):
|
class InvalidConnectionException(Exception):
|
||||||
@ -40,15 +35,6 @@ class EntryNotFoundException(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AttributeNotFoundException(Exception):
|
|
||||||
"""Exception raised when an entry's is unexpectedly not set."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
class InvalidAttributeException(Exception):
|
|
||||||
"""Exception raised when an entry's is unexpectedly not set."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_is_valid_base_dn(input_str) -> None:
|
def _assert_is_valid_base_dn(input_str) -> None:
|
||||||
"""Checks whether the input string is a valid LDAP base DN.
|
"""Checks whether the input string is a valid LDAP base DN.
|
||||||
|
|
||||||
|
@ -6,9 +6,11 @@ import hashlib
|
|||||||
from binascii import Error as Base64DecodeError
|
from binascii import Error as Base64DecodeError
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image
|
from PIL.Image import JpegImageFile, Image
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
from lumi2.exceptions import InvalidStringFormatException, InvalidImageException
|
||||||
|
|
||||||
class User:
|
class User:
|
||||||
"""Class model for a user.
|
"""Class model for a user.
|
||||||
|
|
||||||
@ -32,7 +34,7 @@ class User:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_username(input_str: str) -> bool:
|
def assert_is_valid_username(input_str: str) -> None:
|
||||||
"""Checks whether the input string is a valid username.
|
"""Checks whether the input string is a valid username.
|
||||||
|
|
||||||
Valid usernames can contain only uppercase/lowercase latin characters,
|
Valid usernames can contain only uppercase/lowercase latin characters,
|
||||||
@ -45,38 +47,74 @@ class User:
|
|||||||
input_str : str
|
input_str : str
|
||||||
The string whose validity as a username to check.
|
The string whose validity as a username to check.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_str is a valid username and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_str is not of type string.
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid username.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_str, str):
|
if not isinstance(input_str, str):
|
||||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
if not len(input_str):
|
if not len(input_str):
|
||||||
return False
|
raise InvalidStringFormatException("Username must contain at least one character.")
|
||||||
if len(input_str) > 64:
|
if len(input_str) > 64:
|
||||||
return False
|
raise InvalidStringFormatException("Username must not exceed 64 characters in length.")
|
||||||
|
|
||||||
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
||||||
return False
|
raise InvalidStringFormatException("Username must start with a letter.")
|
||||||
|
|
||||||
valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
|
valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
|
||||||
for char in input_str:
|
for char in input_str:
|
||||||
if char not in valid_chars:
|
if char not in valid_chars:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
f"Invalid character in username: '{char}'."
|
||||||
return True
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_password_hash(input_str: str) -> bool:
|
def assert_is_valid_password(input_str: str) -> None:
|
||||||
|
"""Checks whether the input string is a valid password.
|
||||||
|
|
||||||
|
A valid password may not contain any whitespace.
|
||||||
|
They must be at least 8 characters long and at 64 characters at most.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_str : str
|
||||||
|
The string whose validity as a password to check.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(input_str, str):
|
||||||
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
|
if len(input_str) < 8:
|
||||||
|
raise InvalidStringFormatException(
|
||||||
|
"Password must be at least 8 characters in length."
|
||||||
|
)
|
||||||
|
if len(input_str) > 64:
|
||||||
|
raise InvalidStringFormatException(
|
||||||
|
"Password may not be longer than 8 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
for char in input_str:
|
||||||
|
if char in whitespace:
|
||||||
|
raise InvalidStringFormatException(
|
||||||
|
"Password my not contain any whitespace."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def assert_is_valid_password_hash(input_str: str) -> None:
|
||||||
"""Checks whether the input string is a valid password hash.
|
"""Checks whether the input string is a valid password hash.
|
||||||
|
|
||||||
A valid password hash is a non-empty string containing base64-decodeable
|
A valid password hash is a non-empty string containing base64-decodeable
|
||||||
@ -87,136 +125,143 @@ class User:
|
|||||||
input_str : str
|
input_str : str
|
||||||
The string whose validity as a password hash to check.
|
The string whose validity as a password hash to check.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_str is a valid password hash and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_str is not of type string.
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid password hash.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_str, str):
|
if not isinstance(input_str, str):
|
||||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
if not len(input_str):
|
if not len(input_str):
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Password hash must be at least one character in length."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
b64decode(input_str, validate=True)
|
b64decode(input_str, validate=True)
|
||||||
return True
|
except Base64DecodeError as e:
|
||||||
except Base64DecodeError:
|
raise InvalidStringFormatException from e
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_email(input_str: str) -> bool:
|
def assert_is_valid_email(input_str: str) -> None:
|
||||||
"""Checks whether the input string is a valid email address.
|
"""Checks whether the input string is a valid email address.
|
||||||
|
|
||||||
WARNING: this validation is very rudimentary. Proper validation requires
|
Very rudimentary check, proper validation would require
|
||||||
a validation email to be sent and confirmed by the user.
|
a validation email to be sent and confirmed by the user.
|
||||||
A valid email address contains no whitespace, at least one '@' character,
|
A valid email address contains no whitespace, at least one '@' character,
|
||||||
and a '.' character somewhere after the '@'.
|
and a '.' character somewhere after the '@'.
|
||||||
|
The maximum length for a valid email address is 64 characters.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
input_str : str
|
input_str : str
|
||||||
The string whose validity as an email address to check.
|
The string whose validity as an email address to check.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_str is a valid email address and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_str is not of type string.
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid email address.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_str, str):
|
if not isinstance(input_str, str):
|
||||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
if '@' not in input_str:
|
if '@' not in input_str:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid email address: no '@' found."
|
||||||
|
)
|
||||||
if '.' not in input_str:
|
if '.' not in input_str:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid email address: no top-level-domain found."
|
||||||
|
)
|
||||||
if '.' not in input_str.split('@')[1]:
|
if '.' not in input_str.split('@')[1]:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid email address: no top-level-domain found."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(input_str) > 64:
|
||||||
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid email address: may not be longer than 64 characters."
|
||||||
|
)
|
||||||
|
|
||||||
for char in input_str:
|
for char in input_str:
|
||||||
if char in whitespace:
|
if char in whitespace:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid email address: no whitespace permitted."
|
||||||
return True
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_person_name(input_str: str) -> bool:
|
def assert_is_valid_name(input_str: str) -> None:
|
||||||
"""Checks whether the input string is valid as a first/last/display name.
|
"""Checks whether the input string is valid as a first/last/display name.
|
||||||
|
|
||||||
Valid names cannot contain whitespace and must be at least one character
|
Valid names cannot contain whitespace and must be at least one character
|
||||||
long.
|
long, and 64 characters at most.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
input_str : str
|
input_str : str
|
||||||
The string whose validity as a first-/last-/displayname to check.
|
The string whose validity as a first-/last-/displayname to check.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_str is a valid name and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_str is not of type string.
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid person's name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_str, str):
|
if not isinstance(input_str, str):
|
||||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
if not len(input_str):
|
if not len(input_str):
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid name: must be at least 1 character in length."
|
||||||
|
)
|
||||||
|
if len(input_str) > 64:
|
||||||
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid name: may not be longer than 64 characters."
|
||||||
|
)
|
||||||
|
|
||||||
for char in input_str:
|
for char in input_str:
|
||||||
if char in whitespace:
|
if char in whitespace:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid name: may not contain whitespace."
|
||||||
return True
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_picture(input_image: Image.Image) -> bool:
|
def assert_is_valid_picture(input_image: JpegImageFile) -> None:
|
||||||
"""Checks whether the input image is a valid Image object.
|
"""Checks whether the input image is a valid Image object.
|
||||||
|
|
||||||
TBD - unsure which formats and filesizes to allow here.
|
Valid images must be of type JpegImageFile.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
input_image : PIL.Image
|
input_image : PIL.Image.JpegImageFile
|
||||||
The Image whose validity to check.
|
The Image object whose validity to check.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_image is a valid Image and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_image is not of type PIL.Image.
|
If input_image is not of type PIL.Image.
|
||||||
|
InvalidImageException
|
||||||
|
If the input image's type is not PIL.Image.JpegImageFile.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_image, Image.Image):
|
if not isinstance(input_image, Image):
|
||||||
raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
|
raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
|
||||||
|
if not isinstance(input_image, JpegImageFile):
|
||||||
# TODO implement some integrity checks
|
raise InvalidImageException(
|
||||||
# TODO implement some filesize restrictions
|
"User picture must be in JPEG format."
|
||||||
return True
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -271,38 +316,54 @@ class User:
|
|||||||
first_name: str, last_name: str, display_name = None,
|
first_name: str, last_name: str, display_name = None,
|
||||||
picture = None,
|
picture = None,
|
||||||
):
|
):
|
||||||
|
"""Constructor for User objects.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
username : str
|
||||||
|
The username, valid as described by User.assert_is_valid_username().
|
||||||
|
password_hash : str
|
||||||
|
The user's base64-encoded SHA512-hashed password (without the
|
||||||
|
'{SHA512}'-prefix expected by LDAP).
|
||||||
|
Must be valid as described by User.assert_is_valid_password_hash().
|
||||||
|
email : str
|
||||||
|
The User's email address.
|
||||||
|
Must be valid as described by User.assert_is_valid_email().
|
||||||
|
first_name : str
|
||||||
|
The User's first name.
|
||||||
|
Must be valid as described by User.assert_is_valid_name().
|
||||||
|
last_name : str
|
||||||
|
The User's last name.
|
||||||
|
Must be valid as described by User.assert_is_valid_name().
|
||||||
|
display_name : str = first_name
|
||||||
|
The User's nickname. If unspecified, gets set to the User's first
|
||||||
|
name.
|
||||||
|
Must be valid as described by User.assert_is_valid_name().
|
||||||
|
picture : PIL.Image.JpegImageFile
|
||||||
|
The User's JPEG picture. If unspecified, a default user picture is
|
||||||
|
used. Must be valid as described by User.asser_is_valid_picture().
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
User.assert_is_valid_username(username)
|
||||||
|
User.assert_is_valid_password_hash(password_hash)
|
||||||
|
User.assert_is_valid_email(email)
|
||||||
|
User.assert_is_valid_name(first_name)
|
||||||
|
User.assert_is_valid_name(last_name)
|
||||||
|
if display_name is not None:
|
||||||
|
User.assert_is_valid_name(display_name)
|
||||||
|
if picture is not None:
|
||||||
|
User.assert_is_valid_picture(picture)
|
||||||
|
except (InvalidStringFormatException, InvalidImageException) as e:
|
||||||
|
raise ValueError from e
|
||||||
|
|
||||||
if not User.is_valid_username(username):
|
|
||||||
raise ValueError(f"Not a valid username: '{username}'.")
|
|
||||||
self.username = username
|
self.username = username
|
||||||
|
|
||||||
if not User.is_valid_password_hash(password_hash):
|
|
||||||
raise ValueError(f"Not a valid password hash: '{password_hash}'.")
|
|
||||||
self.password_hash = password_hash
|
self.password_hash = password_hash
|
||||||
|
|
||||||
if not User.is_valid_email(email):
|
|
||||||
raise ValueError(f"Not a valid email address: '{email}'.")
|
|
||||||
self.email = email
|
self.email = email
|
||||||
|
|
||||||
for name in [first_name, last_name]:
|
|
||||||
if not User.is_valid_person_name(name):
|
|
||||||
raise ValueError(f"Not a valid name: '{name}'.")
|
|
||||||
self.first_name = first_name
|
self.first_name = first_name
|
||||||
self.last_name = last_name
|
self.last_name = last_name
|
||||||
|
self.display_name = display_name if display_name is not None else first_name
|
||||||
if display_name is not None:
|
self.picture = picture if picture is not None else User._get_default_picture()
|
||||||
if not User.is_valid_person_name(display_name):
|
|
||||||
raise ValueError(f"Not a valid display name: '{display_name}'.")
|
|
||||||
self.display_name = display_name
|
|
||||||
else:
|
|
||||||
self.display_name = first_name
|
|
||||||
|
|
||||||
if picture is not None:
|
|
||||||
if not User.is_valid_picture(picture):
|
|
||||||
raise ValueError(f"Not a valid image: '{picture}'.")
|
|
||||||
self.picture = picture
|
|
||||||
else:
|
|
||||||
self.picture = User._get_default_picture()
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_static_images(self, force=False) -> None:
|
def _generate_static_images(self, force=False) -> None:
|
||||||
@ -385,48 +446,61 @@ class Group:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid_groupname(input_str: str) -> bool:
|
def assert_is_valid_groupname(input_str: str) -> None:
|
||||||
"""Checks whether the input string is a valid group name.
|
"""Checks whether the input string is a valid group name.
|
||||||
|
|
||||||
A valid group name consists of only alphanumeric characters, starts with
|
A valid group name consists of only alphanumeric characters, starts with
|
||||||
a latin character and has minimum length 1.
|
a latin character, has minimum length 1 and maximim length 64.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
input_str : str
|
input_str : str
|
||||||
The string to check for validity as a group name.
|
The string to check for validity as a group name.
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if input_str is a valid group name, and False otherwise.
|
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
TypeError
|
TypeError
|
||||||
If input_str is not of type string.
|
If input_str is not of type string.
|
||||||
|
InvalidStringFormatException
|
||||||
|
If input_str is not a valid group name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(input_str, str):
|
if not isinstance(input_str, str):
|
||||||
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
|
||||||
|
|
||||||
if not len(input_str):
|
if not len(input_str):
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid group name: must contain at least one character."
|
||||||
|
)
|
||||||
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
"Invalid group name: must start with a letter."
|
||||||
|
)
|
||||||
for char in input_str:
|
for char in input_str:
|
||||||
if not char in ascii_uppercase + ascii_lowercase + digits:
|
if not char in ascii_uppercase + ascii_lowercase + digits:
|
||||||
return False
|
raise InvalidStringFormatException(
|
||||||
|
f"Invalid character in group name: '{char}'."
|
||||||
return True
|
)
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, groupname: str, members: set[User]):
|
def __init__(self, groupname: str, members: set[User]):
|
||||||
|
"""Constructor for Group objects.
|
||||||
|
|
||||||
if not Group.is_valid_groupname(groupname):
|
Groups must always have at least one member (an LDAP limitation).
|
||||||
raise ValueError("Not a valid group name: '{groupname}'.")
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
groupname : str
|
||||||
|
The Group's name.
|
||||||
|
Must be valid as described by Group.assert_is_valid_groupname().
|
||||||
|
members : set[User]
|
||||||
|
A set conaining Users who are members of this Group.
|
||||||
|
The set must contain at least one member.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
Group.assert_is_valid_groupname(groupname)
|
||||||
|
except InvalidStringFormatException as e:
|
||||||
|
raise ValueError from e
|
||||||
self.groupname = groupname
|
self.groupname = groupname
|
||||||
|
|
||||||
if not isinstance(members, set):
|
if not isinstance(members, set):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user