mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
form: move option asking+prompt in external function
This commit is contained in:
parent
fe5c73b4ed
commit
91497afbfe
2 changed files with 142 additions and 136 deletions
|
@ -445,7 +445,7 @@ class BaseTest:
|
||||||
assert option.name == id_
|
assert option.name == id_
|
||||||
assert option.ask == {"en": id_}
|
assert option.ask == {"en": id_}
|
||||||
assert option.readonly is (True if is_special_readonly_option else False)
|
assert option.readonly is (True if is_special_readonly_option else False)
|
||||||
assert option.visible is None
|
assert option.visible is True
|
||||||
# assert option.bind is None
|
# assert option.bind is None
|
||||||
|
|
||||||
if is_special_readonly_option:
|
if is_special_readonly_option:
|
||||||
|
|
|
@ -35,6 +35,7 @@ from yunohost.utils.i18n import _value_for_locale
|
||||||
|
|
||||||
logger = getActionLogger("yunohost.form")
|
logger = getActionLogger("yunohost.form")
|
||||||
|
|
||||||
|
Context = dict[str, Any]
|
||||||
|
|
||||||
# ╭───────────────────────────────────────────────────────╮
|
# ╭───────────────────────────────────────────────────────╮
|
||||||
# │ ┌─╴╷ ╷╭─┐╷ │
|
# │ ┌─╴╷ ╷╭─┐╷ │
|
||||||
|
@ -200,16 +201,14 @@ class BaseOption:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
question: Dict[str, Any],
|
question: Dict[str, Any],
|
||||||
context: Mapping[str, Any] = {},
|
|
||||||
hooks: Dict[str, Callable] = {},
|
hooks: Dict[str, Callable] = {},
|
||||||
):
|
):
|
||||||
self.name = question["name"]
|
self.name = question["name"]
|
||||||
self.context = context
|
|
||||||
self.hooks = hooks
|
self.hooks = hooks
|
||||||
self.type = question.get("type", "string")
|
self.type = question.get("type", "string")
|
||||||
self.default = question.get("default", None)
|
self.default = question.get("default", None)
|
||||||
self.optional = question.get("optional", False)
|
self.optional = question.get("optional", False)
|
||||||
self.visible = question.get("visible", None)
|
self.visible = question.get("visible", True)
|
||||||
self.readonly = question.get("readonly", False)
|
self.readonly = question.get("readonly", False)
|
||||||
# Don't restrict choices if there's none specified
|
# Don't restrict choices if there's none specified
|
||||||
self.choices = question.get("choices", None)
|
self.choices = question.get("choices", None)
|
||||||
|
@ -241,75 +240,11 @@ class BaseOption:
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def ask_if_needed(self):
|
def is_visible(self, context: Context) -> bool:
|
||||||
if self.visible and not evaluate_simple_js_expression(
|
if isinstance(self.visible, bool):
|
||||||
self.visible, context=self.context
|
return self.visible
|
||||||
):
|
|
||||||
# FIXME There could be several use case if the question is not displayed:
|
|
||||||
# - we doesn't want to give a specific value
|
|
||||||
# - we want to keep the previous value
|
|
||||||
# - we want the default value
|
|
||||||
self.value = self.values[self.name] = None
|
|
||||||
return self.values
|
|
||||||
|
|
||||||
for i in range(5):
|
return evaluate_simple_js_expression(self.visible, context=context)
|
||||||
# Display question if no value filled or if it's a readonly message
|
|
||||||
if Moulinette.interface.type == "cli" and os.isatty(1):
|
|
||||||
text_for_user_input_in_cli = self._format_text_for_user_input_in_cli()
|
|
||||||
if self.readonly:
|
|
||||||
Moulinette.display(text_for_user_input_in_cli)
|
|
||||||
self.value = self.values[self.name] = self.current_value
|
|
||||||
return self.values
|
|
||||||
elif self.value is None:
|
|
||||||
self._prompt(text_for_user_input_in_cli)
|
|
||||||
|
|
||||||
# Apply default value
|
|
||||||
class_default = getattr(self, "default_value", None)
|
|
||||||
if self.value in [None, ""] and (
|
|
||||||
self.default is not None or class_default is not None
|
|
||||||
):
|
|
||||||
self.value = class_default if self.default is None else self.default
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Normalize and validate
|
|
||||||
self.value = self.normalize(self.value, self)
|
|
||||||
self._value_pre_validator()
|
|
||||||
except YunohostValidationError as e:
|
|
||||||
# If in interactive cli, re-ask the current question
|
|
||||||
if i < 4 and Moulinette.interface.type == "cli" and os.isatty(1):
|
|
||||||
logger.error(str(e))
|
|
||||||
self.value = None
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Otherwise raise the ValidationError
|
|
||||||
raise
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
self.value = self.values[self.name] = self._value_post_validator()
|
|
||||||
|
|
||||||
# Search for post actions in hooks
|
|
||||||
post_hook = f"post_ask__{self.name}"
|
|
||||||
if post_hook in self.hooks:
|
|
||||||
self.values.update(self.hooks[post_hook](self))
|
|
||||||
|
|
||||||
return self.values
|
|
||||||
|
|
||||||
def _prompt(self, text):
|
|
||||||
prefill = ""
|
|
||||||
if self.current_value is not None:
|
|
||||||
prefill = self.humanize(self.current_value, self)
|
|
||||||
elif self.default is not None:
|
|
||||||
prefill = self.humanize(self.default, self)
|
|
||||||
self.value = Moulinette.prompt(
|
|
||||||
message=text,
|
|
||||||
is_password=self.hide_user_input_in_prompt,
|
|
||||||
confirm=False,
|
|
||||||
prefill=prefill,
|
|
||||||
is_multiline=(self.type == "text"),
|
|
||||||
autocomplete=self.choices or [],
|
|
||||||
help=_value_for_locale(self.help),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _format_text_for_user_input_in_cli(self):
|
def _format_text_for_user_input_in_cli(self):
|
||||||
text_for_user_input_in_cli = _value_for_locale(self.ask)
|
text_for_user_input_in_cli = _value_for_locale(self.ask)
|
||||||
|
@ -396,9 +331,9 @@ class DisplayTextOption(BaseOption):
|
||||||
argument_type = "display_text"
|
argument_type = "display_text"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
self, question, hooks: Dict[str, Callable] = {}
|
||||||
):
|
):
|
||||||
super().__init__(question, context, hooks)
|
super().__init__(question, hooks)
|
||||||
|
|
||||||
self.optional = True
|
self.optional = True
|
||||||
self.readonly = True
|
self.readonly = True
|
||||||
|
@ -424,13 +359,19 @@ class DisplayTextOption(BaseOption):
|
||||||
|
|
||||||
class ButtonOption(BaseOption):
|
class ButtonOption(BaseOption):
|
||||||
argument_type = "button"
|
argument_type = "button"
|
||||||
enabled = None
|
enabled = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
self, question, hooks: Dict[str, Callable] = {}
|
||||||
):
|
):
|
||||||
super().__init__(question, context, hooks)
|
super().__init__(question, hooks)
|
||||||
self.enabled = question.get("enabled", None)
|
self.enabled = question.get("enabled", True)
|
||||||
|
|
||||||
|
def is_enabled(self, context: Context) -> bool:
|
||||||
|
if isinstance(self.enabled, bool):
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
return evaluate_simple_js_expression(self.enabled, context=context)
|
||||||
|
|
||||||
|
|
||||||
# ╭───────────────────────────────────────────────────────╮
|
# ╭───────────────────────────────────────────────────────╮
|
||||||
|
@ -452,10 +393,8 @@ class PasswordOption(BaseOption):
|
||||||
default_value = ""
|
default_value = ""
|
||||||
forbidden_chars = "{}"
|
forbidden_chars = "{}"
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
super().__init__(question, hooks)
|
||||||
):
|
|
||||||
super().__init__(question, context, hooks)
|
|
||||||
self.redact = True
|
self.redact = True
|
||||||
if self.default is not None:
|
if self.default is not None:
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
|
@ -491,10 +430,8 @@ class NumberOption(BaseOption):
|
||||||
argument_type = "number"
|
argument_type = "number"
|
||||||
default_value = None
|
default_value = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
super().__init__(question, hooks)
|
||||||
):
|
|
||||||
super().__init__(question, context, hooks)
|
|
||||||
self.min = question.get("min", None)
|
self.min = question.get("min", None)
|
||||||
self.max = question.get("max", None)
|
self.max = question.get("max", None)
|
||||||
self.step = question.get("step", None)
|
self.step = question.get("step", None)
|
||||||
|
@ -549,10 +486,8 @@ class BooleanOption(BaseOption):
|
||||||
yes_answers = ["1", "yes", "y", "true", "t", "on"]
|
yes_answers = ["1", "yes", "y", "true", "t", "on"]
|
||||||
no_answers = ["0", "no", "n", "false", "f", "off"]
|
no_answers = ["0", "no", "n", "false", "f", "off"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
super().__init__(question, hooks)
|
||||||
):
|
|
||||||
super().__init__(question, context, hooks)
|
|
||||||
self.yes = question.get("yes", 1)
|
self.yes = question.get("yes", 1)
|
||||||
self.no = question.get("no", 0)
|
self.no = question.get("no", 0)
|
||||||
if self.default is None:
|
if self.default is None:
|
||||||
|
@ -716,10 +651,8 @@ class FileOption(BaseOption):
|
||||||
argument_type = "file"
|
argument_type = "file"
|
||||||
upload_dirs: List[str] = []
|
upload_dirs: List[str] = []
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
super().__init__(question, hooks)
|
||||||
):
|
|
||||||
super().__init__(question, context, hooks)
|
|
||||||
self.accept = question.get("accept", "")
|
self.accept = question.get("accept", "")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -830,15 +763,16 @@ class TagsOption(BaseOption):
|
||||||
return super()._value_post_validator()
|
return super()._value_post_validator()
|
||||||
|
|
||||||
|
|
||||||
|
# ─ ENTITIES ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class DomainOption(BaseOption):
|
class DomainOption(BaseOption):
|
||||||
argument_type = "domain"
|
argument_type = "domain"
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
|
||||||
):
|
|
||||||
from yunohost.domain import domain_list, _get_maindomain
|
from yunohost.domain import domain_list, _get_maindomain
|
||||||
|
|
||||||
super().__init__(question, context, hooks)
|
super().__init__(question, hooks)
|
||||||
|
|
||||||
if self.default is None:
|
if self.default is None:
|
||||||
self.default = _get_maindomain()
|
self.default = _get_maindomain()
|
||||||
|
@ -864,12 +798,10 @@ class DomainOption(BaseOption):
|
||||||
class AppOption(BaseOption):
|
class AppOption(BaseOption):
|
||||||
argument_type = "app"
|
argument_type = "app"
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
|
||||||
):
|
|
||||||
from yunohost.app import app_list
|
from yunohost.app import app_list
|
||||||
|
|
||||||
super().__init__(question, context, hooks)
|
super().__init__(question, hooks)
|
||||||
|
|
||||||
apps = app_list(full=True)["apps"]
|
apps = app_list(full=True)["apps"]
|
||||||
|
|
||||||
|
@ -891,13 +823,11 @@ class AppOption(BaseOption):
|
||||||
class UserOption(BaseOption):
|
class UserOption(BaseOption):
|
||||||
argument_type = "user"
|
argument_type = "user"
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
|
||||||
):
|
|
||||||
from yunohost.user import user_list, user_info
|
from yunohost.user import user_list, user_info
|
||||||
from yunohost.domain import _get_maindomain
|
from yunohost.domain import _get_maindomain
|
||||||
|
|
||||||
super().__init__(question, context, hooks)
|
super().__init__(question, hooks)
|
||||||
|
|
||||||
self.choices = {
|
self.choices = {
|
||||||
username: f"{infos['fullname']} ({infos['mail']})"
|
username: f"{infos['fullname']} ({infos['mail']})"
|
||||||
|
@ -924,12 +854,10 @@ class UserOption(BaseOption):
|
||||||
class GroupOption(BaseOption):
|
class GroupOption(BaseOption):
|
||||||
argument_type = "group"
|
argument_type = "group"
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, question, hooks: Dict[str, Callable] = {}):
|
||||||
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
|
|
||||||
):
|
|
||||||
from yunohost.user import user_group_list
|
from yunohost.user import user_group_list
|
||||||
|
|
||||||
super().__init__(question, context)
|
super().__init__(question)
|
||||||
|
|
||||||
self.choices = list(
|
self.choices = list(
|
||||||
user_group_list(short=True, include_primary_groups=False)["groups"]
|
user_group_list(short=True, include_primary_groups=False)["groups"]
|
||||||
|
@ -981,12 +909,111 @@ OPTIONS = {
|
||||||
# ╰───────────────────────────────────────────────────────╯
|
# ╰───────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_or_validate_form(
|
||||||
|
raw_options: dict[str, Any],
|
||||||
|
prefilled_answers: dict[str, Any] = {},
|
||||||
|
context: Context = {},
|
||||||
|
hooks: dict[str, Callable[[], None]] = {},
|
||||||
|
) -> list[BaseOption]:
|
||||||
|
options = []
|
||||||
|
answers = {**prefilled_answers}
|
||||||
|
|
||||||
|
for name, raw_option in raw_options.items():
|
||||||
|
raw_option["name"] = name
|
||||||
|
raw_option["value"] = answers.get(name)
|
||||||
|
question_class = OPTIONS[raw_option.get("type", "string")]
|
||||||
|
option = question_class(raw_option, hooks=hooks)
|
||||||
|
|
||||||
|
interactive = Moulinette.interface.type == "cli" and os.isatty(1)
|
||||||
|
|
||||||
|
if isinstance(option, ButtonOption):
|
||||||
|
if option.is_enabled(context):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise YunohostValidationError(
|
||||||
|
"config_action_disabled",
|
||||||
|
action=option.name,
|
||||||
|
help=_value_for_locale(option.help),
|
||||||
|
)
|
||||||
|
|
||||||
|
if option.is_visible(context):
|
||||||
|
for i in range(5):
|
||||||
|
# Display question if no value filled or if it's a readonly message
|
||||||
|
if interactive:
|
||||||
|
text_for_user_input_in_cli = (
|
||||||
|
option._format_text_for_user_input_in_cli()
|
||||||
|
)
|
||||||
|
if option.readonly:
|
||||||
|
Moulinette.display(text_for_user_input_in_cli)
|
||||||
|
option.value = option.current_value
|
||||||
|
break
|
||||||
|
elif option.value is None:
|
||||||
|
prefill = ""
|
||||||
|
if option.current_value is not None:
|
||||||
|
prefill = option.humanize(option.current_value, option)
|
||||||
|
elif option.default is not None:
|
||||||
|
prefill = option.humanize(option.default, option)
|
||||||
|
option.value = Moulinette.prompt(
|
||||||
|
message=text_for_user_input_in_cli,
|
||||||
|
is_password=option.hide_user_input_in_prompt,
|
||||||
|
confirm=False,
|
||||||
|
prefill=prefill,
|
||||||
|
is_multiline=(option.type == "text"),
|
||||||
|
autocomplete=option.choices or [],
|
||||||
|
help=_value_for_locale(option.help),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply default value
|
||||||
|
class_default = getattr(option, "default_value", None)
|
||||||
|
if option.value in [None, ""] and (
|
||||||
|
option.default is not None or class_default is not None
|
||||||
|
):
|
||||||
|
option.value = (
|
||||||
|
class_default if option.default is None else option.default
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Normalize and validate
|
||||||
|
option.value = option.normalize(option.value, option)
|
||||||
|
option._value_pre_validator()
|
||||||
|
except YunohostValidationError as e:
|
||||||
|
# If in interactive cli, re-ask the current question
|
||||||
|
if i < 4 and interactive:
|
||||||
|
logger.error(str(e))
|
||||||
|
option.value = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Otherwise raise the ValidationError
|
||||||
|
raise
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
option.value = option.values[option.name] = option._value_post_validator()
|
||||||
|
|
||||||
|
# Search for post actions in hooks
|
||||||
|
post_hook = f"post_ask__{option.name}"
|
||||||
|
if post_hook in option.hooks:
|
||||||
|
option.values.update(option.hooks[post_hook](option))
|
||||||
|
else:
|
||||||
|
# FIXME There could be several use case if the question is not displayed:
|
||||||
|
# - we doesn't want to give a specific value
|
||||||
|
# - we want to keep the previous value
|
||||||
|
# - we want the default value
|
||||||
|
option.value = option.values[option.name] = None
|
||||||
|
|
||||||
|
answers.update(option.values)
|
||||||
|
context.update(option.values)
|
||||||
|
options.append(option)
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def ask_questions_and_parse_answers(
|
def ask_questions_and_parse_answers(
|
||||||
raw_questions: Dict,
|
raw_options: dict[str, Any],
|
||||||
prefilled_answers: Union[str, Mapping[str, Any]] = {},
|
prefilled_answers: Union[str, Mapping[str, Any]] = {},
|
||||||
current_values: Mapping[str, Any] = {},
|
current_values: Mapping[str, Any] = {},
|
||||||
hooks: Dict[str, Callable[[], None]] = {},
|
hooks: Dict[str, Callable[[], None]] = {},
|
||||||
) -> List[BaseOption]:
|
) -> list[BaseOption]:
|
||||||
"""Parse arguments store in either manifest.json or actions.json or from a
|
"""Parse arguments store in either manifest.json or actions.json or from a
|
||||||
config panel against the user answers when they are present.
|
config panel against the user answers when they are present.
|
||||||
|
|
||||||
|
@ -1013,32 +1040,11 @@ def ask_questions_and_parse_answers(
|
||||||
answers = {}
|
answers = {}
|
||||||
|
|
||||||
context = {**current_values, **answers}
|
context = {**current_values, **answers}
|
||||||
out = []
|
|
||||||
|
|
||||||
for name, raw_question in raw_questions.items():
|
return prompt_or_validate_form(
|
||||||
raw_question["name"] = name
|
raw_options, prefilled_answers=answers, context=context, hooks=hooks
|
||||||
question_class = OPTIONS[raw_question.get("type", "string")]
|
|
||||||
raw_question["value"] = answers.get(name)
|
|
||||||
question = question_class(raw_question, context=context, hooks=hooks)
|
|
||||||
if question.type == "button":
|
|
||||||
if question.enabled is None or evaluate_simple_js_expression( # type: ignore
|
|
||||||
question.enabled, context=context # type: ignore
|
|
||||||
): # type: ignore
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"config_action_disabled",
|
|
||||||
action=question.name,
|
|
||||||
help=_value_for_locale(question.help),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
new_values = question.ask_if_needed()
|
|
||||||
answers.update(new_values)
|
|
||||||
context.update(new_values)
|
|
||||||
out.append(question)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def hydrate_questions_with_choices(raw_questions: List) -> List:
|
def hydrate_questions_with_choices(raw_questions: List) -> List:
|
||||||
out = []
|
out = []
|
||||||
|
|
Loading…
Add table
Reference in a new issue