[enh] config panel python method hook

This commit is contained in:
ljf 2021-10-13 19:28:15 +02:00
parent 8dbc93c674
commit 9f7fb61b50
3 changed files with 143 additions and 60 deletions

View file

@ -1608,14 +1608,9 @@ def app_config_set(
class AppConfigPanel(ConfigPanel): class AppConfigPanel(ConfigPanel):
def __init__(self, app): entity_type = "app"
save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml")
# Check app is installed config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.yml")
_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)
def _load_current_values(self): def _load_current_values(self):
self.values = self._call_config_script("show") self.values = self._call_config_script("show")
@ -1639,7 +1634,7 @@ class AppConfigPanel(ConfigPanel):
from yunohost.hook import hook_exec from yunohost.hook import hook_exec
# Add default config script if needed # 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): if not os.path.exists(config_script):
logger.debug("Adding a default config script") logger.debug("Adding a default config script")
default_script = """#!/bin/bash default_script = """#!/bin/bash
@ -1651,15 +1646,15 @@ ynh_app_config_run $1
# Call config script to extract current values # Call config script to extract current values
logger.debug(f"Calling '{action}' action from config script") 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) settings = _get_app_settings(app_id)
env.update( env.update(
{ {
"app_id": app_id, "app_id": app_id,
"app": self.app, "app": self.entity,
"app_instance_nb": str(app_instance_nb), "app_instance_nb": str(app_instance_nb),
"final_path": settings.get("final_path", ""), "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),
} }
) )

View file

@ -452,14 +452,9 @@ def domain_config_set(
class DomainConfigPanel(ConfigPanel): class DomainConfigPanel(ConfigPanel):
def __init__(self, domain): entity_type = "domain"
_assert_domain_exists(domain) save_path_tpl = f"{DOMAIN_SETTINGS_PATH}/{entity}.yml")
self.domain = domain save_mode = "diff"
self.save_mode = "diff"
super().__init__(
config_path=DOMAIN_CONFIG_PATH,
save_path=f"{DOMAIN_SETTINGS_DIR}/{domain}.yml",
)
def _get_toml(self): def _get_toml(self):
from yunohost.dns import _get_registrar_config_section from yunohost.dns import _get_registrar_config_section

View file

