mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
form: rework pre + post options validators
This commit is contained in:
parent
3943774811
commit
ec5da99a79
1 changed files with 155 additions and 122 deletions
|
@ -60,7 +60,7 @@ from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||||
from yunohost.utils.i18n import _value_for_locale
|
from yunohost.utils.i18n import _value_for_locale
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import ModelField, FieldInfo
|
||||||
|
|
||||||
logger = getLogger("yunohost.form")
|
logger = getLogger("yunohost.form")
|
||||||
|
|
||||||
|
@ -397,6 +397,7 @@ class BaseInputOption(BaseOption):
|
||||||
optional: bool = False # FIXME keep required as default?
|
optional: bool = False # FIXME keep required as default?
|
||||||
default: Any = None
|
default: Any = None
|
||||||
_annotation = Any
|
_annotation = Any
|
||||||
|
_none_as_empty_str: bool = True
|
||||||
|
|
||||||
@validator("default", pre=True)
|
@validator("default", pre=True)
|
||||||
def check_empty_default(value: Any) -> Any:
|
def check_empty_default(value: Any) -> Any:
|
||||||
|
@ -405,7 +406,9 @@ class BaseInputOption(BaseOption):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value: Any, option={}) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -474,31 +477,30 @@ class BaseInputOption(BaseOption):
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
if self.value in [None, ""] and not self.optional:
|
def _value_pre_validator(cls, value: Any, field: "ModelField") -> Any:
|
||||||
raise YunohostValidationError("app_argument_required", name=self.id)
|
if value == "":
|
||||||
|
return None
|
||||||
|
|
||||||
# we have an answer, do some post checks
|
return value
|
||||||
if self.value not in [None, ""]:
|
|
||||||
if self.pattern and not re.match(self.pattern["regexp"], str(self.value)):
|
|
||||||
raise YunohostValidationError(
|
|
||||||
self.pattern["error"],
|
|
||||||
name=self.id,
|
|
||||||
value=self.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _value_post_validator(self):
|
@classmethod
|
||||||
if not self.redact:
|
def _value_post_validator(cls, value: Any, field: "ModelField") -> Any:
|
||||||
return self.value
|
extras = field.field_info.extra
|
||||||
|
|
||||||
|
if value is None and extras["none_as_empty_str"]:
|
||||||
|
value = ""
|
||||||
|
|
||||||
|
if not extras.get("redact"):
|
||||||
|
return value
|
||||||
|
|
||||||
# Tell the operation_logger to redact all password-type / secret args
|
# Tell the operation_logger to redact all password-type / secret args
|
||||||
# Also redact the % escaped version of the password that might appear in
|
# Also redact the % escaped version of the password that might appear in
|
||||||
# the 'args' section of metadata (relevant for password with non-alphanumeric char)
|
# the 'args' section of metadata (relevant for password with non-alphanumeric char)
|
||||||
data_to_redact = []
|
data_to_redact = []
|
||||||
if self.value and isinstance(self.value, str):
|
if value and isinstance(value, str):
|
||||||
data_to_redact.append(self.value)
|
data_to_redact.append(value)
|
||||||
if self.current_value and isinstance(self.current_value, str):
|
|
||||||
data_to_redact.append(self.current_value)
|
|
||||||
data_to_redact += [
|
data_to_redact += [
|
||||||
urllib.parse.quote(data)
|
urllib.parse.quote(data)
|
||||||
for data in data_to_redact
|
for data in data_to_redact
|
||||||
|
@ -508,7 +510,7 @@ class BaseInputOption(BaseOption):
|
||||||
for operation_logger in OperationLogger._instances:
|
for operation_logger in OperationLogger._instances:
|
||||||
operation_logger.data_to_redact.extend(data_to_redact)
|
operation_logger.data_to_redact.extend(data_to_redact)
|
||||||
|
|
||||||
return self.value
|
return value
|
||||||
|
|
||||||
|
|
||||||
# ─ STRINGS ───────────────────────────────────────────────
|
# ─ STRINGS ───────────────────────────────────────────────
|
||||||
|
@ -561,19 +563,25 @@ class PasswordOption(BaseInputOption):
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
super()._value_pre_validator()
|
def _value_pre_validator(
|
||||||
|
cls, value: Union[str, None], field: "ModelField"
|
||||||
|
) -> Union[str, None]:
|
||||||
|
value = super()._value_pre_validator(value, field)
|
||||||
|
|
||||||
if self.value not in [None, ""]:
|
if value is not None and value != "":
|
||||||
if any(char in self.value for char in self._forbidden_chars):
|
forbidden_chars: str = field.field_info.extra["forbidden_chars"]
|
||||||
|
if any(char in value for char in forbidden_chars):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"pattern_password_app", forbidden_chars=self._forbidden_chars
|
"pattern_password_app", forbidden_chars=forbidden_chars
|
||||||
)
|
)
|
||||||
|
|
||||||
# If it's an optional argument the value should be empty or strong enough
|
# If it's an optional argument the value should be empty or strong enough
|
||||||
from yunohost.utils.password import assert_password_is_strong_enough
|
from yunohost.utils.password import assert_password_is_strong_enough
|
||||||
|
|
||||||
assert_password_is_strong_enough("user", self.value)
|
assert_password_is_strong_enough("user", value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ColorOption(BaseInputOption):
|
class ColorOption(BaseInputOption):
|
||||||
|
@ -581,6 +589,29 @@ class ColorOption(BaseInputOption):
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
_annotation = Color
|
_annotation = Color
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def humanize(value: Union[Color, str, None], option={}) -> str:
|
||||||
|
if isinstance(value, Color):
|
||||||
|
value.as_named(fallback=True)
|
||||||
|
|
||||||
|
return super(ColorOption, ColorOption).humanize(value, option)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize(value: Union[Color, str, None], option={}) -> str:
|
||||||
|
if isinstance(value, Color):
|
||||||
|
return value.as_hex()
|
||||||
|
|
||||||
|
return super(ColorOption, ColorOption).normalize(value, option)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _value_post_validator(
|
||||||
|
cls, value: Union[Color, None], field: "ModelField"
|
||||||
|
) -> Union[str, None]:
|
||||||
|
if isinstance(value, Color):
|
||||||
|
return value.as_hex()
|
||||||
|
|
||||||
|
return super()._value_post_validator(value, field)
|
||||||
|
|
||||||
|
|
||||||
# ─ NUMERIC ───────────────────────────────────────────────
|
# ─ NUMERIC ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -623,24 +654,16 @@ class NumberOption(BaseInputOption):
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
super()._value_pre_validator()
|
def _value_pre_validator(
|
||||||
if self.value in [None, ""]:
|
cls, value: Union[int, None], field: "ModelField"
|
||||||
return
|
) -> Union[int, None]:
|
||||||
|
value = super()._value_pre_validator(value, field)
|
||||||
|
|
||||||
if self.min is not None and int(self.value) < self.min:
|
if value is None:
|
||||||
raise YunohostValidationError(
|
return None
|
||||||
"app_argument_invalid",
|
|
||||||
name=self.id,
|
|
||||||
error=m18n.n("invalid_number_min", min=self.min),
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.max is not None and int(self.value) > self.max:
|
return value
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_invalid",
|
|
||||||
name=self.id,
|
|
||||||
error=m18n.n("invalid_number_max", max=self.max),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─ BOOLEAN ───────────────────────────────────────────────
|
# ─ BOOLEAN ───────────────────────────────────────────────
|
||||||
|
@ -654,6 +677,7 @@ class BooleanOption(BaseInputOption):
|
||||||
_annotation = Union[bool, int, str]
|
_annotation = Union[bool, int, str]
|
||||||
_yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"}
|
_yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"}
|
||||||
_no_answers: set[str] = {"0", "no", "n", "false", "f", "off"}
|
_no_answers: set[str] = {"0", "no", "n", "false", "f", "off"}
|
||||||
|
_none_as_empty_str = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
|
@ -737,6 +761,15 @@ class BooleanOption(BaseInputOption):
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _value_post_validator(
|
||||||
|
cls, value: Union[bool, None], field: "ModelField"
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return field.field_info.extra["parse"][value]
|
||||||
|
|
||||||
|
return super()._value_post_validator(value, field)
|
||||||
|
|
||||||
|
|
||||||
# ─ TIME ──────────────────────────────────────────────────
|
# ─ TIME ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -746,14 +779,14 @@ class DateOption(BaseInputOption):
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
_annotation = datetime.date
|
_annotation = datetime.date
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
super()._value_pre_validator()
|
def _value_post_validator(
|
||||||
|
cls, value: Union[datetime.date, None], field: "ModelField"
|
||||||
|
) -> Union[str, None]:
|
||||||
|
if isinstance(value, datetime.date):
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
if self.value not in [None, ""]:
|
return super()._value_post_validator(value, field)
|
||||||
try:
|
|
||||||
datetime.strptime(self.value, "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise YunohostValidationError("config_validate_date")
|
|
||||||
|
|
||||||
|
|
||||||
class TimeOption(BaseInputOption):
|
class TimeOption(BaseInputOption):
|
||||||
|
@ -761,6 +794,16 @@ class TimeOption(BaseInputOption):
|
||||||
default: Union[str, int, None]
|
default: Union[str, int, None]
|
||||||
_annotation = datetime.time
|
_annotation = datetime.time
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _value_post_validator(
|
||||||
|
cls, value: Union[datetime.date, None], field: "ModelField"
|
||||||
|
) -> Union[str, None]:
|
||||||
|
if isinstance(value, datetime.time):
|
||||||
|
# FIXME could use `value.isoformat()` to get `%H:%M:%S`
|
||||||
|
return value.strftime("%H:%M")
|
||||||
|
|
||||||
|
return super()._value_post_validator(value, field)
|
||||||
|
|
||||||
|
|
||||||
# ─ LOCATIONS ─────────────────────────────────────────────
|
# ─ LOCATIONS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -780,6 +823,9 @@ class WebPathOption(BaseInputOption):
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
option = option.dict() if isinstance(option, BaseOption) else option
|
option = option.dict() if isinstance(option, BaseOption) else option
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
value = ""
|
||||||
|
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid",
|
"app_argument_invalid",
|
||||||
|
@ -828,52 +874,40 @@ class FileOption(BaseInputOption):
|
||||||
if os.path.exists(upload_dir):
|
if os.path.exists(upload_dir):
|
||||||
shutil.rmtree(upload_dir)
|
shutil.rmtree(upload_dir)
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
if self.value is None:
|
def _value_post_validator(cls, value: Any, field: "ModelField") -> Any:
|
||||||
self.value = self.current_value
|
|
||||||
|
|
||||||
super()._value_pre_validator()
|
|
||||||
|
|
||||||
# Validation should have already failed if required
|
|
||||||
if self.value in [None, ""]:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
if Moulinette.interface.type != "api":
|
|
||||||
if not os.path.exists(str(self.value)) or not os.path.isfile(
|
|
||||||
str(self.value)
|
|
||||||
):
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_invalid",
|
|
||||||
name=self.id,
|
|
||||||
error=m18n.n("file_does_not_exist", path=str(self.value)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _value_post_validator(self):
|
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
|
|
||||||
if not self.value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def is_file_path(s):
|
||||||
|
return (
|
||||||
|
isinstance(s, str)
|
||||||
|
and s.startswith("/")
|
||||||
|
and os.path.exists(s)
|
||||||
|
and os.path.isfile(s)
|
||||||
|
)
|
||||||
|
|
||||||
|
file_exists = is_file_path(value)
|
||||||
|
if Moulinette.interface.type != "api" and not file_exists:
|
||||||
|
# FIXME error
|
||||||
|
raise YunohostValidationError("File doesn't exists", raw_msg=True)
|
||||||
|
elif file_exists:
|
||||||
|
content = read_file(str(value), file_mode="rb")
|
||||||
|
else:
|
||||||
|
content = b64decode(value)
|
||||||
|
|
||||||
upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
|
upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
|
||||||
_, file_path = tempfile.mkstemp(dir=upload_dir)
|
_, file_path = tempfile.mkstemp(dir=upload_dir)
|
||||||
|
|
||||||
FileOption._upload_dirs.add(upload_dir)
|
FileOption._upload_dirs.add(upload_dir)
|
||||||
|
|
||||||
logger.debug(f"Saving file {self.id} for file question into {file_path}")
|
logger.debug(f"Saving file {field.name} for file question into {file_path}")
|
||||||
|
|
||||||
def is_file_path(s):
|
|
||||||
return isinstance(s, str) and s.startswith("/") and os.path.exists(s)
|
|
||||||
|
|
||||||
if Moulinette.interface.type != "api" or is_file_path(self.value):
|
|
||||||
content = read_file(str(self.value), file_mode="rb")
|
|
||||||
else:
|
|
||||||
content = b64decode(self.value)
|
|
||||||
|
|
||||||
write_to_file(file_path, content, file_mode="wb")
|
write_to_file(file_path, content, file_mode="wb")
|
||||||
|
|
||||||
self.value = file_path
|
return file_path
|
||||||
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
# ─ CHOICES ───────────────────────────────────────────────
|
# ─ CHOICES ───────────────────────────────────────────────
|
||||||
|
@ -895,6 +929,13 @@ class BaseChoicesOption(BaseInputOption):
|
||||||
# FIXME probably forbid choices to be None?
|
# FIXME probably forbid choices to be None?
|
||||||
choices: Union[dict[str, Any], list[Any], None]
|
choices: Union[dict[str, Any], list[Any], None]
|
||||||
|
|
||||||
|
@validator("choices", pre=True)
|
||||||
|
def parse_comalist_choices(value: Any) -> Union[dict[str, Any], list[Any], None]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
values = [value.strip() for value in value.split(",")]
|
||||||
|
return [value for value in values if value]
|
||||||
|
return value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _dynamic_annotation(self) -> Union[object, Type[str]]:
|
def _dynamic_annotation(self) -> Union[object, Type[str]]:
|
||||||
if self.choices is not None:
|
if self.choices is not None:
|
||||||
|
@ -937,19 +978,6 @@ class BaseChoicesOption(BaseInputOption):
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
|
||||||
super()._value_pre_validator()
|
|
||||||
|
|
||||||
# we have an answer, do some post checks
|
|
||||||
if self.value not in [None, ""]:
|
|
||||||
if self.choices and self.value not in self.choices:
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_choice_invalid",
|
|
||||||
name=self.id,
|
|
||||||
value=self.value,
|
|
||||||
choices=", ".join(str(choice) for choice in self.choices),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SelectOption(BaseChoicesOption):
|
class SelectOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.select] = OptionType.select
|
type: Literal[OptionType.select] = OptionType.select
|
||||||
|
@ -969,6 +997,8 @@ class TagsOption(BaseChoicesOption):
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ",".join(str(v) for v in value)
|
return ",".join(str(v) for v in value)
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -976,7 +1006,9 @@ class TagsOption(BaseChoicesOption):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ",".join(str(v) for v in value)
|
return ",".join(str(v) for v in value)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = value.strip()
|
value = value.strip().strip(",")
|
||||||
|
if value is None or value == "":
|
||||||
|
return ""
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -999,36 +1031,37 @@ class TagsOption(BaseChoicesOption):
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
@classmethod
|
||||||
values = self.value
|
def _value_pre_validator(
|
||||||
if isinstance(values, str):
|
cls, value: Union[list, str, None], field: "ModelField"
|
||||||
values = values.split(",")
|
) -> Union[str, None]:
|
||||||
elif values is None:
|
if value is None or value == "":
|
||||||
values = []
|
return None
|
||||||
|
|
||||||
if not isinstance(values, list):
|
if not isinstance(value, (list, str, type(None))):
|
||||||
if self.choices:
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_choice_invalid",
|
|
||||||
name=self.id,
|
|
||||||
value=self.value,
|
|
||||||
choices=", ".join(str(choice) for choice in self.choices),
|
|
||||||
)
|
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid",
|
"app_argument_invalid",
|
||||||
name=self.id,
|
name=field.name,
|
||||||
error=f"'{str(self.value)}' is not a list",
|
error=f"'{str(value)}' is not a list",
|
||||||
)
|
)
|
||||||
|
|
||||||
for value in values:
|
if isinstance(value, str):
|
||||||
self.value = value
|
value = [v.strip() for v in value.split(",")]
|
||||||
super()._value_pre_validator()
|
value = [v for v in value if v]
|
||||||
self.value = values
|
|
||||||
|
|
||||||
def _value_post_validator(self):
|
if isinstance(value, list):
|
||||||
if isinstance(self.value, list):
|
choices = field.field_info.extra.get("choices")
|
||||||
self.value = ",".join(self.value)
|
if choices:
|
||||||
return super()._value_post_validator()
|
if not all(v in choices for v in value):
|
||||||
|
raise YunohostValidationError(
|
||||||
|
"app_argument_choice_invalid",
|
||||||
|
name=field.name,
|
||||||
|
value=value,
|
||||||
|
choices=", ".join(str(choice) for choice in choices),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ",".join(str(v) for v in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# ─ ENTITIES ──────────────────────────────────────────────
|
# ─ ENTITIES ──────────────────────────────────────────────
|
||||||
|
|
Loading…
Add table
Reference in a new issue