form: use pydantic BaseModel in Options and add some validators

This commit is contained in:
axolotle 2023-04-13 20:11:03 +02:00
parent 5bd8680847
commit f5c56db10e

View file

@ -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,