mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
Merge branch 'dev' into rework-authenticator-system
This commit is contained in:
commit
e2cb8cdfab
18 changed files with 271 additions and 385 deletions
|
@ -45,13 +45,6 @@ Dev Documentation
|
|||
|
||||
https://moulinette.readthedocs.org
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* Python 2.7
|
||||
* python-bottle (>= 0.10)
|
||||
* python-ldap (>= 2.4)
|
||||
* PyYAML
|
||||
|
||||
Testing
|
||||
-------
|
||||
|
|
60
debian/changelog
vendored
60
debian/changelog
vendored
|
@ -1,8 +1,62 @@
|
|||
moulinette (4.2) unstable; urgency=low
|
||||
moulinette (4.2.3.3) stable; urgency=low
|
||||
|
||||
- Placeholder for 4.2 to satisfy CI / debian build during dev
|
||||
- [fix] Damn array args bug (2c9ec9f6)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Wed, 20 Jan 2021 05:19:58 +0100
|
||||
Thanks to all contributors <3 ! (ljf)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Thu, 03 Jun 2021 18:40:18 +0200
|
||||
|
||||
moulinette (4.2.3.2) stable; urgency=low
|
||||
|
||||
- [fix] wait 1s for message in call_async_output, prevent CPU overload ([#275](https://github.com/YunoHost/moulinette/pull/275))
|
||||
- [i18n] Translations updated for Chinese (Simplified)
|
||||
|
||||
Thanks to all contributors <3 ! (Kayou, yahoo~~)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Wed, 02 Jun 2021 20:23:31 +0200
|
||||
|
||||
moulinette (4.2.3.1) stable; urgency=low
|
||||
|
||||
- [fix] Request params not decoded ([#277](https://github.com/YunoHost/moulinette/pull/277))
|
||||
|
||||
Thanks to all contributors <3 ! (ljf)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Tue, 25 May 2021 18:59:01 +0200
|
||||
|
||||
moulinette (4.2.3) stable; urgency=low
|
||||
|
||||
- [fix] Unicode password doesn't log in ([#276](https://github.com/YunoHost/moulinette/pull/276))
|
||||
- [i18n] Translations updated for Chinese (Simplified), Galician
|
||||
|
||||
Thanks to all contributors <3 ! (José M, ljf, yahoo~~)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Mon, 24 May 2021 17:34:19 +0200
|
||||
|
||||
moulinette (4.2.2) stable; urgency=low
|
||||
|
||||
- [i18n] Translations updated for French, Hungarian
|
||||
- Release as stable
|
||||
|
||||
Thanks to all contributors <3 ! (Dominik Blahó, Éric Gaspar)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Sat, 08 May 2021 15:10:01 +0200
|
||||
|
||||
moulinette (4.2.1) testing; urgency=low
|
||||
|
||||
- Fix weird technical thing in actionmap sucategories loading, related to recent changes in Yunohost actionmap (135fae95)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Sat, 17 Apr 2021 04:58:10 +0200
|
||||
|
||||
moulinette (4.2.0) testing; urgency=low
|
||||
|
||||
- [mod] Python2 -> python3 ([#228](https://github.com/YunoHost/moulinette/pull/228), 8e70561f, 570e5323, 3758b811, 90f894b5, [#269](https://github.com/YunoHost/moulinette/pull/269), e85b9f71, cafe68f3)
|
||||
- [mod] Code formatting, test fixing, cleanup (677efcf6, 0de15467, [#268](https://github.com/YunoHost/moulinette/pull/268), affb54f8, f7199f7a, d6f82c91)
|
||||
- [enh] Improve error semantic such that the webadmin shall be able to redirect to the proper log view ([#257](https://github.com/YunoHost/moulinette/pull/257), [#271](https://github.com/YunoHost/moulinette/pull/271))
|
||||
- [fix] Simpler and more consistent logging initialization ([#263](https://github.com/YunoHost/moulinette/pull/263))
|
||||
|
||||
Thanks to all contributors <3 ! (Kay0u, Laurent Peuch)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> Fri, 19 Mar 2021 18:39:42 +0100
|
||||
|
||||
moulinette (4.1.4) stable; urgency=low
|
||||
|
||||
|
|
|
@ -1,181 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" License
|
||||
|
||||
Copyright (C) 2013 YunoHost
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program; if not, see http://www.gnu.org/licenses
|
||||
|
||||
"""
|
||||
|
||||
"""
|
||||
Generate JSON specification files API
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import json
|
||||
import requests
|
||||
from yunohost import str_to_func, __version__
|
||||
|
||||
def main():
|
||||
"""
|
||||
|
||||
"""
|
||||
with open('action_map.yml') as f:
|
||||
action_map = yaml.load(f)
|
||||
|
||||
try:
|
||||
with open('/etc/yunohost/current_host', 'r') as f:
|
||||
domain = f.readline().rstrip()
|
||||
except IOError:
|
||||
domain = requests.get('http://ip.yunohost.org').text
|
||||
|
||||
with open('action_map.yml') as f:
|
||||
action_map = yaml.load(f)
|
||||
|
||||
resource_list = {
|
||||
'apiVersion': __version__,
|
||||
'swaggerVersion': '1.1',
|
||||
'basePath': 'http://'+ domain + ':6767',
|
||||
'apis': []
|
||||
}
|
||||
|
||||
resources = {}
|
||||
|
||||
del action_map['general_arguments']
|
||||
for category, category_params in action_map.items():
|
||||
if 'category_help' not in category_params: category_params['category_help'] = ''
|
||||
resource_path = '/api/'+ category
|
||||
resource_list['apis'].append({
|
||||
'path': resource_path,
|
||||
'description': category_params['category_help']
|
||||
})
|
||||
resources[category] = {
|
||||
'apiVersion': __version__,
|
||||
'swaggerVersion': '1.1',
|
||||
'basePath': 'http://'+ domain + ':6767',
|
||||
'apis': []
|
||||
}
|
||||
|
||||
resources[category]['resourcePath'] = resource_path
|
||||
|
||||
registered_paths = {}
|
||||
|
||||
for action, action_params in category_params['actions'].items():
|
||||
if 'action_help' not in action_params:
|
||||
action_params['action_help'] = ''
|
||||
if 'api' not in action_params:
|
||||
action_params['api'] = 'GET /'+ category +'/'+ action
|
||||
|
||||
method, path = action_params['api'].split(' ')
|
||||
key_param = ''
|
||||
if '{' in path:
|
||||
key_param = path[path.find("{")+1:path.find("}")]
|
||||
|
||||
notes = ''
|
||||
if str_to_func('yunohost_'+ category +'.'+ category +'_'+ action) is None:
|
||||
notes = 'Not yet implemented'
|
||||
|
||||
operation = {
|
||||
'httpMethod': method,
|
||||
'nickname': category +'_'+ action,
|
||||
'summary': action_params['action_help'],
|
||||
'notes': notes,
|
||||
'errorResponses': []
|
||||
}
|
||||
|
||||
if 'arguments' in action_params:
|
||||
operation['parameters'] = []
|
||||
for arg_name, arg_params in action_params['arguments'].items():
|
||||
if 'help' not in arg_params:
|
||||
arg_params['help'] = ''
|
||||
param_type = 'query'
|
||||
allow_multiple = False
|
||||
required = True
|
||||
allowable_values = None
|
||||
name = arg_name.replace('-', '_')
|
||||
if name[0] == '_':
|
||||
required = False
|
||||
if 'full' in arg_params:
|
||||
name = arg_params['full'][2:]
|
||||
else:
|
||||
name = name[2:]
|
||||
name = name.replace('-', '_')
|
||||
|
||||
if 'nargs' in arg_params:
|
||||
if arg_params['nargs'] == '*':
|
||||
allow_multiple = True
|
||||
required = False
|
||||
if arg_params['nargs'] == '+':
|
||||
allow_multiple = True
|
||||
required = True
|
||||
else:
|
||||
allow_multiple = False
|
||||
if 'choices' in arg_params:
|
||||
allowable_values = {
|
||||
'valueType': 'LIST',
|
||||
'values': arg_params['choices']
|
||||
}
|
||||
if 'action' in arg_params and arg_params['action'] == 'store_true':
|
||||
allowable_values = {
|
||||
'valueType': 'LIST',
|
||||
'values': ['true', 'false']
|
||||
}
|
||||
|
||||
if name == key_param:
|
||||
param_type = 'path'
|
||||
required = True
|
||||
allow_multiple = False
|
||||
|
||||
parameters = {
|
||||
'paramType': param_type,
|
||||
'name': name,
|
||||
'description': arg_params['help'],
|
||||
'dataType': 'string',
|
||||
'required': required,
|
||||
'allowMultiple': allow_multiple
|
||||
}
|
||||
if allowable_values is not None:
|
||||
parameters['allowableValues'] = allowable_values
|
||||
|
||||
operation['parameters'].append(parameters)
|
||||
|
||||
|
||||
if path in registered_paths:
|
||||
resources[category]['apis'][registered_paths[path]]['operations'].append(operation)
|
||||
resources[category]['apis'][registered_paths[path]]['description'] = ''
|
||||
else:
|
||||
registered_paths[path] = len(resources[category]['apis'])
|
||||
resources[category]['apis'].append({
|
||||
'path': path,
|
||||
'description': action_params['action_help'],
|
||||
'operations': [operation]
|
||||
})
|
||||
|
||||
|
||||
try: os.listdir(os.getcwd() +'/doc')
|
||||
except OSError: os.makedirs(os.getcwd() +'/doc')
|
||||
|
||||
for category, api_dict in resources.items():
|
||||
with open(os.getcwd() +'/doc/'+ category +'.json', 'w') as f:
|
||||
json.dump(api_dict, f)
|
||||
|
||||
with open(os.getcwd() +'/doc/resources.json', 'w') as f:
|
||||
json.dump(resource_list, f)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
|
@ -1,114 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" License
|
||||
|
||||
Copyright (C) 2013 YunoHost
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program; if not, see http://www.gnu.org/licenses
|
||||
|
||||
"""
|
||||
|
||||
"""
|
||||
Generate function header documentation
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import re
|
||||
|
||||
def main():
|
||||
"""
|
||||
|
||||
"""
|
||||
with open('action_map.yml') as f:
|
||||
action_map = yaml.load(f)
|
||||
|
||||
resources = {}
|
||||
|
||||
del action_map['general_arguments']
|
||||
for category, category_params in action_map.items():
|
||||
if 'category_help' not in category_params: category_params['category_help'] = ''
|
||||
|
||||
with open('yunohost_'+ category +'.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
with open('yunohost_'+ category +'.py', 'w') as f:
|
||||
in_block = False
|
||||
for line in lines:
|
||||
if in_block:
|
||||
if re.search(r'^"""', line):
|
||||
in_block = False
|
||||
f.write('\n')
|
||||
f.write(' '+ category_params['category_help'] +'\n')
|
||||
f.write('"""\n')
|
||||
else:
|
||||
f.write(line)
|
||||
|
||||
if re.search(r'^""" yunohost_'+ category, line):
|
||||
in_block = True
|
||||
|
||||
for action, action_params in category_params['actions'].items():
|
||||
if 'action_help' not in action_params:
|
||||
action_params['action_help'] = ''
|
||||
|
||||
help_lines = [
|
||||
' """',
|
||||
' '+ action_params['action_help'],
|
||||
''
|
||||
]
|
||||
|
||||
if 'arguments' in action_params:
|
||||
help_lines.append(' Keyword argument:')
|
||||
for arg_name, arg_params in action_params['arguments'].items():
|
||||
if 'help' in arg_params:
|
||||
help = ' -- '+ arg_params['help']
|
||||
else:
|
||||
help = ''
|
||||
name = arg_name.replace('-', '_')
|
||||
if name[0] == '_':
|
||||
required = False
|
||||
if 'full' in arg_params:
|
||||
name = arg_params['full'][2:]
|
||||
else:
|
||||
name = name[2:]
|
||||
name = name.replace('-', '_')
|
||||
|
||||
help_lines.append(' '+ name + help)
|
||||
|
||||
help_lines.append('')
|
||||
help_lines.append(' """')
|
||||
|
||||
with open('yunohost_'+ category +'.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
with open('yunohost_'+ category +'.py', 'w') as f:
|
||||
in_block = False
|
||||
first_quotes = True
|
||||
for line in lines:
|
||||
if in_block:
|
||||
if re.search(r'^ """', line):
|
||||
if first_quotes:
|
||||
first_quotes = False
|
||||
else:
|
||||
in_block = False
|
||||
for help_line in help_lines:
|
||||
f.write(help_line +'\n')
|
||||
else:
|
||||
f.write(line)
|
||||
|
||||
if re.search(r'^def '+ category +'_'+ action +'\(', line):
|
||||
in_block = True
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
|
@ -7,11 +7,11 @@
|
|||
"confirm": "确认 {prompt}",
|
||||
"deprecated_command": "{prog}{command}已经放弃使用,将来会删除",
|
||||
"deprecated_command_alias": "{prog}{old}已经放弃使用,将来会删除,请使用{prog}{new}代替",
|
||||
"error": "错误:",
|
||||
"error": "错误:",
|
||||
"error_see_log": "发生错误。请参看日志文件获取错误详情,日志文件位于 /var/log/yunohost/。",
|
||||
"file_exists": "文件已存在:{path}",
|
||||
"file_not_exist": "文件不存在:{path}",
|
||||
"folder_exists": "目录已存在:{path}",
|
||||
"file_exists": "文件已存在: '{path}'",
|
||||
"file_not_exist": "文件不存在: '{path}'",
|
||||
"folder_exists": "目录已存在: '{path}'",
|
||||
"folder_not_exist": "目录不存在",
|
||||
"info": "信息:",
|
||||
"instance_already_running": "已经有一个YunoHost操作正在运行。 请等待它完成再运行另一个。",
|
||||
|
|
|
@ -1,3 +1,58 @@
|
|||
{
|
||||
"password": "Heslo"
|
||||
"password": "Heslo",
|
||||
"logged_out": "Jste odhlášen/a",
|
||||
"ldap_server_is_down_restart_it": "LDAP služba neběží, probíhá pokus o její nastartování...",
|
||||
"warn_the_user_that_lock_is_acquired": "Předchozí operace dokončena, nyní spouštíme tuto",
|
||||
"warn_the_user_about_waiting_lock_again": "Stále čekáme...",
|
||||
"warn_the_user_about_waiting_lock": "Jiná YunoHost operace právě probíhá, před spuštěním této čekáme na její dokončení",
|
||||
"command_unknown": "Příkaz '{command:s}' neznámý?",
|
||||
"download_bad_status_code": "{url:s} vrátil stavový kód {code:s}",
|
||||
"download_unknown_error": "Chyba při stahování dat z {url:s}: {error:s}",
|
||||
"download_timeout": "{url:s} příliš dlouho neodpovídá, akce přerušena.",
|
||||
"download_ssl_error": "SSL chyba při spojení s {url:s}",
|
||||
"invalid_url": "Špatný odkaz {url:s} (je vůbec dostupný?)",
|
||||
"error_changing_file_permissions": "Chyba při nastavování oprávnění pro {path:s}: {error:s}",
|
||||
"error_removing": "Chyba při přesunu {path:s}: {error:s}",
|
||||
"error_writing_file": "Chyba při zápisu souboru/ů {file:s}: {error:s}",
|
||||
"corrupted_toml": "Nepodařilo se načíst TOML z {ressource:s} (reason: {error:s})",
|
||||
"corrupted_yaml": "Nepodařilo se načíst YAML z {ressource:s} (reason: {error:s})",
|
||||
"corrupted_json": "Nepodařilo se načíst JSON {ressource:s} (reason: {error:s})",
|
||||
"unknown_error_reading_file": "Vyskytla se neznámá chyba při čtení souboru/ů {file:s} (reason: {error:s})",
|
||||
"cannot_write_file": "Nelze zapsat soubor/y {file:s} (reason: {error:s})",
|
||||
"cannot_open_file": "Nelze otevřít soubor/y {file:s} (reason: {error:s})",
|
||||
"websocket_request_expected": "Očekáván WebSocket požadavek",
|
||||
"warning": "Varování:",
|
||||
"values_mismatch": "Hodnoty nesouhlasí",
|
||||
"unknown_user": "Neznámý '{user}' uživatel",
|
||||
"unknown_group": "Neznámá '{group}' skupina",
|
||||
"session_expired": "Sezení vypršelo. Přihlašte se znovu, prosím.",
|
||||
"unable_retrieve_session": "Není možné obdržet sezení neboť '{exception}'",
|
||||
"unable_authenticate": "Není možné ověřit",
|
||||
"success": "Zadařilo se!",
|
||||
"server_already_running": "Na tomto portu je server již provozován",
|
||||
"root_required": "Pro provedení této akce musíte být root",
|
||||
"pattern_not_match": "Neodpovídá výrazu",
|
||||
"operation_interrupted": "Operace přerušena",
|
||||
"not_logged_in": "Nejste přihlášen",
|
||||
"logged_in": "Přihlášení",
|
||||
"ldap_server_down": "Spojení s LDAP serverem se nezdařilo",
|
||||
"ldap_attribute_already_exists": "Atribut '{attribute}' již obsahuje hodnotu '{value}'",
|
||||
"invalid_usage": "Nesprávné použití, pass --help pro zobrazení nápovědy",
|
||||
"invalid_token": "Nesprávný token - ověřte se prosím",
|
||||
"invalid_password": "Nesprávné heslo",
|
||||
"invalid_argument": "Nesprávný argument '{argument}': {error}",
|
||||
"instance_already_running": "Právě probíhá jiná YunoHost operace. Před spuštěním další operace vyčkejte na její dokončení.",
|
||||
"info": "Info:",
|
||||
"folder_not_exist": "Adresář neexistuje",
|
||||
"folder_exists": "Adresář již existuje: '{path}'",
|
||||
"file_not_exist": "Soubor neexistuje: '{path}'",
|
||||
"file_exists": "Soubor již existuje: '{path}'",
|
||||
"error": "Chyba:",
|
||||
"deprecated_command_alias": "'{prog} {old}' je zastaralý a bude odebrán v budoucích verzích, použijte '{prog} {new}'",
|
||||
"deprecated_command": "'{prog} {command}' je zastaralý a bude odebrán v budoucích verzích",
|
||||
"confirm": "Potvrdit {prompt}",
|
||||
"colon": "{}: ",
|
||||
"authentication_required_long": "K provedení této akce je vyžadováno ověření",
|
||||
"authentication_required": "Vyžadováno ověření",
|
||||
"argument_required": "Je vyžadován argument '{argument}'"
|
||||
}
|
||||
|
|
1
locales/fi.json
Normal file
1
locales/fi.json
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
58
locales/gl.json
Normal file
58
locales/gl.json
Normal file
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"ldap_attribute_already_exists": "O atributo '{attribute}' xa existe e ten o valor '{value}'",
|
||||
"invalid_usage": "Uso non válido, pass --help para ver a axuda",
|
||||
"invalid_token": "Token non válido - por favor autentícate",
|
||||
"invalid_password": "Contrasinal non válido",
|
||||
"invalid_argument": "Argumento non válido '{argument}': {error}",
|
||||
"instance_already_running": "Hai unha operación de YunoHost en execución. Por favor agarda a que remate antes de realizar unha nova.",
|
||||
"info": "Info:",
|
||||
"folder_not_exist": "O cartafol non existe",
|
||||
"folder_exists": "Xa existe o cartafol: '{path}'",
|
||||
"file_not_exist": "Non existe o ficheiro: '{path}'",
|
||||
"file_exists": "Xa existe o ficheiro: '{path}'",
|
||||
"error": "Erro:",
|
||||
"deprecated_command_alias": "'{prog} {old}' xa non se utiliza e será eliminado no futuro, usa '{prog} {new}' no seu lugar",
|
||||
"deprecated_command": "'{prog} {command}' xa non se utiliza e xa non se usará no futuro",
|
||||
"confirm": "Confirma {prompt}",
|
||||
"colon": "{}: ",
|
||||
"authentication_required_long": "Requírese autenticación para realizar esta acción",
|
||||
"authentication_required": "Autenticación requerida",
|
||||
"argument_required": "O argumento '{argument}' é requerido",
|
||||
"logged_out": "Sesión pechada",
|
||||
"password": "Contrasinal",
|
||||
"warning": "Aviso:",
|
||||
"values_mismatch": "Non concordan os valores",
|
||||
"unknown_user": "Usuaria '{user}' descoñecida",
|
||||
"unknown_group": "Grupo '{group}' descoñecido",
|
||||
"session_expired": "A sesión caducou. Volve a conectar por favor.",
|
||||
"unable_retrieve_session": "Non se puido obter a sesión porque '{exception}'",
|
||||
"unable_authenticate": "Non se puido autenticar",
|
||||
"success": "Ben feito!",
|
||||
"server_already_running": "Xa hai un servidor a funcionar nese porto",
|
||||
"root_required": "Tes que ser root para facer esta acción",
|
||||
"pattern_not_match": "Non concorda co patrón",
|
||||
"operation_interrupted": "Interrumpeuse a operación",
|
||||
"not_logged_in": "Non estás conectada",
|
||||
"logged_in": "Conectada",
|
||||
"ldap_server_down": "Non se puido acadar o servidor LDAP",
|
||||
"ldap_server_is_down_restart_it": "O servizo LDAP está caído, intentando reinicialo...",
|
||||
"warn_the_user_that_lock_is_acquired": "O outro comando rematou, agora executarase este",
|
||||
"warn_the_user_about_waiting_lock_again": "Agardando...",
|
||||
"warn_the_user_about_waiting_lock": "Estase executando outro comando de YunoHost neste intre, estamos agardando a que remate para executar este",
|
||||
"command_unknown": "Comando '{command:s}' descoñecido?",
|
||||
"download_bad_status_code": "{url:s} devolveu o código de estado {code:s}",
|
||||
"download_unknown_error": "Erro ao descargar os datos desde {url:s}: {error:s}",
|
||||
"download_timeout": "{url:s} está tardando en responder, deixámolo.",
|
||||
"download_ssl_error": "Erro SSL ao conectar con {url:s}",
|
||||
"invalid_url": "URL non válido {url:s} (existe esta web?)",
|
||||
"error_changing_file_permissions": "Erro ao cambiar os permisos de {path:s}: {error:s}",
|
||||
"error_removing": "Erro ao eliminar {path:s}: {error:s}",
|
||||
"error_writing_file": "Erro ao escribir o ficheiro {file:s}: {error:s}",
|
||||
"corrupted_toml": "Lectura corrupta de datos TOML de {ressource:s} (razón: {error:s})",
|
||||
"corrupted_yaml": "Lectura corrupta dos datos YAML de {ressource:s} (razón: {error:s})",
|
||||
"corrupted_json": "Lectura corrupta dos datos JSON de {ressource:s} (razón: {error:s})",
|
||||
"unknown_error_reading_file": "Erro descoñecido ao intentar ler o ficheiro {file:s} (razón: {error:s})",
|
||||
"cannot_write_file": "Non se puido escribir o ficheiro {file:s} (razón: {error:s})",
|
||||
"cannot_open_file": "Non se puido abrir o ficheiro {file:s} (razón: {error:s})",
|
||||
"websocket_request_expected": "Agardábase unha solicitude WebSocket"
|
||||
}
|
|
@ -6,14 +6,14 @@
|
|||
"colon": "{}: ",
|
||||
"confirm": "पुष्टि करें {prompt}",
|
||||
"deprecated_command": "'{prog}' '{command}' का प्रयोग न करे, भविष्य में इसे हटा दिया जाएगा",
|
||||
"deprecated_command_alias": "'{prog} {old}' का प्रयोग न करे ,भविष्य में इसे हटा दिया जाएगा।इस की जगह '{prog} {new}' का प्रोयोग करे।",
|
||||
"deprecated_command_alias": "'{prog} {old}' अब पुराना हो गया है और इसे भविष्य में हटा दिया जाएगा, इस की जगह '{prog} {new}' का प्रयोग करें",
|
||||
"error": "गलती:",
|
||||
"error_see_log": "एक त्रुटि पाई गई। कृपया विवरण के लिए लॉग देखें।",
|
||||
"file_exists": "फ़ाइल पहले से ही मौजूद है:'{path}'",
|
||||
"file_not_exist": "फ़ाइल मौजूद नहीं है: '{path}'",
|
||||
"folder_exists": "फ़ोल्डर में पहले से ही मौजूद है: '{path}'",
|
||||
"folder_not_exist": "फ़ोल्डर मौजूद नहीं है।",
|
||||
"instance_already_running": "आवृत्ति पहले से चल रही है।",
|
||||
"folder_not_exist": "फ़ोल्डर मौजूद नहीं है",
|
||||
"instance_already_running": "यूनोहोस्ट का एक कार्य पहले से चल रहा है। कृपया इस कार्य के समाप्त होने का इंतज़ार करें।",
|
||||
"invalid_argument": "अवैध तर्क '{argument}':'{error}'",
|
||||
"invalid_password": "अवैध पासवर्ड",
|
||||
"invalid_usage": "अवैध उपयोग, सहायता देखने के लिए --help साथ लिखे।",
|
||||
|
@ -35,5 +35,6 @@
|
|||
"unknown_user": "अज्ञात उपयोगकर्ता: '{user}'",
|
||||
"values_mismatch": "वैल्यूज मेल नहीं खाती।",
|
||||
"warning": "चेतावनी:",
|
||||
"websocket_request_expected": "एक WebSocket अनुरोध की उम्मीद।"
|
||||
"websocket_request_expected": "एक WebSocket अनुरोध की उम्मीद।",
|
||||
"info": "सूचना:"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,19 @@
|
|||
{
|
||||
"logged_out": "Kilépett",
|
||||
"password": "Jelszó"
|
||||
"password": "Jelszó",
|
||||
"download_timeout": "{url:s} régóta nem válaszol, folyamat megszakítva.",
|
||||
"invalid_url": "Helytelen URL: {url:s} (biztos létezik az oldal?)",
|
||||
"cannot_open_file": "{file:s} megnyitása sikertelen (Oka: {error:s})",
|
||||
"unknown_user": "Ismeretlen felhasználó: '{user}'",
|
||||
"unknown_group": "Ismeretlen csoport: '{group}'",
|
||||
"server_already_running": "Egy szerver már fut ezen a porton",
|
||||
"logged_in": "Bejelentkezve",
|
||||
"success": "Siker!",
|
||||
"values_mismatch": "Eltérő értékek",
|
||||
"warning": "Figyelem:",
|
||||
"invalid_password": "Helytelen jelszó",
|
||||
"info": "Információ:",
|
||||
"file_not_exist": "A fájl nem létezik: '{path}'",
|
||||
"file_exists": "A fájl már létezik: '{path}'",
|
||||
"error": "Hiba:"
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"logged_out": "Wylogowano",
|
||||
"password": "Hasło",
|
||||
"warn_the_user_that_lock_is_acquired": "drugie polecenie właśnie się zakończyło, teraz uruchamiając to polecenie",
|
||||
"warn_the_user_that_lock_is_acquired": "Inne polecenie właśnie się zakończyło, teraz uruchamiam to polecenie",
|
||||
"warn_the_user_about_waiting_lock_again": "Wciąż czekam...",
|
||||
"warn_the_user_about_waiting_lock": "Kolejne polecenie YunoHost jest teraz uruchomione, czekamy na jego zakończenie przed uruchomieniem tego",
|
||||
"command_unknown": "Polecenie „{command:s}” jest nieznane?",
|
||||
"command_unknown": "Polecenie '{command:s}' jest nieznane?",
|
||||
"download_bad_status_code": "{url:s} zwrócił kod stanu {code:s}",
|
||||
"download_unknown_error": "Błąd podczas pobierania danych z {url:s}: {error:s}",
|
||||
"download_timeout": "{url:s} odpowiedział zbyt długo, poddał się.",
|
||||
|
@ -13,17 +13,17 @@
|
|||
"error_changing_file_permissions": "Błąd podczas zmiany uprawnień dla {path:s}: {error:s}",
|
||||
"error_removing": "Błąd podczas usuwania {path:s}: {error:s}",
|
||||
"error_writing_file": "Błąd podczas zapisywania pliku {file:s}: {error:s}",
|
||||
"corrupted_toml": "Uszkodzony toml z {ressource: s} (powód: {error:s})",
|
||||
"corrupted_yaml": "Uszkodzony yaml odczytany z {ressource:s} (powód: {error:s})",
|
||||
"corrupted_json": "Uszkodzony json odczytany z {ressource:s} (powód: {error:s})",
|
||||
"corrupted_toml": "Uszkodzony TOML z {ressource: s} (reason: {error:s})",
|
||||
"corrupted_yaml": "Uszkodzony YAML odczytany z {ressource:s} (reason: {error:s})",
|
||||
"corrupted_json": "Uszkodzony JSON odczytany z {ressource:s} (reason: {error:s})",
|
||||
"unknown_error_reading_file": "Nieznany błąd podczas próby odczytania pliku {file:s} (przyczyna: {error:s})",
|
||||
"cannot_write_file": "Nie można zapisać pliku {file:s} (przyczyna: {error:s})",
|
||||
"cannot_open_file": "Nie można otworzyć pliku {file:s} (przyczyna: {error:s})",
|
||||
"websocket_request_expected": "Oczekiwano żądania WebSocket",
|
||||
"warning": "Ostrzeżenie:",
|
||||
"values_mismatch": "Wartości nie pasują",
|
||||
"unknown_user": "Nieznany użytkownik „{user}”",
|
||||
"unknown_group": "Nieznana grupa „{group}”",
|
||||
"unknown_user": "Nieznany użytkownik '{user}'",
|
||||
"unknown_group": "Nieznana grupa '{group}'",
|
||||
"unable_retrieve_session": "Nie można pobrać sesji, ponieważ „{exception}”",
|
||||
"unable_authenticate": "Nie można uwierzytelnić",
|
||||
"success": "Sukces!",
|
||||
|
@ -53,5 +53,7 @@
|
|||
"colon": "{}: ",
|
||||
"authentication_required_long": "Do wykonania tej czynności wymagane jest uwierzytelnienie",
|
||||
"authentication_required": "Wymagane uwierzytelnienie",
|
||||
"argument_required": "Argument „{argument}” jest wymagany"
|
||||
"argument_required": "Argument „{argument}” jest wymagany",
|
||||
"ldap_server_is_down_restart_it": "Usługa LDAP nie działa, próba restartu...",
|
||||
"session_expired": "Sesja wygasła. Zaloguj się ponownie."
|
||||
}
|
||||
|
|
|
@ -14,7 +14,12 @@ from importlib import import_module
|
|||
from moulinette import m18n, msignals
|
||||
from moulinette.cache import open_cachefile
|
||||
from moulinette.globals import init_moulinette_env
|
||||
from moulinette.core import MoulinetteError, MoulinetteLock
|
||||
from moulinette.core import (
|
||||
MoulinetteError,
|
||||
MoulinetteLock,
|
||||
MoulinetteAuthenticationError,
|
||||
MoulinetteValidationError,
|
||||
)
|
||||
from moulinette.interfaces import BaseActionsMapParser, GLOBAL_SECTION, TO_RETURN_PROP
|
||||
from moulinette.utils.log import start_action_logging
|
||||
|
||||
|
@ -207,7 +212,9 @@ class PatternParameter(_ExtraParameter):
|
|||
if msg == message:
|
||||
msg = m18n.g(message)
|
||||
|
||||
raise MoulinetteError("invalid_argument", argument=arg_name, error=msg)
|
||||
raise MoulinetteValidationError(
|
||||
"invalid_argument", argument=arg_name, error=msg
|
||||
)
|
||||
return arg_value
|
||||
|
||||
@staticmethod
|
||||
|
@ -238,7 +245,7 @@ class RequiredParameter(_ExtraParameter):
|
|||
def __call__(self, required, arg_name, arg_value):
|
||||
if required and (arg_value is None or arg_value == ""):
|
||||
logger.warning("argument '%s' is required", arg_name)
|
||||
raise MoulinetteError("argument_required", argument=arg_name)
|
||||
raise MoulinetteValidationError("argument_required", argument=arg_name)
|
||||
return arg_value
|
||||
|
||||
@staticmethod
|
||||
|
@ -491,7 +498,7 @@ class ActionsMap(object):
|
|||
|
||||
authenticator = self.get_authenticator(auth_method)
|
||||
if not msignals.authenticate(authenticator):
|
||||
raise MoulinetteError("authentication_required_long")
|
||||
raise MoulinetteAuthenticationError("authentication_required_long")
|
||||
|
||||
def process(self, args, timeout=None, **kwargs):
|
||||
"""
|
||||
|
@ -771,6 +778,9 @@ class ActionsMap(object):
|
|||
# No parser for the action
|
||||
continue
|
||||
|
||||
if action_parser is None: # No parser for the action
|
||||
continue
|
||||
|
||||
# Store action identifier and add arguments
|
||||
action_parser.set_defaults(_tid=tid)
|
||||
action_parser.add_arguments(
|
||||
|
|
|
@ -6,7 +6,7 @@ import hashlib
|
|||
import hmac
|
||||
|
||||
from moulinette.cache import open_cachefile, get_cachedir, cachefile_exists
|
||||
from moulinette.core import MoulinetteError
|
||||
from moulinette.core import MoulinetteError, MoulinetteAuthenticationError
|
||||
|
||||
logger = logging.getLogger("moulinette.authenticator")
|
||||
|
||||
|
@ -80,7 +80,7 @@ class BaseAuthenticator(object):
|
|||
raise
|
||||
except Exception as e:
|
||||
logger.exception("authentication {self.name} failed because '{e}'")
|
||||
raise MoulinetteError("unable_authenticate")
|
||||
raise MoulinetteAuthenticationError("unable_authenticate")
|
||||
else:
|
||||
is_authenticated = True
|
||||
|
||||
|
@ -109,7 +109,7 @@ class BaseAuthenticator(object):
|
|||
raise
|
||||
except Exception as e:
|
||||
logger.exception("authentication {self.name} failed because '{e}'")
|
||||
raise MoulinetteError("unable_authenticate")
|
||||
raise MoulinetteAuthenticationError("unable_authenticate")
|
||||
else:
|
||||
is_authenticated = True
|
||||
|
||||
|
@ -117,7 +117,7 @@ class BaseAuthenticator(object):
|
|||
# No credentials given, can't authenticate
|
||||
#
|
||||
else:
|
||||
raise MoulinetteError("unable_authenticate")
|
||||
raise MoulinetteAuthenticationError("unable_authenticate")
|
||||
|
||||
self.is_authenticated = is_authenticated
|
||||
return is_authenticated
|
||||
|
@ -146,7 +146,7 @@ class BaseAuthenticator(object):
|
|||
def _authenticate_session(self, session_id, session_token):
|
||||
"""Checks session and token against the stored session token"""
|
||||
if not self._session_exists(session_id):
|
||||
raise MoulinetteError("session_expired")
|
||||
raise MoulinetteAuthenticationError("session_expired")
|
||||
try:
|
||||
# FIXME : shouldn't we also add a check that this session file
|
||||
# is not too old ? e.g. not older than 24 hours ? idk...
|
||||
|
@ -155,7 +155,7 @@ class BaseAuthenticator(object):
|
|||
stored_hash = f.read()
|
||||
except IOError as e:
|
||||
logger.debug("unable to retrieve session", exc_info=1)
|
||||
raise MoulinetteError("unable_retrieve_session", exception=e)
|
||||
raise MoulinetteAuthenticationError("unable_retrieve_session", exception=e)
|
||||
else:
|
||||
#
|
||||
# session_id (or just id) : This is unique id for the current session from the user. Not too important
|
||||
|
@ -177,7 +177,7 @@ class BaseAuthenticator(object):
|
|||
hash_ = hashlib.sha256(to_hash).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(hash_, stored_hash):
|
||||
raise MoulinetteError("invalid_token")
|
||||
raise MoulinetteAuthenticationError("invalid_token")
|
||||
else:
|
||||
return
|
||||
|
||||
|
|
|
@ -370,6 +370,8 @@ class MoulinetteSignals(object):
|
|||
|
||||
class MoulinetteError(Exception):
|
||||
|
||||
http_code = 500
|
||||
|
||||
"""Moulinette base exception"""
|
||||
|
||||
def __init__(self, key, raw_msg=False, *args, **kwargs):
|
||||
|
@ -384,6 +386,16 @@ class MoulinetteError(Exception):
|
|||
return self.strerror
|
||||
|
||||
|
||||
class MoulinetteValidationError(MoulinetteError):
|
||||
|
||||
http_code = 400
|
||||
|
||||
|
||||
class MoulinetteAuthenticationError(MoulinetteError):
|
||||
|
||||
http_code = 401
|
||||
|
||||
|
||||
class MoulinetteLock(object):
|
||||
|
||||
"""Locker for a moulinette instance
|
||||
|
|
|
@ -15,7 +15,7 @@ from bottle import abort
|
|||
|
||||
from moulinette import msignals, m18n, env
|
||||
from moulinette.actionsmap import ActionsMap
|
||||
from moulinette.core import MoulinetteError
|
||||
from moulinette.core import MoulinetteError, MoulinetteValidationError
|
||||
from moulinette.interfaces import (
|
||||
BaseActionsMapParser,
|
||||
BaseInterface,
|
||||
|
@ -207,8 +207,7 @@ class _HTTPArgumentParser(object):
|
|||
return self._parser.dequeue_callbacks(*args, **kwargs)
|
||||
|
||||
def _error(self, message):
|
||||
# TODO: Raise a proper exception
|
||||
raise MoulinetteError(message, raw_msg=True)
|
||||
raise MoulinetteValidationError(message, raw_msg=True)
|
||||
|
||||
|
||||
class _ActionsMapPlugin(object):
|
||||
|
@ -250,9 +249,9 @@ class _ActionsMapPlugin(object):
|
|||
def wrapper():
|
||||
kwargs = {}
|
||||
try:
|
||||
kwargs["password"] = request.POST["password"]
|
||||
kwargs["password"] = request.POST.password
|
||||
except KeyError:
|
||||
raise HTTPBadRequestResponse("Missing password parameter")
|
||||
raise HTTPResponse("Missing password parameter", 400)
|
||||
|
||||
kwargs["profile"] = request.POST.get("profile", self.actionsmap.default_authentication)
|
||||
return callback(**kwargs)
|
||||
|
@ -321,7 +320,7 @@ class _ActionsMapPlugin(object):
|
|||
for a in args:
|
||||
params[a] = True
|
||||
# Append other request params
|
||||
for k, v in request.params.dict.items():
|
||||
for k, v in request.params.decode().dict.items():
|
||||
v = _format(v)
|
||||
if k not in params.keys():
|
||||
params[k] = v
|
||||
|
@ -387,7 +386,7 @@ class _ActionsMapPlugin(object):
|
|||
self.logout(profile)
|
||||
except:
|
||||
pass
|
||||
raise HTTPUnauthorizedResponse(e.strerror)
|
||||
raise HTTPResponse(e.strerror, 401)
|
||||
else:
|
||||
# Update dicts with new values
|
||||
s_tokens[profile] = s_new_token
|
||||
|
@ -420,7 +419,7 @@ class _ActionsMapPlugin(object):
|
|||
if profile not in request.get_cookie(
|
||||
"session.tokens", secret=s_secret, default={}
|
||||
):
|
||||
raise HTTPUnauthorizedResponse(m18n.g("not_logged_in"))
|
||||
raise HTTPResponse(m18n.g("not_logged_in"), 401)
|
||||
else:
|
||||
del self.secrets[s_id]
|
||||
authenticator = self.actionsmap.get_authenticator(profile)
|
||||
|
@ -448,7 +447,7 @@ class _ActionsMapPlugin(object):
|
|||
|
||||
wsock = request.environ.get("wsgi.websocket")
|
||||
if not wsock:
|
||||
raise HTTPErrorResponse(m18n.g("websocket_request_expected"))
|
||||
raise HTTPResponse(m18n.g("websocket_request_expected"), 500)
|
||||
|
||||
while True:
|
||||
item = queue.get()
|
||||
|
@ -485,7 +484,7 @@ class _ActionsMapPlugin(object):
|
|||
try:
|
||||
ret = self.actionsmap.process(arguments, timeout=30, route=_route)
|
||||
except MoulinetteError as e:
|
||||
raise HTTPBadRequestResponse(e)
|
||||
raise moulinette_error_to_http_response(e)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPResponse):
|
||||
raise e
|
||||
|
@ -493,7 +492,7 @@ class _ActionsMapPlugin(object):
|
|||
|
||||
tb = traceback.format_exc()
|
||||
logs = {"route": _route, "arguments": arguments, "traceback": tb}
|
||||
return HTTPErrorResponse(json_encode(logs))
|
||||
return HTTPResponse(json_encode(logs), 500)
|
||||
else:
|
||||
return format_for_response(ret)
|
||||
finally:
|
||||
|
@ -521,7 +520,7 @@ class _ActionsMapPlugin(object):
|
|||
]
|
||||
except KeyError:
|
||||
msg = m18n.g("authentication_required")
|
||||
raise HTTPUnauthorizedResponse(msg)
|
||||
raise HTTPResponse(msg, 401)
|
||||
else:
|
||||
return authenticator(token=(s_id, s_token))
|
||||
|
||||
|
@ -548,36 +547,17 @@ class _ActionsMapPlugin(object):
|
|||
# HTTP Responses -------------------------------------------------------
|
||||
|
||||
|
||||
class HTTPOKResponse(HTTPResponse):
|
||||
def __init__(self, output=""):
|
||||
super(HTTPOKResponse, self).__init__(output, 200)
|
||||
def moulinette_error_to_http_response(error):
|
||||
|
||||
|
||||
class HTTPBadRequestResponse(HTTPResponse):
|
||||
def __init__(self, error=""):
|
||||
|
||||
if isinstance(error, MoulinetteError):
|
||||
content = error.content()
|
||||
if isinstance(content, dict):
|
||||
super(HTTPBadRequestResponse, self).__init__(
|
||||
return HTTPResponse(
|
||||
json_encode(content),
|
||||
400,
|
||||
error.http_code,
|
||||
headers={"Content-type": "application/json"},
|
||||
)
|
||||
else:
|
||||
super(HTTPBadRequestResponse, self).__init__(content, 400)
|
||||
else:
|
||||
super(HTTPBadRequestResponse, self).__init__(error, 400)
|
||||
|
||||
|
||||
class HTTPUnauthorizedResponse(HTTPResponse):
|
||||
def __init__(self, output=""):
|
||||
super(HTTPUnauthorizedResponse, self).__init__(output, 401)
|
||||
|
||||
|
||||
class HTTPErrorResponse(HTTPResponse):
|
||||
def __init__(self, output=""):
|
||||
super(HTTPErrorResponse, self).__init__(output, 500)
|
||||
return HTTPResponse(content, error.http_code)
|
||||
|
||||
|
||||
def format_for_response(content):
|
||||
|
@ -692,7 +672,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
|||
except KeyError as e:
|
||||
error_message = "no argument parser found for route '%s': %s" % (route, e)
|
||||
logger.error(error_message)
|
||||
raise MoulinetteError(error_message, raw_msg=True)
|
||||
raise MoulinetteValidationError(error_message, raw_msg=True)
|
||||
|
||||
return parser.authentication
|
||||
|
||||
|
@ -709,7 +689,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
|||
except KeyError as e:
|
||||
error_message = "no argument parser found for route '%s': %s" % (route, e)
|
||||
logger.error(error_message)
|
||||
raise MoulinetteError(error_message, raw_msg=True)
|
||||
raise MoulinetteValidationError(error_message, raw_msg=True)
|
||||
ret = argparse.Namespace()
|
||||
|
||||
# TODO: Catch errors?
|
||||
|
|
|
@ -13,7 +13,7 @@ import argcomplete
|
|||
|
||||
from moulinette import msignals, m18n
|
||||
from moulinette.actionsmap import ActionsMap
|
||||
from moulinette.core import MoulinetteError
|
||||
from moulinette.core import MoulinetteError, MoulinetteValidationError
|
||||
from moulinette.interfaces import (
|
||||
BaseActionsMapParser,
|
||||
BaseInterface,
|
||||
|
@ -411,7 +411,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
|||
e,
|
||||
)
|
||||
logger.exception(error_message)
|
||||
raise MoulinetteError(error_message, raw_msg=True)
|
||||
raise MoulinetteValidationError(error_message, raw_msg=True)
|
||||
|
||||
tid = getattr(ret, "_tid", None)
|
||||
|
||||
|
@ -443,7 +443,7 @@ class ActionsMapParser(BaseActionsMapParser):
|
|||
e,
|
||||
)
|
||||
logger.exception(error_message)
|
||||
raise MoulinetteError(error_message, raw_msg=True)
|
||||
raise MoulinetteValidationError(error_message, raw_msg=True)
|
||||
else:
|
||||
self.prepare_action_namespace(getattr(ret, "_tid", None), ret)
|
||||
self._parser.dequeue_callbacks(ret)
|
||||
|
@ -494,7 +494,7 @@ class Interface(BaseInterface):
|
|||
|
||||
"""
|
||||
if output_as and output_as not in ["json", "plain", "none"]:
|
||||
raise MoulinetteError("invalid_usage")
|
||||
raise MoulinetteValidationError("invalid_usage")
|
||||
|
||||
# auto-complete
|
||||
argcomplete.autocomplete(self.actionsmap.parser._parser)
|
||||
|
@ -558,7 +558,7 @@ class Interface(BaseInterface):
|
|||
if confirm:
|
||||
m = message[0].lower() + message[1:]
|
||||
if prompt(m18n.g("confirm", prompt=m)) != value:
|
||||
raise MoulinetteError("values_mismatch")
|
||||
raise MoulinetteValidationError("values_mismatch")
|
||||
|
||||
return value
|
||||
|
||||
|
|
|
@ -77,7 +77,7 @@ def call_async_output(args, callback, **kwargs):
|
|||
|
||||
while True:
|
||||
try:
|
||||
callback, message = log_queue.get_nowait()
|
||||
callback, message = log_queue.get(True, 1)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue