diff --git a/src/yunohost/app.py b/src/yunohost/app.py index fb544cab2..8d441c00d 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1608,14 +1608,9 @@ def app_config_set( class AppConfigPanel(ConfigPanel): - def __init__(self, app): - - # Check app is installed - _assert_is_installed(app) - - self.app = app - config_path = os.path.join(APPS_SETTING_PATH, app, "config_panel.toml") - super().__init__(config_path=config_path) + entity_type = "app" + save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml") + config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.yml") def _load_current_values(self): self.values = self._call_config_script("show") @@ -1639,7 +1634,7 @@ class AppConfigPanel(ConfigPanel): from yunohost.hook import hook_exec # Add default config script if needed - config_script = os.path.join(APPS_SETTING_PATH, self.app, "scripts", "config") + config_script = os.path.join(APPS_SETTING_PATH, self.entity, "scripts", "config") if not os.path.exists(config_script): logger.debug("Adding a default config script") default_script = """#!/bin/bash @@ -1651,15 +1646,15 @@ ynh_app_config_run $1 # Call config script to extract current values logger.debug(f"Calling '{action}' action from config script") - app_id, app_instance_nb = _parse_app_instance_name(self.app) + app_id, app_instance_nb = _parse_app_instance_name(self.entity) settings = _get_app_settings(app_id) env.update( { "app_id": app_id, - "app": self.app, + "app": self.entity, "app_instance_nb": str(app_instance_nb), "final_path": settings.get("final_path", ""), - "YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.app), + "YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.entity), } ) diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index b40831d25..b353badb8 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -452,14 +452,9 @@ def domain_config_set( class DomainConfigPanel(ConfigPanel): - def __init__(self, domain): - _assert_domain_exists(domain) - self.domain = domain - self.save_mode = "diff" - super().__init__( - config_path=DOMAIN_CONFIG_PATH, - save_path=f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", - ) + entity_type = "domain" + save_path_tpl = f"{DOMAIN_SETTINGS_PATH}/{entity}.yml") + save_mode = "diff" def _get_toml(self): from yunohost.dns import _get_registrar_config_section diff --git a/src/yunohost/utils/config.py b/src/yunohost/utils/config.py index 4ee62c6f7..880cd09f3 100644 --- a/src/yunohost/utils/config.py +++ b/src/yunohost/utils/config.py @@ -19,6 +19,7 @@ """ +import glob import os import re import urllib.parse @@ -27,7 +28,7 @@ import shutil import ast import operator as op from collections import OrderedDict -from typing import Optional, Dict, List, Union, Any, Mapping +from typing import Optional, Dict, List, Union, Any, Mapping, Callable from moulinette.interfaces.cli import colorize from moulinette import Moulinette, m18n @@ -189,13 +190,45 @@ def evaluate_simple_js_expression(expr, context={}): class ConfigPanel: - def __init__(self, config_path, save_path=None): + entity_type = "config" + save_path_tpl = None + config_path_tpl = "/usr/share/yunohost/other/config_{entity}.toml" + + @classmethod + def list(cls): + """ + List available config panel + """ + try: + entities = [re.match("^" + cls.save_path_tpl.format(entity="(?p)") + "$", f).group('entity') + for f in glob.glob(cls.save_path_tpl.format(entity="*")) + if os.path.isfile(f)] + except FileNotFoundError: + entities = [] + return entities + + def __init__(self, entity, config_path=None, save_path=None, creation=False): + self.entity = entity self.config_path = config_path + if not config_path: + self.config_path = self.config_path_tpl.format(entity=entity) self.save_path = save_path + if not save_path and self.save_path_tpl: + self.save_path = self.save_path_tpl.format(entity=entity) self.config = {} self.values = {} self.new_values = {} + if self.save_path and not creation and not os.path.exists(self.save_path): + raise YunohostError(f"{self.entity_type}_doesnt_exists", name=entity) + if self.save_path and creation and os.path.exists(self.save_path): + raise YunohostError(f"{self.entity_type}_already_exists", name=entity) + + # Search for hooks in the config panel + self.hooks = {func: getattr(self, func) + for func in dir(self) + if callable(getattr(self, func)) and re.match("^(validate|post_ask)__", func)} + def get(self, key="", mode="classic"): self.filter_key = key or "" @@ -274,19 +307,12 @@ class ConfigPanel: # Import and parse pre-answered options logger.debug("Import and parse pre-answered options") - args = urllib.parse.parse_qs(args or "", keep_blank_values=True) - self.args = {key: ",".join(value_) for key, value_ in args.items()} - - if args_file: - # Import YAML / JSON file but keep --args values - self.args = {**read_yaml(args_file), **self.args} - - if value is not None: - self.args = {self.filter_key.split(".")[-1]: value} + self._parse_pre_answered(args, value, args_file) # Read or get values and hydrate the config self._load_current_values() self._hydrate() + Question.operation_logger = operation_logger self._ask() if operation_logger: @@ -525,7 +551,12 @@ class ConfigPanel: display_header(f"\n# {name}") # Check and ask unanswered questions - questions = ask_questions_and_parse_answers(section["options"], self.args) + questions = ask_questions_and_parse_answers( + section["options"], + prefilled_answers=self.args, + current_values=self.values, + hooks=self.hooks + ) self.new_values.update( { question.name: question.value @@ -543,20 +574,42 @@ class ConfigPanel: if "default" in option } + @property + def future_values(self): # TODO put this in ConfigPanel ? + return {**self.values, **self.new_values} + + def __getattr__(self, name): + if "new_values" in self.__dict__ and name in self.new_values: + return self.new_values[name] + + if "values" in self.__dict__ and name in self.values: + return self.values[name] + + return self.__dict__[name] + def _load_current_values(self): """ Retrieve entries in YAML file And set default values if needed """ - # Retrieve entries in the YAML - on_disk_settings = {} - if os.path.exists(self.save_path) and os.path.isfile(self.save_path): - on_disk_settings = read_yaml(self.save_path) or {} - # Inject defaults if needed (using the magic .update() ;)) self.values = self._get_default_values() - self.values.update(on_disk_settings) + + # Retrieve entries in the YAML + if os.path.exists(self.save_path) and os.path.isfile(self.save_path): + self.values.update(read_yaml(self.save_path) or {}) + + def _parse_pre_answered(self, args, value, args_file): + args = urllib.parse.parse_qs(args or "", keep_blank_values=True) + self.args = {key: ",".join(value_) for key, value_ in args.items()} + + if args_file: + # Import YAML / JSON file but keep --args values + self.args = {**read_yaml(args_file), **self.args} + + if value is not None: + self.args = {self.filter_key.split(".")[-1]: value} def _apply(self): logger.info("Saving the new configuration...") @@ -564,7 +617,7 @@ class ConfigPanel: if not os.path.exists(dir_path): mkdir(dir_path, mode=0o700) - values_to_save = {**self.values, **self.new_values} + values_to_save = self.future_values if self.save_mode == "diff": defaults = self._get_default_values() values_to_save = { @@ -587,8 +640,8 @@ class ConfigPanel: if services_to_reload: logger.info("Reloading services...") for service in services_to_reload: - if hasattr(self, "app"): - service = service.replace("__APP__", self.app) + if hasattr(self, "entity"): + service = service.replace("__APP__", self.entity) service_reload_or_restart(service) def _iterate(self, trigger=["option"]): @@ -607,13 +660,16 @@ class Question(object): hide_user_input_in_prompt = False pattern: Optional[Dict] = None - def __init__(self, question: Dict[str, Any], context: Mapping[str, Any] = {}): + def __init__(self, question: Dict[str, Any], + context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): self.name = question["name"] + self.context = context + self.hooks = hooks self.type = question.get("type", "string") self.default = question.get("default", None) self.optional = question.get("optional", False) self.visible = question.get("visible", None) - self.context = context self.choices = question.get("choices", []) self.pattern = question.get("pattern", self.pattern) self.ask = question.get("ask", {"en": self.name}) @@ -623,6 +679,8 @@ class Question(object): self.current_value = question.get("current_value") # .value is the "proposed" value which we got from the user self.value = question.get("value") + # Use to return several values in case answer is in mutipart + self.values = {} # Empty value is parsed as empty string if self.default == "": @@ -663,8 +721,8 @@ class Question(object): # - we doesn't want to give a specific value # - we want to keep the previous value # - we want the default value - self.value = None - return self.value + self.value = self.values[self.name] = None + return self.values for i in range(5): # Display question if no value filled or if it's a readonly message @@ -698,9 +756,18 @@ class Question(object): break - self.value = self._post_parse_value() + self.value = self.values[self.name] = self._post_parse_value() +<<<<<<< HEAD return self.value +======= + # 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 +>>>>>>> c0cd8dbf... [enh] config panel python method hook def _prevalidate(self): if self.value in [None, ""] and not self.optional: @@ -864,8 +931,10 @@ class PasswordQuestion(Question): default_value = "" forbidden_chars = "{}" - def __init__(self, question, context: Mapping[str, Any] = {}): - super().__init__(question, context) + def __init__(self, question, + context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): + super().__init__(question, context, hooks) self.redact = True if self.default is not None: raise YunohostValidationError( @@ -983,8 +1052,9 @@ class BooleanQuestion(Question): choices="yes/no", ) - def __init__(self, question, context: Mapping[str, Any] = {}): - super().__init__(question, context) + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): + super().__init__(question, context, hooks) self.yes = question.get("yes", 1) self.no = question.get("no", 0) if self.default is None: @@ -1004,10 +1074,11 @@ class BooleanQuestion(Question): class DomainQuestion(Question): argument_type = "domain" - def __init__(self, question, context: Mapping[str, Any] = {}): + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): from yunohost.domain import domain_list, _get_maindomain - super().__init__(question, context) + super().__init__(question, context, hooks) if self.default is None: self.default = _get_maindomain() @@ -1030,11 +1101,12 @@ class DomainQuestion(Question): class UserQuestion(Question): argument_type = "user" - def __init__(self, question, context: Mapping[str, Any] = {}): + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): from yunohost.user import user_list, user_info from yunohost.domain import _get_maindomain - super().__init__(question, context) + super().__init__(question, context, hooks) self.choices = list(user_list()["users"].keys()) if not self.choices: @@ -1056,8 +1128,9 @@ class NumberQuestion(Question): argument_type = "number" default_value = None - def __init__(self, question, context: Mapping[str, Any] = {}): - super().__init__(question, context) + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): + super().__init__(question, context, hooks) self.min = question.get("min", None) self.max = question.get("max", None) self.step = question.get("step", None) @@ -1108,8 +1181,9 @@ class DisplayTextQuestion(Question): argument_type = "display_text" readonly = True - def __init__(self, question, context: Mapping[str, Any] = {}): - super().__init__(question, context) + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): + super().__init__(question, context, hooks) self.optional = True self.style = question.get( @@ -1143,8 +1217,9 @@ class FileQuestion(Question): if os.path.exists(upload_dir): shutil.rmtree(upload_dir) - def __init__(self, question, context: Mapping[str, Any] = {}): - super().__init__(question, context) + def __init__(self, question, context: Mapping[str, Any] = {}, + hooks: Dict[str, Callable] = {}): + super().__init__(question, context, hooks) self.accept = question.get("accept", "") def _prevalidate(self): @@ -1214,7 +1289,14 @@ ARGUMENTS_TYPE_PARSERS = { def ask_questions_and_parse_answers( +<<<<<<< HEAD raw_questions: Dict, prefilled_answers: Union[str, Mapping[str, Any]] = {} +======= + raw_questions: Dict, + prefilled_answers: Union[str, Mapping[str, Any]] = {}, + current_values: Union[str, Mapping[str, Any]] = {}, + hooks: Dict[str, Callable[[], None]] = {} +>>>>>>> c0cd8dbf... [enh] config panel python method hook ) -> List[Question]: """Parse arguments store in either manifest.json or actions.json or from a config panel against the user answers when they are present. @@ -1241,13 +1323,24 @@ def ask_questions_and_parse_answers( else: answers = {} +<<<<<<< HEAD +======= + context = {**current_values, **answers} +>>>>>>> c0cd8dbf... [enh] config panel python method hook out = [] for raw_question in raw_questions: question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")] raw_question["value"] = answers.get(raw_question["name"]) +<<<<<<< HEAD question = question_class(raw_question, context=answers) answers[question.name] = question.ask_if_needed() +======= + question = question_class(raw_question, context=context, hooks=hooks) + new_values = question.ask_if_needed() + answers.update(new_values) + context.update(new_values) +>>>>>>> c0cd8dbf... [enh] config panel python method hook out.append(question) return out