@ -19,6 +19,7 @@
""" """
import glob
import os import os
import re import re
import urllib.parse import urllib.parse
@ -27,7 +28,7 @@ import shutil
import ast import ast
import operator as op import operator as op
from collections import OrderedDict 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.interfaces.cli import colorize
from moulinette import Moulinette, m18n from moulinette import Moulinette, m18n
@ -189,13 +190,45 @@ def evaluate_simple_js_expression(expr, context={}):
class ConfigPanel: 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<entity>)") + "$", 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 self.config_path = config_path
if not config_path:
self.config_path = self.config_path_tpl.format(entity=entity)
self.save_path = save_path 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.config = {}
self.values = {} self.values = {}
self.new_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"): def get(self, key="", mode="classic"):
self.filter_key = key or "" self.filter_key = key or ""
@ -274,19 +307,12 @@ class ConfigPanel:
# Import and parse pre-answered options # Import and parse pre-answered options
logger.debug("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._parse_pre_answered(args, value, args_file)
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}
# Read or get values and hydrate the config # Read or get values and hydrate the config
self._load_current_values() self._load_current_values()
self._hydrate() self._hydrate()
Question.operation_logger = operation_logger
self._ask() self._ask()
if operation_logger: if operation_logger:
@ -525,7 +551,12 @@ class ConfigPanel:
display_header(f"\n# {name}") display_header(f"\n# {name}")
# Check and ask unanswered questions # 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( self.new_values.update(
{ {
question.name: question.value question.name: question.value
@ -543,20 +574,42 @@ class ConfigPanel:
if "default" in option 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): def _load_current_values(self):
""" """
Retrieve entries in YAML file Retrieve entries in YAML file
And set default values if needed 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() ;)) # Inject defaults if needed (using the magic .update() ;))
self.values = self._get_default_values() 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): def _apply(self):
logger.info("Saving the new configuration...") logger.info("Saving the new configuration...")
@ -564,7 +617,7 @@ class ConfigPanel:
if not os.path.exists(dir_path): if not os.path.exists(dir_path):
mkdir(dir_path, mode=0o700) mkdir(dir_path, mode=0o700)
values_to_save = {**self.values, **self.new_values} values_to_save = self.future_values
if self.save_mode == "diff": if self.save_mode == "diff":
defaults = self._get_default_values() defaults = self._get_default_values()
values_to_save = { values_to_save = {
@ -587,8 +640,8 @@ class ConfigPanel:
if services_to_reload: if services_to_reload:
logger.info("Reloading services...") logger.info("Reloading services...")
for service in services_to_reload: for service in services_to_reload:
if hasattr(self, "app"): if hasattr(self, "entity"):
service = service.replace("__APP__", self.app) service = service.replace("__APP__", self.entity)
service_reload_or_restart(service) service_reload_or_restart(service)
def _iterate(self, trigger=["option"]): def _iterate(self, trigger=["option"]):
@ -607,13 +660,16 @@ class Question(object):
hide_user_input_in_prompt = False hide_user_input_in_prompt = False
pattern: Optional[Dict] = None 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.name = question["name"]
self.context = context
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", None)
self.context = context
self.choices = question.get("choices", []) self.choices = question.get("choices", [])
self.pattern = question.get("pattern", self.pattern) self.pattern = question.get("pattern", self.pattern)
self.ask = question.get("ask", {"en": self.name}) self.ask = question.get("ask", {"en": self.name})
@ -623,6 +679,8 @@ class Question(object):
self.current_value = question.get("current_value") self.current_value = question.get("current_value")
# .value is the "proposed" value which we got from the user # .value is the "proposed" value which we got from the user
self.value = question.get("value") 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 # Empty value is parsed as empty string
if self.default == "": if self.default == "":
@ -663,8 +721,8 @@ class Question(object):
# - we doesn't want to give a specific value # - we doesn't want to give a specific value
# - we want to keep the previous value # - we want to keep the previous value
# - we want the default value # - we want the default value
self.value = None self.value = self.values[self.name] = None
return self.value return self.values
for i in range(5): for i in range(5):
# Display question if no value filled or if it's a readonly message # Display question if no value filled or if it's a readonly message
@ -698,9 +756,18 @@ class Question(object):
break break
self.value = self._post_parse_value() self.value = self.values[self.name] = self._post_parse_value()
<<<<<<< HEAD
return self.value 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): def _prevalidate(self):
if self.value in [None, ""] and not self.optional: if self.value in [None, ""] and not self.optional:
@ -864,8 +931,10 @@ class PasswordQuestion(Question):
default_value = "" default_value = ""
forbidden_chars = "{}" forbidden_chars = "{}"
def __init__(self, question, context: Mapping[str, Any] = {}): def __init__(self, question,
super().__init__(question, context) context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
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(
@ -983,8 +1052,9 @@ class BooleanQuestion(Question):
choices="yes/no", choices="yes/no",
) )
def __init__(self, question, context: Mapping[str, Any] = {}): def __init__(self, question, context: Mapping[str, Any] = {},
super().__init__(question, context) hooks: Dict[str, Callable] = {}):
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:
@ -1004,10 +1074,11 @@ class BooleanQuestion(Question):
class DomainQuestion(Question): class DomainQuestion(Question):
argument_type = "domain" 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 from yunohost.domain import domain_list, _get_maindomain
super().__init__(question, context) super().__init__(question, context, hooks)
if self.default is None: if self.default is None:
self.default = _get_maindomain() self.default = _get_maindomain()
@ -1030,11 +1101,12 @@ class DomainQuestion(Question):
class UserQuestion(Question): class UserQuestion(Question):
argument_type = "user" 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.user import user_list, user_info
from yunohost.domain import _get_maindomain from yunohost.domain import _get_maindomain
super().__init__(question, context) super().__init__(question, context, hooks)
self.choices = list(user_list()["users"].keys()) self.choices = list(user_list()["users"].keys())
if not self.choices: if not self.choices:
@ -1056,8 +1128,9 @@ class NumberQuestion(Question):
argument_type = "number" argument_type = "number"
default_value = None default_value = None
def __init__(self, question, context: Mapping[str, Any] = {}): def __init__(self, question, context: Mapping[str, Any] = {},
super().__init__(question, context) hooks: Dict[str, Callable] = {}):
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)
@ -1108,8 +1181,9 @@ class DisplayTextQuestion(Question):
argument_type = "display_text" argument_type = "display_text"
readonly = True readonly = True
def __init__(self, question, context: Mapping[str, Any] = {}): def __init__(self, question, context: Mapping[str, Any] = {},
super().__init__(question, context) hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.optional = True self.optional = True
self.style = question.get( self.style = question.get(
@ -1143,8 +1217,9 @@ class FileQuestion(Question):
if os.path.exists(upload_dir): if os.path.exists(upload_dir):
shutil.rmtree(upload_dir) shutil.rmtree(upload_dir)
def __init__(self, question, context: Mapping[str, Any] = {}): def __init__(self, question, context: Mapping[str, Any] = {},
super().__init__(question, context) hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.accept = question.get("accept", "") self.accept = question.get("accept", "")
def _prevalidate(self): def _prevalidate(self):
@ -1214,7 +1289,14 @@ ARGUMENTS_TYPE_PARSERS = {
def ask_questions_and_parse_answers( 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]] = {}
=======
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]: ) -> List[Question]:
"""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.
@ -1241,13 +1323,24 @@ def ask_questions_and_parse_answers(
else: else:
answers = {} answers = {}
<<<<<<< HEAD
=======
context = {**current_values, **answers}
>>>>>>> c0cd8dbf... [enh] config panel python method hook
out = [] out = []
for raw_question in raw_questions: for raw_question in raw_questions:
question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")] question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")]
raw_question["value"] = answers.get(raw_question["name"]) raw_question["value"] = answers.get(raw_question["name"])
<<<<<<< HEAD
question = question_class(raw_question, context=answers) question = question_class(raw_question, context=answers)
answers[question.name] = question.ask_if_needed() 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) out.append(question)
return out return out