mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
form: use pydantic BaseModel in Options and add some validators
This commit is contained in:
parent
5bd8680847
commit
f5c56db10e
1 changed files with 261 additions and 209 deletions
|
@ -17,6 +17,7 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import ast
|
import ast
|
||||||
|
import datetime
|
||||||
import operator as op
|
import operator as op
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
@ -24,9 +25,18 @@ import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Union
|
from typing import Any, Callable, Dict, List, Literal, Mapping, Union
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
root_validator,
|
||||||
|
validator,
|
||||||
|
)
|
||||||
|
from pydantic.color import Color
|
||||||
|
from pydantic.networks import EmailStr, HttpUrl
|
||||||
|
from pydantic.types import FilePath
|
||||||
|
|
||||||
from moulinette import Moulinette, m18n
|
from moulinette import Moulinette, m18n
|
||||||
from moulinette.interfaces.cli import colorize
|
from moulinette.interfaces.cli import colorize
|
||||||
from moulinette.utils.filesystem import read_file, write_to_file
|
from moulinette.utils.filesystem import read_file, write_to_file
|
||||||
|
@ -36,7 +46,6 @@ from yunohost.utils.i18n import _value_for_locale
|
||||||
|
|
||||||
logger = getLogger("yunohost.form")
|
logger = getLogger("yunohost.form")
|
||||||
|
|
||||||
Context = dict[str, Any]
|
|
||||||
|
|
||||||
# ╭───────────────────────────────────────────────────────╮
|
# ╭───────────────────────────────────────────────────────╮
|
||||||
# │ ┌─╴╷ ╷╭─┐╷ │
|
# │ ┌─╴╷ ╷╭─┐╷ │
|
||||||
|
@ -240,27 +249,58 @@ FORBIDDEN_READONLY_TYPES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BaseOption:
|
Context = dict[str, Any]
|
||||||
def __init__(
|
Translation = Union[dict[str, str], str]
|
||||||
self,
|
JSExpression = str
|
||||||
question: Dict[str, Any],
|
Values = dict[str, Any]
|
||||||
):
|
|
||||||
self.id = question["id"]
|
|
||||||
self.type = question.get("type", OptionType.string)
|
|
||||||
self.visible = question.get("visible", True)
|
|
||||||
|
|
||||||
self.readonly = question.get("readonly", False)
|
|
||||||
if self.readonly and self.type in FORBIDDEN_READONLY_TYPES:
|
class Pattern(BaseModel):
|
||||||
# FIXME i18n
|
regexp: str
|
||||||
raise YunohostError(
|
error: Translation = "error_pattern" # FIXME add generic i18n key
|
||||||
|
|
||||||
|
|
||||||
|
class BaseOption(BaseModel):
|
||||||
|
type: OptionType
|
||||||
|
id: str
|
||||||
|
ask: Union[Translation, None]
|
||||||
|
readonly: bool = False
|
||||||
|
visible: Union[JSExpression, bool] = True
|
||||||
|
bind: Union[str, None] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
use_enum_values = True
|
||||||
|
validate_assignment = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def schema_extra(schema: dict[str, Any], model: Type["BaseOption"]) -> None:
|
||||||
|
# FIXME Do proper doctstring for Options
|
||||||
|
del schema["description"]
|
||||||
|
schema["additionalProperties"] = False
|
||||||
|
|
||||||
|
@validator("ask", always=True)
|
||||||
|
def parse_or_set_default_ask(
|
||||||
|
cls, value: Union[Translation, None], values: Values
|
||||||
|
) -> Translation:
|
||||||
|
if value is None:
|
||||||
|
return {"en": values["id"]}
|
||||||
|
if isinstance(value, str):
|
||||||
|
return {"en": value}
|
||||||
|
return value
|
||||||
|
|
||||||
|
@validator("readonly", pre=True)
|
||||||
|
def can_be_readonly(cls, value: bool, values: Values) -> bool:
|
||||||
|
forbidden_types = ("password", "app", "domain", "user", "file")
|
||||||
|
if value is True and values["type"] in forbidden_types:
|
||||||
|
raise ValueError(
|
||||||
|
m18n.n(
|
||||||
"config_forbidden_readonly_type",
|
"config_forbidden_readonly_type",
|
||||||
type=self.type,
|
type=values["type"],
|
||||||
id=self.id,
|
id=values["id"],
|
||||||
)
|
)
|
||||||
|
)
|
||||||
self.ask = question.get("ask", self.id)
|
return value
|
||||||
if not isinstance(self.ask, dict):
|
|
||||||
self.ask = {"en": self.ask}
|
|
||||||
|
|
||||||
def is_visible(self, context: Context) -> bool:
|
def is_visible(self, context: Context) -> bool:
|
||||||
if isinstance(self.visible, bool):
|
if isinstance(self.visible, bool):
|
||||||
|
@ -268,7 +308,7 @@ class BaseOption:
|
||||||
|
|
||||||
return evaluate_simple_js_expression(self.visible, context=context)
|
return evaluate_simple_js_expression(self.visible, context=context)
|
||||||
|
|
||||||
def _get_prompt_message(self) -> str:
|
def _get_prompt_message(self, value: None) -> str:
|
||||||
return _value_for_locale(self.ask)
|
return _value_for_locale(self.ask)
|
||||||
|
|
||||||
|
|
||||||
|
@ -278,9 +318,7 @@ class BaseOption:
|
||||||
|
|
||||||
|
|
||||||
class BaseReadonlyOption(BaseOption):
|
class BaseReadonlyOption(BaseOption):
|
||||||
def __init__(self, question):
|
readonly: Literal[True] = True
|
||||||
super().__init__(question)
|
|
||||||
self.readonly = True
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayTextOption(BaseReadonlyOption):
|
class DisplayTextOption(BaseReadonlyOption):
|
||||||
|
@ -291,38 +329,35 @@ class MarkdownOption(BaseReadonlyOption):
|
||||||
type: Literal[OptionType.markdown] = OptionType.markdown
|
type: Literal[OptionType.markdown] = OptionType.markdown
|
||||||
|
|
||||||
|
|
||||||
|
class State(str, Enum):
|
||||||
|
success = "success"
|
||||||
|
info = "info"
|
||||||
|
warning = "warning"
|
||||||
|
danger = "danger"
|
||||||
|
|
||||||
|
|
||||||
class AlertOption(BaseReadonlyOption):
|
class AlertOption(BaseReadonlyOption):
|
||||||
type: Literal[OptionType.alert] = OptionType.alert
|
type: Literal[OptionType.alert] = OptionType.alert
|
||||||
|
style: State = State.info
|
||||||
|
icon: Union[str, None] = None
|
||||||
|
|
||||||
def __init__(self, question):
|
def _get_prompt_message(self, value: None) -> str:
|
||||||
super().__init__(question)
|
colors = {
|
||||||
self.style = question.get("style", "info")
|
State.success: "green",
|
||||||
|
State.info: "cyan",
|
||||||
def _get_prompt_message(self) -> str:
|
State.warning: "yellow",
|
||||||
text = _value_for_locale(self.ask)
|
State.danger: "red",
|
||||||
|
|
||||||
if self.style in ["success", "info", "warning", "danger"]:
|
|
||||||
color = {
|
|
||||||
"success": "green",
|
|
||||||
"info": "cyan",
|
|
||||||
"warning": "yellow",
|
|
||||||
"danger": "red",
|
|
||||||
}
|
}
|
||||||
prompt = m18n.g(self.style) if self.style != "danger" else m18n.n("danger")
|
message = m18n.g(self.style) if self.style != State.danger else m18n.n("danger")
|
||||||
return colorize(prompt, color[self.style]) + f" {text}"
|
return f"{colorize(message, colors[self.style])} {_value_for_locale(self.ask)}"
|
||||||
else:
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonOption(BaseReadonlyOption):
|
class ButtonOption(BaseReadonlyOption):
|
||||||
type: Literal[OptionType.button] = OptionType.button
|
type: Literal[OptionType.button] = OptionType.button
|
||||||
enabled = True
|
help: Union[Translation, None] = None
|
||||||
|
style: State = State.success
|
||||||
def __init__(self, question):
|
icon: Union[str, None] = None
|
||||||
super().__init__(question)
|
enabled: Union[JSExpression, bool] = True
|
||||||
self.help = question.get("help")
|
|
||||||
self.style = question.get("style", "success")
|
|
||||||
self.enabled = question.get("enabled", True)
|
|
||||||
|
|
||||||
def is_enabled(self, context: Context) -> bool:
|
def is_enabled(self, context: Context) -> bool:
|
||||||
if isinstance(self.enabled, bool):
|
if isinstance(self.enabled, bool):
|
||||||
|
@ -337,16 +372,21 @@ class ButtonOption(BaseReadonlyOption):
|
||||||
|
|
||||||
|
|
||||||
class BaseInputOption(BaseOption):
|
class BaseInputOption(BaseOption):
|
||||||
hide_user_input_in_prompt = False
|
help: Union[Translation, None] = None
|
||||||
pattern: Optional[Dict] = None
|
example: Union[str, None] = None
|
||||||
|
placeholder: Union[str, None] = None
|
||||||
|
redact: bool = False
|
||||||
|
optional: bool = False # FIXME keep required as default?
|
||||||
|
default: Any = None
|
||||||
|
|
||||||
def __init__(self, question: Dict[str, Any]):
|
@validator("default", pre=True)
|
||||||
super().__init__(question)
|
def check_empty_default(value: Any) -> Any:
|
||||||
self.default = question.get("default", None)
|
if value == "":
|
||||||
self.optional = question.get("optional", False)
|
return None
|
||||||
self.pattern = question.get("pattern", self.pattern)
|
return value
|
||||||
self.help = question.get("help")
|
|
||||||
self.redact = question.get("redact", False)
|
# FIXME remove
|
||||||
|
def old__init__(self, question: Dict[str, Any]):
|
||||||
# .current_value is the currently stored value
|
# .current_value is the currently stored value
|
||||||
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
|
||||||
|
@ -354,10 +394,6 @@ class BaseInputOption(BaseOption):
|
||||||
# Use to return several values in case answer is in mutipart
|
# Use to return several values in case answer is in mutipart
|
||||||
self.values: Dict[str, Any] = {}
|
self.values: Dict[str, Any] = {}
|
||||||
|
|
||||||
# Empty value is parsed as empty string
|
|
||||||
if self.default == "":
|
|
||||||
self.default = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
return str(value)
|
return str(value)
|
||||||
|
@ -368,12 +404,12 @@ class BaseInputOption(BaseOption):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _get_prompt_message(self) -> str:
|
def _get_prompt_message(self, value: Any) -> str:
|
||||||
message = super()._get_prompt_message()
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
if self.readonly:
|
if self.readonly:
|
||||||
message = colorize(message, "purple")
|
message = colorize(message, "purple")
|
||||||
return f"{message} {self.humanize(self.current_value)}"
|
return f"{message} {self.humanize(value, self)}"
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
@ -418,7 +454,8 @@ class BaseInputOption(BaseOption):
|
||||||
|
|
||||||
|
|
||||||
class BaseStringOption(BaseInputOption):
|
class BaseStringOption(BaseInputOption):
|
||||||
default_value = ""
|
default: Union[str, None]
|
||||||
|
pattern: Union[Pattern, None] = None
|
||||||
|
|
||||||
|
|
||||||
class StringOption(BaseStringOption):
|
class StringOption(BaseStringOption):
|
||||||
|
@ -429,27 +466,23 @@ class TextOption(BaseStringOption):
|
||||||
type: Literal[OptionType.text] = OptionType.text
|
type: Literal[OptionType.text] = OptionType.text
|
||||||
|
|
||||||
|
|
||||||
|
FORBIDDEN_PASSWORD_CHARS = r"{}"
|
||||||
|
|
||||||
|
|
||||||
class PasswordOption(BaseInputOption):
|
class PasswordOption(BaseInputOption):
|
||||||
type: Literal[OptionType.password] = OptionType.password
|
type: Literal[OptionType.password] = OptionType.password
|
||||||
hide_user_input_in_prompt = True
|
example: Literal[None] = None
|
||||||
default_value = ""
|
default: Literal[None] = None
|
||||||
forbidden_chars = "{}"
|
redact: Literal[True] = True
|
||||||
|
_forbidden_chars: str = FORBIDDEN_PASSWORD_CHARS
|
||||||
def __init__(self, question):
|
|
||||||
super().__init__(question)
|
|
||||||
self.redact = True
|
|
||||||
if self.default is not None:
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_password_no_default", name=self.id
|
|
||||||
)
|
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
super()._value_pre_validator()
|
super()._value_pre_validator()
|
||||||
|
|
||||||
if self.value not in [None, ""]:
|
if self.value not in [None, ""]:
|
||||||
if any(char in self.value for char in self.forbidden_chars):
|
if any(char in self.value for char in self._forbidden_chars):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"pattern_password_app", forbidden_chars=self.forbidden_chars
|
"pattern_password_app", forbidden_chars=self._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
|
||||||
|
@ -458,26 +491,25 @@ class PasswordOption(BaseInputOption):
|
||||||
assert_password_is_strong_enough("user", self.value)
|
assert_password_is_strong_enough("user", self.value)
|
||||||
|
|
||||||
|
|
||||||
class ColorOption(BaseStringOption):
|
class ColorOption(BaseInputOption):
|
||||||
type: Literal[OptionType.color] = OptionType.color
|
type: Literal[OptionType.color] = OptionType.color
|
||||||
pattern = {
|
default: Union[str, None]
|
||||||
"regexp": r"^#[ABCDEFabcdef\d]{3,6}$",
|
# pattern = {
|
||||||
"error": "config_validate_color", # i18n: config_validate_color
|
# "regexp": r"^#[ABCDEFabcdef\d]{3,6}$",
|
||||||
}
|
# "error": "config_validate_color", # i18n: config_validate_color
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
# ─ NUMERIC ───────────────────────────────────────────────
|
# ─ NUMERIC ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class NumberOption(BaseInputOption):
|
class NumberOption(BaseInputOption):
|
||||||
|
# `number` and `range` are exactly the same, but `range` does render as a slider in web-admin
|
||||||
type: Literal[OptionType.number, OptionType.range] = OptionType.number
|
type: Literal[OptionType.number, OptionType.range] = OptionType.number
|
||||||
default_value = None
|
default: Union[int, None]
|
||||||
|
min: Union[int, None] = None
|
||||||
def __init__(self, question):
|
max: Union[int, None] = None
|
||||||
super().__init__(question)
|
step: Union[int, None] = None
|
||||||
self.min = question.get("min", None)
|
|
||||||
self.max = question.get("max", None)
|
|
||||||
self.step = question.get("step", None)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
|
@ -493,7 +525,7 @@ class NumberOption(BaseInputOption):
|
||||||
if value in [None, ""]:
|
if value in [None, ""]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
option = option.__dict__ if isinstance(option, BaseOption) else option
|
option = option.dict() if isinstance(option, BaseOption) else option
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid",
|
"app_argument_invalid",
|
||||||
name=option.get("id"),
|
name=option.get("id"),
|
||||||
|
@ -525,20 +557,15 @@ class NumberOption(BaseInputOption):
|
||||||
|
|
||||||
class BooleanOption(BaseInputOption):
|
class BooleanOption(BaseInputOption):
|
||||||
type: Literal[OptionType.boolean] = OptionType.boolean
|
type: Literal[OptionType.boolean] = OptionType.boolean
|
||||||
default_value = 0
|
yes: Any = 1
|
||||||
yes_answers = ["1", "yes", "y", "true", "t", "on"]
|
no: Any = 0
|
||||||
no_answers = ["0", "no", "n", "false", "f", "off"]
|
default: Union[bool, int, str, None] = 0
|
||||||
|
_yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"}
|
||||||
def __init__(self, question):
|
_no_answers: set[str] = {"0", "no", "n", "false", "f", "off"}
|
||||||
super().__init__(question)
|
|
||||||
self.yes = question.get("yes", 1)
|
|
||||||
self.no = question.get("no", 0)
|
|
||||||
if self.default is None:
|
|
||||||
self.default = self.no
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
option = option.__dict__ if isinstance(option, BaseOption) else option
|
option = option.dict() if isinstance(option, BaseOption) else option
|
||||||
|
|
||||||
yes = option.get("yes", 1)
|
yes = option.get("yes", 1)
|
||||||
no = option.get("no", 0)
|
no = option.get("no", 0)
|
||||||
|
@ -561,7 +588,7 @@ class BooleanOption(BaseInputOption):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
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 isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
|
@ -569,8 +596,8 @@ class BooleanOption(BaseInputOption):
|
||||||
technical_yes = option.get("yes", 1)
|
technical_yes = option.get("yes", 1)
|
||||||
technical_no = option.get("no", 0)
|
technical_no = option.get("no", 0)
|
||||||
|
|
||||||
no_answers = BooleanOption.no_answers
|
no_answers = BooleanOption._no_answers
|
||||||
yes_answers = BooleanOption.yes_answers
|
yes_answers = BooleanOption._yes_answers
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
str(technical_yes).lower() not in no_answers
|
str(technical_yes).lower() not in no_answers
|
||||||
|
@ -579,8 +606,8 @@ class BooleanOption(BaseInputOption):
|
||||||
str(technical_no).lower() not in yes_answers
|
str(technical_no).lower() not in yes_answers
|
||||||
), f"'no' value can't be in {yes_answers}"
|
), f"'no' value can't be in {yes_answers}"
|
||||||
|
|
||||||
no_answers += [str(technical_no).lower()]
|
no_answers.add(str(technical_no).lower())
|
||||||
yes_answers += [str(technical_yes).lower()]
|
yes_answers.add(str(technical_yes).lower())
|
||||||
|
|
||||||
strvalue = str(value).lower()
|
strvalue = str(value).lower()
|
||||||
|
|
||||||
|
@ -602,8 +629,8 @@ class BooleanOption(BaseInputOption):
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return getattr(self, key, default)
|
return getattr(self, key, default)
|
||||||
|
|
||||||
def _get_prompt_message(self):
|
def _get_prompt_message(self, value: Union[bool, None]) -> str:
|
||||||
message = super()._get_prompt_message()
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
if not self.readonly:
|
if not self.readonly:
|
||||||
message += " [yes | no]"
|
message += " [yes | no]"
|
||||||
|
@ -614,12 +641,13 @@ class BooleanOption(BaseInputOption):
|
||||||
# ─ TIME ──────────────────────────────────────────────────
|
# ─ TIME ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class DateOption(BaseStringOption):
|
class DateOption(BaseInputOption):
|
||||||
type: Literal[OptionType.date] = OptionType.date
|
type: Literal[OptionType.date] = OptionType.date
|
||||||
pattern = {
|
default: Union[str, None]
|
||||||
"regexp": r"^\d{4}-\d\d-\d\d$",
|
# pattern = {
|
||||||
"error": "config_validate_date", # i18n: config_validate_date
|
# "regexp": r"^\d{4}-\d\d-\d\d$",
|
||||||
}
|
# "error": "config_validate_date", # i18n: config_validate_date
|
||||||
|
# }
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -633,32 +661,34 @@ class DateOption(BaseStringOption):
|
||||||
raise YunohostValidationError("config_validate_date")
|
raise YunohostValidationError("config_validate_date")
|
||||||
|
|
||||||
|
|
||||||
class TimeOption(BaseStringOption):
|
class TimeOption(BaseInputOption):
|
||||||
type: Literal[OptionType.time] = OptionType.time
|
type: Literal[OptionType.time] = OptionType.time
|
||||||
pattern = {
|
default: Union[str, int, None]
|
||||||
"regexp": r"^(?:\d|[01]\d|2[0-3]):[0-5]\d$",
|
# pattern = {
|
||||||
"error": "config_validate_time", # i18n: config_validate_time
|
# "regexp": r"^(?:\d|[01]\d|2[0-3]):[0-5]\d$",
|
||||||
}
|
# "error": "config_validate_time", # i18n: config_validate_time
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
# ─ LOCATIONS ─────────────────────────────────────────────
|
# ─ LOCATIONS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class EmailOption(BaseStringOption):
|
class EmailOption(BaseInputOption):
|
||||||
type: Literal[OptionType.email] = OptionType.email
|
type: Literal[OptionType.email] = OptionType.email
|
||||||
pattern = {
|
default: Union[EmailStr, None]
|
||||||
"regexp": r"^.+@.+",
|
# pattern = {
|
||||||
"error": "config_validate_email", # i18n: config_validate_email
|
# "regexp": r"^.+@.+",
|
||||||
}
|
# "error": "config_validate_email", # i18n: config_validate_email
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
class WebPathOption(BaseInputOption):
|
class WebPathOption(BaseInputOption):
|
||||||
type: Literal[OptionType.path] = OptionType.path
|
type: Literal[OptionType.path] = OptionType.path
|
||||||
default_value = ""
|
default: Union[str, None]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
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 not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
|
@ -685,10 +715,11 @@ class WebPathOption(BaseInputOption):
|
||||||
|
|
||||||
class URLOption(BaseStringOption):
|
class URLOption(BaseStringOption):
|
||||||
type: Literal[OptionType.url] = OptionType.url
|
type: Literal[OptionType.url] = OptionType.url
|
||||||
pattern = {
|
default: Union[str, None]
|
||||||
"regexp": r"^https?://.*$",
|
# pattern = {
|
||||||
"error": "config_validate_url", # i18n: config_validate_url
|
# "regexp": r"^https?://.*$",
|
||||||
}
|
# "error": "config_validate_url", # i18n: config_validate_url
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
# ─ FILE ──────────────────────────────────────────────────
|
# ─ FILE ──────────────────────────────────────────────────
|
||||||
|
@ -696,16 +727,16 @@ class URLOption(BaseStringOption):
|
||||||
|
|
||||||
class FileOption(BaseInputOption):
|
class FileOption(BaseInputOption):
|
||||||
type: Literal[OptionType.file] = OptionType.file
|
type: Literal[OptionType.file] = OptionType.file
|
||||||
upload_dirs: List[str] = []
|
# `FilePath` for CLI (path must exists and must be a file)
|
||||||
|
# `bytes` for API (a base64 encoded file actually)
|
||||||
def __init__(self, question):
|
accept: Union[str, None] = "" # currently only used by the web-admin
|
||||||
super().__init__(question)
|
default: Union[str, None]
|
||||||
self.accept = question.get("accept", "")
|
_upload_dirs: set[str] = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clean_upload_dirs(cls):
|
def clean_upload_dirs(cls):
|
||||||
# Delete files uploaded from API
|
# Delete files uploaded from API
|
||||||
for upload_dir in cls.upload_dirs:
|
for upload_dir in cls._upload_dirs:
|
||||||
if os.path.exists(upload_dir):
|
if os.path.exists(upload_dir):
|
||||||
shutil.rmtree(upload_dir)
|
shutil.rmtree(upload_dir)
|
||||||
|
|
||||||
|
@ -738,7 +769,7 @@ class FileOption(BaseInputOption):
|
||||||
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 += [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 {self.id} for file question into {file_path}")
|
||||||
|
|
||||||
|
@ -760,26 +791,30 @@ class FileOption(BaseInputOption):
|
||||||
# ─ CHOICES ───────────────────────────────────────────────
|
# ─ CHOICES ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class BaseChoicesOption(BaseInputOption):
|
ChoosableOptions = Literal[
|
||||||
def __init__(
|
OptionType.string,
|
||||||
self,
|
OptionType.color,
|
||||||
question: Dict[str, Any],
|
OptionType.number,
|
||||||
):
|
OptionType.date,
|
||||||
super().__init__(question)
|
OptionType.time,
|
||||||
# Don't restrict choices if there's none specified
|
OptionType.email,
|
||||||
self.choices = question.get("choices", None)
|
OptionType.path,
|
||||||
|
OptionType.url,
|
||||||
|
]
|
||||||
|
|
||||||
def _get_prompt_message(self) -> str:
|
|
||||||
message = super()._get_prompt_message()
|
class BaseChoicesOption(BaseInputOption):
|
||||||
|
# FIXME probably forbid choices to be None?
|
||||||
|
choices: Union[dict[str, Any], list[Any], None]
|
||||||
|
|
||||||
|
def _get_prompt_message(self, value: Any) -> str:
|
||||||
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
if self.readonly:
|
if self.readonly:
|
||||||
message = message
|
if isinstance(self.choices, dict) and value is not None:
|
||||||
choice = self.current_value
|
value = self.choices[value]
|
||||||
|
|
||||||
if isinstance(self.choices, dict) and choice is not None:
|
return f"{colorize(message, 'purple')} {value}"
|
||||||
choice = self.choices[choice]
|
|
||||||
|
|
||||||
return f"{colorize(message, 'purple')} {choice}"
|
|
||||||
|
|
||||||
if self.choices:
|
if self.choices:
|
||||||
# Prevent displaying a shitload of choices
|
# Prevent displaying a shitload of choices
|
||||||
|
@ -789,17 +824,15 @@ class BaseChoicesOption(BaseInputOption):
|
||||||
if isinstance(self.choices, dict)
|
if isinstance(self.choices, dict)
|
||||||
else self.choices
|
else self.choices
|
||||||
)
|
)
|
||||||
choices_to_display = choices[:20]
|
splitted_choices = choices[:20]
|
||||||
remaining_choices = len(choices[20:])
|
remaining_choices = len(choices[20:])
|
||||||
|
|
||||||
if remaining_choices > 0:
|
if remaining_choices > 0:
|
||||||
choices_to_display += [
|
splitted_choices += [
|
||||||
m18n.n("other_available_options", n=remaining_choices)
|
m18n.n("other_available_options", n=remaining_choices)
|
||||||
]
|
]
|
||||||
|
|
||||||
choices_to_display = " | ".join(
|
choices_to_display = " | ".join(str(choice) for choice in splitted_choices)
|
||||||
str(choice) for choice in choices_to_display
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"{message} [{choices_to_display}]"
|
return f"{message} [{choices_to_display}]"
|
||||||
|
|
||||||
|
@ -821,12 +854,15 @@ class BaseChoicesOption(BaseInputOption):
|
||||||
|
|
||||||
class SelectOption(BaseChoicesOption):
|
class SelectOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.select] = OptionType.select
|
type: Literal[OptionType.select] = OptionType.select
|
||||||
default_value = ""
|
choices: Union[dict[str, Any], list[Any]]
|
||||||
|
default: Union[str, None]
|
||||||
|
|
||||||
|
|
||||||
class TagsOption(BaseChoicesOption):
|
class TagsOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.tags] = OptionType.tags
|
type: Literal[OptionType.tags] = OptionType.tags
|
||||||
default_value = ""
|
choices: Union[list[str], None] = None
|
||||||
|
pattern: Union[Pattern, None] = None
|
||||||
|
default: Union[str, list[str], None]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
|
@ -879,20 +915,24 @@ class TagsOption(BaseChoicesOption):
|
||||||
|
|
||||||
class DomainOption(BaseChoicesOption):
|
class DomainOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.domain] = OptionType.domain
|
type: Literal[OptionType.domain] = OptionType.domain
|
||||||
|
choices: Union[dict[str, str], None]
|
||||||
|
|
||||||
def __init__(self, question):
|
@root_validator()
|
||||||
from yunohost.domain import domain_list, _get_maindomain
|
def inject_domains_choices_and_default(cls, values: Values) -> Values:
|
||||||
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
||||||
|
from yunohost.domain import domain_list
|
||||||
|
|
||||||
super().__init__(question)
|
data = domain_list()
|
||||||
|
values["choices"] = {
|
||||||
if self.default is None:
|
domain: domain + " ★" if domain == data["main"] else domain
|
||||||
self.default = _get_maindomain()
|
for domain in data["domains"]
|
||||||
|
|
||||||
self.choices = {
|
|
||||||
domain: domain + " ★" if domain == self.default else domain
|
|
||||||
for domain in domain_list()["domains"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if values["default"] is None:
|
||||||
|
values["default"] = data["main"]
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
if value.startswith("https://"):
|
if value.startswith("https://"):
|
||||||
|
@ -908,87 +948,99 @@ class DomainOption(BaseChoicesOption):
|
||||||
|
|
||||||
class AppOption(BaseChoicesOption):
|
class AppOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.app] = OptionType.app
|
type: Literal[OptionType.app] = OptionType.app
|
||||||
|
choices: Union[dict[str, str], None]
|
||||||
|
add_yunohost_portal_to_choices: bool = False
|
||||||
|
filter: Union[str, None] = None
|
||||||
|
|
||||||
def __init__(self, question):
|
@root_validator()
|
||||||
|
def inject_apps_choices(cls, values: Values) -> Values:
|
||||||
from yunohost.app import app_list
|
from yunohost.app import app_list
|
||||||
|
|
||||||
super().__init__(question)
|
|
||||||
self.filter = question.get("filter", None)
|
|
||||||
self.add_yunohost_portal_to_choices = question.get("add_yunohost_portal_to_choices", False)
|
|
||||||
|
|
||||||
apps = app_list(full=True)["apps"]
|
apps = app_list(full=True)["apps"]
|
||||||
|
|
||||||
if self.filter:
|
if values.get("filter", None):
|
||||||
apps = [
|
apps = [
|
||||||
app
|
app
|
||||||
for app in apps
|
for app in apps
|
||||||
if evaluate_simple_js_expression(self.filter, context=app)
|
if evaluate_simple_js_expression(values["filter"], context=app)
|
||||||
]
|
]
|
||||||
|
values["choices"] = {"_none": "---"}
|
||||||
|
|
||||||
def _app_display(app):
|
if values.get("add_yunohost_portal_to_choices", False):
|
||||||
domain_path_or_id = f" ({app.get('domain_path', app['id'])})"
|
values["choices"]["_yunohost_portal_with_public_apps"] = "YunoHost's portal with public apps"
|
||||||
return app["label"] + domain_path_or_id
|
|
||||||
|
|
||||||
self.choices = {"_none": "---"}
|
values["choices"].update(
|
||||||
if self.add_yunohost_portal_to_choices:
|
{
|
||||||
# FIXME: i18n
|
app["id"]: f"{app['label']} ({app.get('domain_path', app['id'])})"
|
||||||
self.choices["_yunohost_portal_with_public_apps"] = "YunoHost's portal with public apps"
|
for app in apps
|
||||||
self.choices.update({app["id"]: _app_display(app) for app in apps})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class UserOption(BaseChoicesOption):
|
class UserOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.user] = OptionType.user
|
type: Literal[OptionType.user] = OptionType.user
|
||||||
|
choices: Union[dict[str, str], None]
|
||||||
|
|
||||||
def __init__(self, question):
|
@root_validator()
|
||||||
from yunohost.user import user_list, user_info
|
def inject_users_choices_and_default(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
from yunohost.domain import _get_maindomain
|
from yunohost.domain import _get_maindomain
|
||||||
|
from yunohost.user import user_info, user_list
|
||||||
|
|
||||||
super().__init__(question)
|
values["choices"] = {
|
||||||
|
|
||||||
self.choices = {
|
|
||||||
username: f"{infos['fullname']} ({infos['mail']})"
|
username: f"{infos['fullname']} ({infos['mail']})"
|
||||||
for username, infos in user_list()["users"].items()
|
for username, infos in user_list()["users"].items()
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self.choices:
|
# FIXME keep this to test if any user, do not raise error if no admin?
|
||||||
|
if not values["choices"]:
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid",
|
"app_argument_invalid",
|
||||||
name=self.id,
|
name=values["id"],
|
||||||
error="You should create a YunoHost user first.",
|
error="You should create a YunoHost user first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.default is None:
|
if values["default"] is None:
|
||||||
# FIXME: this code is obsolete with the new admins group
|
# FIXME: this code is obsolete with the new admins group
|
||||||
# Should be replaced by something like "any first user we find in the admin group"
|
# Should be replaced by something like "any first user we find in the admin group"
|
||||||
root_mail = "root@%s" % _get_maindomain()
|
root_mail = "root@%s" % _get_maindomain()
|
||||||
for user in self.choices.keys():
|
for user in values["choices"].keys():
|
||||||
if root_mail in user_info(user).get("mail-aliases", []):
|
if root_mail in user_info(user).get("mail-aliases", []):
|
||||||
self.default = user
|
values["default"] = user
|
||||||
break
|
break
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class GroupOption(BaseChoicesOption):
|
class GroupOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.group] = OptionType.group
|
type: Literal[OptionType.group] = OptionType.group
|
||||||
|
choices: Union[dict[str, str], None]
|
||||||
|
|
||||||
def __init__(self, question):
|
@root_validator()
|
||||||
|
def inject_groups_choices_and_default(cls, values: Values) -> Values:
|
||||||
from yunohost.user import user_group_list
|
from yunohost.user import user_group_list
|
||||||
|
|
||||||
super().__init__(question)
|
groups = user_group_list(short=True, include_primary_groups=False)["groups"]
|
||||||
|
|
||||||
self.choices = list(
|
def _human_readable_group(groupname):
|
||||||
user_group_list(short=True, include_primary_groups=False)["groups"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _human_readable_group(g):
|
|
||||||
# i18n: visitors
|
# i18n: visitors
|
||||||
# i18n: all_users
|
# i18n: all_users
|
||||||
# i18n: admins
|
# i18n: admins
|
||||||
return m18n.n(g) if g in ["visitors", "all_users", "admins"] else g
|
return (
|
||||||
|
m18n.n(groupname)
|
||||||
|
if groupname in ["visitors", "all_users", "admins"]
|
||||||
|
else groupname
|
||||||
|
)
|
||||||
|
|
||||||
self.choices = {g: _human_readable_group(g) for g in self.choices}
|
values["choices"] = {
|
||||||
|
groupname: _human_readable_group(groupname) for groupname in groups
|
||||||
|
}
|
||||||
|
|
||||||
if self.default is None:
|
if values["default"] is None:
|
||||||
self.default = "all_users"
|
values["default"] = "all_users"
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
OPTIONS = {
|
OPTIONS = {
|
||||||
|
@ -997,7 +1049,7 @@ OPTIONS = {
|
||||||
OptionType.alert: AlertOption,
|
OptionType.alert: AlertOption,
|
||||||
OptionType.button: ButtonOption,
|
OptionType.button: ButtonOption,
|
||||||
OptionType.string: StringOption,
|
OptionType.string: StringOption,
|
||||||
OptionType.text: StringOption,
|
OptionType.text: TextOption,
|
||||||
OptionType.password: PasswordOption,
|
OptionType.password: PasswordOption,
|
||||||
OptionType.color: ColorOption,
|
OptionType.color: ColorOption,
|
||||||
OptionType.number: NumberOption,
|
OptionType.number: NumberOption,
|
||||||
|
|
Loading…
Add table
Reference in a new issue