From 2315e811fa1f418a832ca9b8f732aeb23c18e4b7 Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 30 Nov 2020 10:58:50 +0100 Subject: [PATCH 01/48] [wip] Import users with a CSV --- data/actionsmap/yunohost.yml | 17 +++++++++++++++++ src/yunohost/user.py | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index b2f5a349b..075e429ec 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -206,6 +206,23 @@ user: arguments: username: help: Username or email to get information + + ### user_import() + import: + action_help: Import several users from CSV + api: POST /users/import + arguments: + csv: + help: "CSV file with columns username, email, quota, groups(separated by coma) and optionally password" + type: open + -u: + full: --update + help: Update all existing users contained in the csv file (by default those users are ignored) + action: store_true + -d: + full: --delete + help: Delete all existing users that are not contained in the csv file (by default those users are ignored) + action: store_true subcategories: group: diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 266c2774c..7b920b8a9 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -566,6 +566,17 @@ def user_info(username): return result_dict +def user_import(csv, update=False, delete=False): + """ + Import users from CSV + + Keyword argument: + csv -- CSV file with columns username, email, quota, groups and optionnally password + + """ + logger.warning(type(csv)) + return {} + # # Group subcategory # From fdc2337e0f9ccc624ac1d293d98f5714ee8a844e Mon Sep 17 00:00:00 2001 From: ljf Date: Thu, 3 Dec 2020 18:04:09 +0100 Subject: [PATCH 02/48] [wip] Import users by csv --- src/yunohost/user.py | 95 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 7b920b8a9..a9010c060 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -566,7 +566,8 @@ def user_info(username): return result_dict -def user_import(csv, update=False, delete=False): +@is_unit_operation() +def user_import(operation_logger, csv, update=False, delete=False): """ Import users from CSV @@ -574,8 +575,96 @@ def user_import(csv, update=False, delete=False): csv -- CSV file with columns username, email, quota, groups and optionnally password """ - logger.warning(type(csv)) - return {} + import csv # CSV are needed only in this function + + # Prepare what should be done + actions = { + 'created': [], + 'updated': [], + 'deleted': [] + } + is_well_formatted = True + + existing_users = user_list()['users'].keys() + reader = csv.DictReader(csv, delimiter=';', quotechar='"') + for user in reader: + if user['username']:#TODO better check + logger.error(m18n.n('user_import_bad_line', line=reader.line_num)) + is_well_formatted = False + continue + + if user['username'] not in existing_users: + actions['created'].append(user) + else: + if update: + actions['updated'].append(user) + existing_users.remove(user['username']) + + if delete: + for user in existing_users: + actions['deleted'].append(user) + + if not is_well_formatted: + raise YunohostError('user_import_bad_file') + + total = len(actions['created'] + actions['updated'] + actions['deleted']) + + # Apply creation, update and deletion operation + result = { + 'created': 0, + 'updated': 0, + 'deleted': 0, + 'errors': 0 + } + + if total == 0: + logger.info(m18n.n('nothing_to_do')) + return + + def on_failure(user, exception): + result['errors'] += 1 + logger.error(user + ': ' + str(exception)) + + operation_logger.start() + for user in actions['created']: + try: + user_create(operation_logger, user['username'], + user['firstname'], user['lastname'], + user['domain'], user['password'], + user['mailbox_quota'], user['mail']) + result['created'] += 1 + except Exception as e: + on_failure(user['username'], e) + + if update: + for user in actions['updated']: + try: + user_update(operation_logger, user['username'], + user['firstname'], user['lastname'], + user['mail'], user['password'], + mailbox_quota=user['mailbox_quota']) + result['updated'] += 1 + except Exception as e: + on_failure(user['username'], e) + + if delete: + for user in actions['deleted']: + try: + user_delete(operation_logger, user, purge=True) + result['deleted'] += 1 + except Exception as e: + on_failure(user, e) + + if result['errors']: + msg = m18n.n('user_import_partial_failed') + if result['created'] + result['updated'] + result['deleted'] == 0: + msg = m18n.n('user_import_failed') + logger.error(msg) + operation_logger.error(msg) + else: + logger.success(m18n.n('user_import_success')) + operation_logger.success() + return result # # Group subcategory From 2ae0ec46f44a55eb98dbdca90711ab6708912604 Mon Sep 17 00:00:00 2001 From: ljf Date: Tue, 8 Dec 2020 16:47:28 +0100 Subject: [PATCH 03/48] [wip] Import users from csv --- src/yunohost/user.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index a9010c060..7745ec56a 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -588,7 +588,7 @@ def user_import(operation_logger, csv, update=False, delete=False): existing_users = user_list()['users'].keys() reader = csv.DictReader(csv, delimiter=';', quotechar='"') for user in reader: - if user['username']:#TODO better check + if re.match(r'^[a-z0-9_]+$', user['username']:#TODO better check logger.error(m18n.n('user_import_bad_line', line=reader.line_num)) is_well_formatted = False continue @@ -636,6 +636,7 @@ def user_import(operation_logger, csv, update=False, delete=False): except Exception as e: on_failure(user['username'], e) +<<<<<<< Updated upstream if update: for user in actions['updated']: try: @@ -654,6 +655,24 @@ def user_import(operation_logger, csv, update=False, delete=False): result['deleted'] += 1 except Exception as e: on_failure(user, e) +======= + for user in actions['updated']: + try: + user_update(operation_logger, user['username'], + user['firstname'], user['lastname'], + user['mail'], user['password'], + mailbox_quota=user['mailbox_quota']) + result['updated'] += 1 + except Exception as e: + on_failure(user['username'], e) + + for user in actions['deleted']: + try: + user_delete(operation_logger, user, purge=True) + result['deleted'] += 1 + except Exception as e: + on_failure(user, e) +>>>>>>> Stashed changes if result['errors']: msg = m18n.n('user_import_partial_failed') From 3e047c4b943881c1e8a14311006773cc583893bb Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 13 Dec 2020 03:24:18 +0100 Subject: [PATCH 04/48] [fix] CSV import --- data/actionsmap/yunohost.yml | 2 +- locales/en.json | 5 + src/yunohost/log.py | 3 + src/yunohost/user.py | 265 ++++++++++++++++++++++------------- 4 files changed, 173 insertions(+), 102 deletions(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 075e429ec..a3ff431e7 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -212,7 +212,7 @@ user: action_help: Import several users from CSV api: POST /users/import arguments: - csv: + csvfile: help: "CSV file with columns username, email, quota, groups(separated by coma) and optionally password" type: open -u: diff --git a/locales/en.json b/locales/en.json index 938a38e20..367183a8a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -400,6 +400,7 @@ "log_regen_conf": "Regenerate system configurations '{}'", "log_user_create": "Add '{}' user", "log_user_delete": "Delete '{}' user", + "log_user_import": "Import users", "log_user_group_create": "Create '{}' group", "log_user_group_delete": "Delete '{}' group", "log_user_group_update": "Update '{}' group", @@ -630,6 +631,10 @@ "user_unknown": "Unknown user: {user:s}", "user_update_failed": "Could not update user {user}: {error}", "user_updated": "User info changed", + "user_import_bad_line": "Incorrect line {line}: {details} ", + "user_import_partial_failed": "The users import operation partially failed", + "user_import_failed": "The users import operation completely failed", + "user_import_success": "Users have been imported", "yunohost_already_installed": "YunoHost is already installed", "yunohost_configured": "YunoHost is now configured", "yunohost_installing": "Installing YunoHost...", diff --git a/src/yunohost/log.py b/src/yunohost/log.py index f8da40002..9ea2c2024 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -371,6 +371,9 @@ def is_unit_operation( for field in exclude: if field in context: context.pop(field, None) + for field, value in context.items(): + if isinstance(value, file): + context[field] = value.name operation_logger = OperationLogger(op_key, related_to, args=context) try: diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 7745ec56a..0489a34fa 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -99,6 +99,7 @@ def user_create( password, mailbox_quota="0", mail=None, + imported=False ): from yunohost.domain import domain_list, _get_maindomain @@ -167,7 +168,8 @@ def user_create( if mail in aliases: raise YunohostValidationError("mail_unavailable") - operation_logger.start() + if not imported: + operation_logger.start() # Get random UID/GID all_uid = {str(x.pw_uid) for x in pwd.getpwall()} @@ -247,13 +249,14 @@ def user_create( hook_callback("post_user_create", args=[username, mail], env=env_dict) # TODO: Send a welcome mail to user - logger.success(m18n.n("user_created")) + if not imported: + logger.success(m18n.n('user_created')) return {"fullname": fullname, "username": username, "mail": mail} -@is_unit_operation([("username", "user")]) -def user_delete(operation_logger, username, purge=False): +@is_unit_operation([('username', 'user')]) +def user_delete(operation_logger, username, purge=False, imported=False): """ Delete user @@ -268,7 +271,8 @@ def user_delete(operation_logger, username, purge=False): if username not in user_list()["users"]: raise YunohostValidationError("user_unknown", user=username) - operation_logger.start() + if not imported: + operation_logger.start() user_group_update("all_users", remove=username, force=True, sync_perm=False) for group, infos in user_group_list()["groups"].items(): @@ -295,13 +299,13 @@ def user_delete(operation_logger, username, purge=False): subprocess.call(["nscd", "-i", "passwd"]) if purge: - subprocess.call(["rm", "-rf", "/home/{0}".format(username)]) - subprocess.call(["rm", "-rf", "/var/mail/{0}".format(username)]) + subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) + subprocess.call(['rm', '-rf', '/var/mail/{0}'.format(username)]) - hook_callback("post_user_delete", args=[username, purge]) - - logger.success(m18n.n("user_deleted")) + hook_callback('post_user_delete', args=[username, purge]) + if not imported: + logger.success(m18n.n('user_deleted')) @is_unit_operation([("username", "user")], exclude=["change_password"]) def user_update( @@ -316,6 +320,7 @@ def user_update( add_mailalias=None, remove_mailalias=None, mailbox_quota=None, + imported=False ): """ Update user informations @@ -394,34 +399,38 @@ def user_update( "admin@" + main_domain, "webmaster@" + main_domain, "postmaster@" + main_domain, + 'abuse@' + main_domain, ] - try: - ldap.validate_uniqueness({"mail": mail}) - except Exception as e: - raise YunohostValidationError("user_update_failed", user=username, error=e) - if mail[mail.find("@") + 1 :] not in domains: - raise YunohostValidationError( - "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] - ) + if mail in user['mail']: + user['mail'].remove(mail) + else: + try: + ldap.validate_uniqueness({'mail': mail}) + except Exception as e: + raise YunohostError('user_update_failed', user=username, error=e) + if mail[mail.find('@') + 1:] not in domains: + raise YunohostError('mail_domain_unknown', domain=mail[mail.find('@') + 1:]) if mail in aliases: raise YunohostValidationError("mail_unavailable") - del user["mail"][0] - new_attr_dict["mail"] = [mail] + user["mail"] + new_attr_dict['mail'] = [mail] + user['mail'][1:] if add_mailalias: if not isinstance(add_mailalias, list): add_mailalias = [add_mailalias] for mail in add_mailalias: - try: - ldap.validate_uniqueness({"mail": mail}) - except Exception as e: - raise YunohostValidationError( - "user_update_failed", user=username, error=e - ) - if mail[mail.find("@") + 1 :] not in domains: - raise YunohostValidationError( - "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] + if mail in user["mail"]: + user["mail"].remove(mail) + else: + try: + ldap.validate_uniqueness({"mail": mail}) + except Exception as e: + raise YunohostError( + "user_update_failed", user=username, error=e + ) + if mail[mail.find("@") + 1:] not in domains: + raise YunohostError( + "mail_domain_unknown", domain=mail[mail.find("@") + 1:] ) user["mail"].append(mail) new_attr_dict["mail"] = user["mail"] @@ -465,7 +474,8 @@ def user_update( new_attr_dict["mailuserquota"] = [mailbox_quota] env_dict["YNH_USER_MAILQUOTA"] = mailbox_quota - operation_logger.start() + if not imported: + operation_logger.start() try: ldap.update("uid=%s,ou=users" % username, new_attr_dict) @@ -475,9 +485,10 @@ def user_update( # Trigger post_user_update hooks hook_callback("post_user_update", env=env_dict) - logger.success(m18n.n("user_updated")) - app_ssowatconf() - return user_info(username) + if not imported: + app_ssowatconf() + logger.success(m18n.n('user_updated')) + return user_info(username) def user_info(username): @@ -507,11 +518,13 @@ def user_info(username): raise YunohostValidationError("user_unknown", user=username) result_dict = { - "username": user["uid"][0], - "fullname": user["cn"][0], - "firstname": user["givenName"][0], - "lastname": user["sn"][0], - "mail": user["mail"][0], + 'username': user['uid'][0], + 'fullname': user['cn'][0], + 'firstname': user['givenName'][0], + 'lastname': user['sn'][0], + 'mail': user['mail'][0], + 'mail-aliases': [], + 'mail-forward': [] } if len(user["mail"]) > 1: @@ -567,7 +580,7 @@ def user_info(username): @is_unit_operation() -def user_import(operation_logger, csv, update=False, delete=False): +def user_import(operation_logger, csvfile, update=False, delete=False): """ Import users from CSV @@ -576,24 +589,51 @@ def user_import(operation_logger, csv, update=False, delete=False): """ import csv # CSV are needed only in this function - - # Prepare what should be done + from moulinette.utils.text import random_ascii + from yunohost.permission import permission_sync_to_user + from yunohost.app import app_ssowatconf + # Pre-validate data and prepare what should be done actions = { 'created': [], 'updated': [], 'deleted': [] } is_well_formatted = True - + validators = { + 'username': r'^[a-z0-9_]+$', + 'firstname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) + 'lastname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', + 'password': r'^|(.{3,})$', + 'mailbox_quota': r'^(\d+[bkMGT])|0$', + 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', + 'alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' + } + def to_list(str_list): + return str_list.split(',') if str_list else [] existing_users = user_list()['users'].keys() - reader = csv.DictReader(csv, delimiter=';', quotechar='"') + reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') for user in reader: - if re.match(r'^[a-z0-9_]+$', user['username']:#TODO better check - logger.error(m18n.n('user_import_bad_line', line=reader.line_num)) + format_errors = [key + ':' + user[key] + for key, validator in validators.items() + if not re.match(validator, user[key])] + if format_errors: + logger.error(m18n.n('user_import_bad_line', + line=reader.line_num, + details=', '.join(format_errors))) is_well_formatted = False continue + user['groups'] = to_list(user['groups']) + user['alias'] = to_list(user['alias']) + user['forward'] = to_list(user['forward']) + user['domain'] = user['mail'].split('@')[1] if user['username'] not in existing_users: + # Generate password if not exists + # This could be used when reset password will be merged + if not user['password']: + user['password'] = random_ascii(70) actions['created'].append(user) else: if update: @@ -609,6 +649,10 @@ def user_import(operation_logger, csv, update=False, delete=False): total = len(actions['created'] + actions['updated'] + actions['deleted']) + if total == 0: + logger.info(m18n.n('nothing_to_do')) + return + # Apply creation, update and deletion operation result = { 'created': 0, @@ -617,62 +661,71 @@ def user_import(operation_logger, csv, update=False, delete=False): 'errors': 0 } - if total == 0: - logger.info(m18n.n('nothing_to_do')) - return - def on_failure(user, exception): result['errors'] += 1 logger.error(user + ': ' + str(exception)) + def update(user, created=False): + remove_alias = None + remove_forward = None + if not created: + info = user_info(user['username']) + user['mail'] = None if info['mail'] == user['mail'] else user['mail'] + remove_alias = list(set(info['mail-aliases']) - set(user['alias'])) + remove_forward = list(set(info['mail-forward']) - set(user['forward'])) + user['alias'] = list(set(user['alias']) - set(info['mail-aliases'])) + user['forward'] = list(set(user['forward']) - set(info['mail-forward'])) + for group, infos in user_group_list()["groups"].items(): + if group == "all_users": + continue + # If the user is in this group (and it's not the primary group), + # remove the member from the group + if user['username'] != group and user['username'] in infos["members"]: + user_group_update(group, remove=user['username'], sync_perm=False, imported=True) + + user_update(user['username'], + user['firstname'], user['lastname'], + user['mail'], user['password'], + mailbox_quota=user['mailbox_quota'], + mail=user['mail'], add_mailalias=user['alias'], + remove_mailalias=remove_alias, + remove_mailforward=remove_forward, + add_mailforward=user['forward'], imported=True) + + for group in user['groups']: + user_group_update(group, add=user['username'], sync_perm=False, imported=True) + operation_logger.start() - for user in actions['created']: - try: - user_create(operation_logger, user['username'], - user['firstname'], user['lastname'], - user['domain'], user['password'], - user['mailbox_quota'], user['mail']) - result['created'] += 1 - except Exception as e: - on_failure(user['username'], e) - -<<<<<<< Updated upstream - if update: - for user in actions['updated']: - try: - user_update(operation_logger, user['username'], - user['firstname'], user['lastname'], - user['mail'], user['password'], - mailbox_quota=user['mailbox_quota']) - result['updated'] += 1 - except Exception as e: - on_failure(user['username'], e) - - if delete: - for user in actions['deleted']: - try: - user_delete(operation_logger, user, purge=True) - result['deleted'] += 1 - except Exception as e: - on_failure(user, e) -======= - for user in actions['updated']: - try: - user_update(operation_logger, user['username'], - user['firstname'], user['lastname'], - user['mail'], user['password'], - mailbox_quota=user['mailbox_quota']) - result['updated'] += 1 - except Exception as e: - on_failure(user['username'], e) - + # We do delete and update before to avoid mail uniqueness issues for user in actions['deleted']: try: - user_delete(operation_logger, user, purge=True) + user_delete(user, purge=True, imported=True) result['deleted'] += 1 - except Exception as e: + except YunohostError as e: on_failure(user, e) ->>>>>>> Stashed changes + + for user in actions['updated']: + try: + update(user) + result['updated'] += 1 + except YunohostError as e: + on_failure(user['username'], e) + + for user in actions['created']: + try: + user_create(user['username'], + user['firstname'], user['lastname'], + user['domain'], user['password'], + user['mailbox_quota'], imported=True) + update(user, created=True) + result['created'] += 1 + except YunohostError as e: + on_failure(user['username'], e) + + + + permission_sync_to_user() + app_ssowatconf() if result['errors']: msg = m18n.n('user_import_partial_failed') @@ -685,6 +738,7 @@ def user_import(operation_logger, csv, update=False, delete=False): operation_logger.success() return result + # # Group subcategory # @@ -857,9 +911,15 @@ def user_group_delete(operation_logger, groupname, force=False, sync_perm=True): logger.debug(m18n.n("group_deleted", group=groupname)) -@is_unit_operation([("groupname", "group")]) +@is_unit_operation([('groupname', 'group')]) def user_group_update( - operation_logger, groupname, add=None, remove=None, force=False, sync_perm=True + operation_logger, + groupname, + add=None, + remove=None, + force=False, + sync_perm=True, + imported=False ): """ Update user informations @@ -929,7 +989,8 @@ def user_group_update( ] if set(new_group) != set(current_group): - operation_logger.start() + if not imported: + operation_logger.start() ldap = _get_ldap_interface() try: ldap.update( @@ -939,14 +1000,16 @@ def user_group_update( except Exception as e: raise YunohostError("group_update_failed", group=groupname, error=e) - if groupname != "all_users": - logger.success(m18n.n("group_updated", group=groupname)) - else: - logger.debug(m18n.n("group_updated", group=groupname)) - if sync_perm: permission_sync_to_user() - return user_group_info(groupname) + + if not imported: + if groupname != "all_users": + logger.success(m18n.n("group_updated", group=groupname)) + else: + logger.debug(m18n.n("group_updated", group=groupname)) + + return user_group_info(groupname) def user_group_info(groupname): From 9e2f4a56f33ded262d6beb2dcf260e194807f905 Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 20 Dec 2020 23:13:22 +0100 Subject: [PATCH 05/48] [enh] Add export feature and refactor user_list --- data/actionsmap/yunohost.yml | 7 +- locales/en.json | 5 +- src/yunohost/user.py | 164 +++++++++++++++++++++++++---------- 3 files changed, 129 insertions(+), 47 deletions(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index a3ff431e7..a5fdf5872 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -67,7 +67,7 @@ user: api: GET /users arguments: --fields: - help: fields to fetch + help: fields to fetch (username, fullname, mail, mail-alias, mail-forward, mailbox-quota, groups, shell, home-path) nargs: "+" ### user_create() @@ -207,6 +207,11 @@ user: username: help: Username or email to get information + ### user_export() + export: + action_help: Export users into CSV + api: GET /users/export + ### user_import() import: action_help: Import several users from CSV diff --git a/locales/en.json b/locales/en.json index 367183a8a..6a092d108 100644 --- a/locales/en.json +++ b/locales/en.json @@ -631,9 +631,12 @@ "user_unknown": "Unknown user: {user:s}", "user_update_failed": "Could not update user {user}: {error}", "user_updated": "User info changed", - "user_import_bad_line": "Incorrect line {line}: {details} ", + "user_import_bad_line": "Incorrect line {line}: {details}", + "user_import_bad_file": "Your CSV file is not correctly formatted it will be ignored to avoid potential data loss", + "user_import_missing_column": "The column {column} is missing", "user_import_partial_failed": "The users import operation partially failed", "user_import_failed": "The users import operation completely failed", + "user_import_nothing_to_do": "No user needs to be imported", "user_import_success": "Users have been imported", "yunohost_already_installed": "YunoHost is already installed", "yunohost_configured": "YunoHost is now configured", diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 0489a34fa..0fae9cf43 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -48,27 +48,48 @@ def user_list(fields=None): from yunohost.utils.ldap import _get_ldap_interface - user_attrs = { - "uid": "username", - "cn": "fullname", - "mail": "mail", - "maildrop": "mail-forward", - "homeDirectory": "home_path", - "mailuserquota": "mailbox-quota", + ldap_attrs = { + 'username': 'uid', + 'password': 'uid', + 'fullname': 'cn', + 'firstname': 'givenName', + 'lastname': 'sn', + 'mail': 'mail', + 'mail-alias': 'mail', + 'mail-forward': 'maildrop', + 'mailbox-quota': 'mailuserquota', + 'groups': 'memberOf', + 'shell': 'loginShell', + 'home-path': 'homeDirectory' } - attrs = ["uid"] + def display_default(values, _): + return values[0] if len(values) == 1 else values + + display = { + 'password': lambda values, user: '', + 'mail': lambda values, user: display_default(values[:1], user), + 'mail-alias': lambda values, _: values[1:], + 'mail-forward': lambda values, user: [forward for forward in values if forward != user['uid'][0]], + 'groups': lambda values, user: [ + group[3:].split(',')[0] + for group in values + if not group.startswith('cn=all_users,') and + not group.startswith('cn=' + user['uid'][0] + ',')], + 'shell': lambda values, _: len(values) > 0 and values[0].strip() == "/bin/false" + } + + attrs = set(['uid']) users = {} - if fields: - keys = user_attrs.keys() - for attr in fields: - if attr in keys: - attrs.append(attr) - else: - raise YunohostError("field_invalid", attr) - else: - attrs = ["uid", "cn", "mail", "mailuserquota"] + if not fields: + fields = ['username', 'fullname', 'mail', 'mailbox-quota', 'shell'] + + for field in fields: + if field in ldap_attrs: + attrs|=set([ldap_attrs[field]]) + else: + raise YunohostError('field_invalid', field) ldap = _get_ldap_interface() result = ldap.search( @@ -79,12 +100,13 @@ def user_list(fields=None): for user in result: entry = {} - for attr, values in user.items(): - if values: - entry[user_attrs[attr]] = values[0] + for field in fields: + values = [] + if ldap_attrs[field] in user: + values = user[ldap_attrs[field]] + entry[field] = display.get(field, display_default)(values, user) - uid = entry[user_attrs["uid"]] - users[uid] = entry + users[entry['username']] = entry return {"users": users} @@ -579,13 +601,49 @@ def user_info(username): return result_dict +def user_export(): + """ + Export users into CSV + + Keyword argument: + csv -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups + + """ + import csv # CSV are needed only in this function + from io import BytesIO + fieldnames = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] + with BytesIO() as csv_io: + writer = csv.DictWriter(csv_io, fieldnames, delimiter=';', quotechar='"') + writer.writeheader() + users = user_list(fieldnames)['users'] + for username, user in users.items(): + user['mail-alias'] = ','.join(user['mail-alias']) + user['mail-forward'] = ','.join(user['mail-forward']) + user['groups'] = ','.join(user['groups']) + writer.writerow(user) + + body = csv_io.getvalue() + if msettings.get('interface') == 'api': + # We return a raw bottle HTTPresponse (instead of serializable data like + # list/dict, ...), which is gonna be picked and used directly by moulinette + from bottle import LocalResponse + response = LocalResponse(body=body, + headers={ + "Content-Disposition": "attachment; filename='users.csv'", + "Content-Type": "text/csv", + } + ) + return response + else: + return body + @is_unit_operation() def user_import(operation_logger, csvfile, update=False, delete=False): """ Import users from CSV Keyword argument: - csv -- CSV file with columns username, email, quota, groups and optionnally password + csv -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups """ import csv # CSV are needed only in this function @@ -604,20 +662,35 @@ def user_import(operation_logger, csvfile, update=False, delete=False): 'firstname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) 'lastname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', 'password': r'^|(.{3,})$', - 'mailbox_quota': r'^(\d+[bkMGT])|0$', 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', - 'alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'mail-forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'mailbox-quota': r'^(\d+[bkMGT])|0$', 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' } + def to_list(str_list): return str_list.split(',') if str_list else [] - existing_users = user_list()['users'].keys() + + existing_users = user_list()['users'] + past_lines = [] reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') for user in reader: - format_errors = [key + ':' + user[key] - for key, validator in validators.items() - if not re.match(validator, user[key])] + # Validation + try: + format_errors = [key + ':' + str(user[key]) + for key, validator in validators.items() + if user[key] is None or not re.match(validator, user[key])] + except KeyError, e: + logger.error(m18n.n('user_import_missing_column', + column=str(e))) + is_well_formatted = False + break + + if 'username' in user: + if user['username'] in past_lines: + format_errors.append('username: %s (duplicated)' % user['username']) + past_lines.append(user['username']) if format_errors: logger.error(m18n.n('user_import_bad_line', line=reader.line_num, @@ -625,9 +698,10 @@ def user_import(operation_logger, csvfile, update=False, delete=False): is_well_formatted = False continue + # Choose what to do with this line and prepare data user['groups'] = to_list(user['groups']) - user['alias'] = to_list(user['alias']) - user['forward'] = to_list(user['forward']) + user['mail-alias'] = to_list(user['mail-alias']) + user['mail-forward'] = to_list(user['mail-forward']) user['domain'] = user['mail'].split('@')[1] if user['username'] not in existing_users: # Generate password if not exists @@ -638,7 +712,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): else: if update: actions['updated'].append(user) - existing_users.remove(user['username']) + del existing_users[user['username']] if delete: for user in existing_users: @@ -650,7 +724,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): total = len(actions['created'] + actions['updated'] + actions['deleted']) if total == 0: - logger.info(m18n.n('nothing_to_do')) + logger.info(m18n.n('user_import_nothing_to_do')) return # Apply creation, update and deletion operation @@ -665,14 +739,13 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['errors'] += 1 logger.error(user + ': ' + str(exception)) - def update(user, created=False): + def update(user, info=False): remove_alias = None remove_forward = None - if not created: - info = user_info(user['username']) + if info: user['mail'] = None if info['mail'] == user['mail'] else user['mail'] - remove_alias = list(set(info['mail-aliases']) - set(user['alias'])) - remove_forward = list(set(info['mail-forward']) - set(user['forward'])) + remove_alias = list(set(info['mail-aliases']) - set(user['mail-alias'])) + remove_forward = list(set(info['mail-forward']) - set(user['mail-forward'])) user['alias'] = list(set(user['alias']) - set(info['mail-aliases'])) user['forward'] = list(set(user['forward']) - set(info['mail-forward'])) for group, infos in user_group_list()["groups"].items(): @@ -686,15 +759,16 @@ def user_import(operation_logger, csvfile, update=False, delete=False): user_update(user['username'], user['firstname'], user['lastname'], user['mail'], user['password'], - mailbox_quota=user['mailbox_quota'], - mail=user['mail'], add_mailalias=user['alias'], + mailbox_quota=user['mailbox-quota'], + mail=user['mail'], add_mailalias=user['mail-alias'], remove_mailalias=remove_alias, remove_mailforward=remove_forward, - add_mailforward=user['forward'], imported=True) + add_mailforward=user['mail-forward'], imported=True) for group in user['groups']: user_group_update(group, add=user['username'], sync_perm=False, imported=True) + users = user_list()['users'] operation_logger.start() # We do delete and update before to avoid mail uniqueness issues for user in actions['deleted']: @@ -706,7 +780,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): for user in actions['updated']: try: - update(user) + update(user, users[user['username']]) result['updated'] += 1 except YunohostError as e: on_failure(user['username'], e) @@ -716,8 +790,8 @@ def user_import(operation_logger, csvfile, update=False, delete=False): user_create(user['username'], user['firstname'], user['lastname'], user['domain'], user['password'], - user['mailbox_quota'], imported=True) - update(user, created=True) + user['mailbox-quota'], imported=True) + update(user) result['created'] += 1 except YunohostError as e: on_failure(user['username'], e) From fd06430e8f1a93f5b3236bbe0b48d9ca6e574b07 Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 21 Dec 2020 02:29:17 +0100 Subject: [PATCH 06/48] [fix] Import user with update mode some unit test --- src/yunohost/tests/test_user-group.py | 64 +++++++++++++++++++++++++ src/yunohost/user.py | 69 ++++++++++++--------------- 2 files changed, 94 insertions(+), 39 deletions(-) diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index 251029796..e83425df9 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -1,5 +1,6 @@ import pytest +<<<<<<< HEAD from .conftest import message, raiseYunohostError from yunohost.user import ( @@ -8,6 +9,10 @@ from yunohost.user import ( user_create, user_delete, user_update, + user_import, + user_export, + CSV_FIELDNAMES, + FIRST_ALIASES, user_group_list, user_group_create, user_group_delete, @@ -110,6 +115,65 @@ def test_del_user(mocker): assert "alice" not in group_res["all_users"]["members"] +def test_import_user(mocker): + import csv + from io import BytesIO + fieldnames = [u'username', u'firstname', u'lastname', u'password', + u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', + u'groups'] + with BytesIO() as csv_io: + writer = csv.DictWriter(csv_io, fieldnames, delimiter=';', + quotechar='"') + writer.writeheader() + writer.writerow({ + 'username': "albert", + 'firstname': "Albert", + 'lastname': "Good", + 'password': "", + 'mailbox-quota': "1G", + 'mail': "albert@" + maindomain, + 'mail-alias': "albert2@" + maindomain, + 'mail-forward': "albert@example.com", + 'groups': "dev", + }) + writer.writerow({ + 'username': "alice", + 'firstname': "Alice", + 'lastname': "White", + 'password': "", + 'mailbox-quota': "1G", + 'mail': "alice@" + maindomain, + 'mail-alias': "alice1@" + maindomain + ",alice2@" + maindomain, + 'mail-forward': "", + 'groups': "apps", + }) + csv_io.seek(0) + with message(mocker, "user_import_success"): + user_import(csv_io, update=True, delete=True) + + group_res = user_group_list()['groups'] + user_res = user_list(CSV_FIELDNAMES)['users'] + assert "albert" in user_res + assert "alice" in user_res + assert "bob" not in user_res + assert len(user_res['alice']['mail-alias']) == 2 + assert "albert" in group_res['dev']['members'] + assert "alice" in group_res['apps']['members'] + + +def test_export_user(mocker): + result = user_export() + should_be = "username;firstname;lastname;password;" + should_be += "mailbox-quota;mail;mail-alias;mail-forward;groups" + should_be += "\r\nbob;Bob;Snow;;0;bob@" + maindomain + ";;;apps" + should_be += "\r\nalice;Alice;White;;0;alice@" + maindomain + ";" + should_be += ','.join([alias + maindomain for alias in FIRST_ALIASES]) + should_be += ";;dev" + should_be += "\r\njack;Jack;Black;;0;jack@" + maindomain + ";;;" + + assert result == should_be + + def test_create_group(mocker): with message(mocker, "group_created", group="adminsys"): diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 0fae9cf43..0bcce9cbc 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -43,6 +43,19 @@ from yunohost.log import is_unit_operation logger = getActionLogger("yunohost.user") +CSV_FIELDNAMES = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] +VALIDATORS = { + 'username': r'^[a-z0-9_]+$', + 'firstname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) + 'lastname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', + 'password': r'^|(.{3,})$', + 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', + 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'mail-forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', + 'mailbox-quota': r'^(\d+[bkMGT])|0$', + 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' +} +FIRST_ALIASES = ['root@', 'admin@', 'webmaster@', 'postmaster@', 'abuse@'] def user_list(fields=None): @@ -87,7 +100,7 @@ def user_list(fields=None): for field in fields: if field in ldap_attrs: - attrs|=set([ldap_attrs[field]]) + attrs |= set([ldap_attrs[field]]) else: raise YunohostError('field_invalid', field) @@ -179,13 +192,7 @@ def user_create( raise YunohostValidationError("system_username_exists") main_domain = _get_maindomain() - aliases = [ - "root@" + main_domain, - "admin@" + main_domain, - "webmaster@" + main_domain, - "postmaster@" + main_domain, - "abuse@" + main_domain, - ] + aliases = [alias + main_domain for alias in FIRST_ALIASES] if mail in aliases: raise YunohostValidationError("mail_unavailable") @@ -416,13 +423,8 @@ def user_update( if mail: main_domain = _get_maindomain() - aliases = [ - "root@" + main_domain, - "admin@" + main_domain, - "webmaster@" + main_domain, - "postmaster@" + main_domain, - 'abuse@' + main_domain, - ] + aliases = [alias + main_domain for alias in FIRST_ALIASES] + if mail in user['mail']: user['mail'].remove(mail) else: @@ -606,23 +608,23 @@ def user_export(): Export users into CSV Keyword argument: - csv -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups + csv -- CSV file with columns username;firstname;lastname;password;mailbox-quota;mail;mail-alias;mail-forward;groups """ - import csv # CSV are needed only in this function + import csv # CSV are needed only in this function from io import BytesIO - fieldnames = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] with BytesIO() as csv_io: - writer = csv.DictWriter(csv_io, fieldnames, delimiter=';', quotechar='"') + writer = csv.DictWriter(csv_io, CSV_FIELDNAMES, + delimiter=';', quotechar='"') writer.writeheader() - users = user_list(fieldnames)['users'] + users = user_list(CSV_FIELDNAMES)['users'] for username, user in users.items(): user['mail-alias'] = ','.join(user['mail-alias']) user['mail-forward'] = ','.join(user['mail-forward']) user['groups'] = ','.join(user['groups']) writer.writerow(user) - body = csv_io.getvalue() + body = csv_io.getvalue().rstrip() if msettings.get('interface') == 'api': # We return a raw bottle HTTPresponse (instead of serializable data like # list/dict, ...), which is gonna be picked and used directly by moulinette @@ -631,12 +633,12 @@ def user_export(): headers={ "Content-Disposition": "attachment; filename='users.csv'", "Content-Type": "text/csv", - } - ) + }) return response else: return body + @is_unit_operation() def user_import(operation_logger, csvfile, update=False, delete=False): """ @@ -657,17 +659,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False): 'deleted': [] } is_well_formatted = True - validators = { - 'username': r'^[a-z0-9_]+$', - 'firstname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) - 'lastname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', - 'password': r'^|(.{3,})$', - 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', - 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'mail-forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'mailbox-quota': r'^(\d+[bkMGT])|0$', - 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' - } def to_list(str_list): return str_list.split(',') if str_list else [] @@ -679,7 +670,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): # Validation try: format_errors = [key + ':' + str(user[key]) - for key, validator in validators.items() + for key, validator in VALIDATORS.items() if user[key] is None or not re.match(validator, user[key])] except KeyError, e: logger.error(m18n.n('user_import_missing_column', @@ -744,10 +735,10 @@ def user_import(operation_logger, csvfile, update=False, delete=False): remove_forward = None if info: user['mail'] = None if info['mail'] == user['mail'] else user['mail'] - remove_alias = list(set(info['mail-aliases']) - set(user['mail-alias'])) + remove_alias = list(set(info['mail-alias']) - set(user['mail-alias'])) remove_forward = list(set(info['mail-forward']) - set(user['mail-forward'])) - user['alias'] = list(set(user['alias']) - set(info['mail-aliases'])) - user['forward'] = list(set(user['forward']) - set(info['mail-forward'])) + user['mail-alias'] = list(set(user['mail-alias']) - set(info['mail-alias'])) + user['mail-forward'] = list(set(user['mail-forward']) - set(info['mail-forward'])) for group, infos in user_group_list()["groups"].items(): if group == "all_users": continue @@ -768,7 +759,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): for group in user['groups']: user_group_update(group, add=user['username'], sync_perm=False, imported=True) - users = user_list()['users'] + users = user_list(CSV_FIELDNAMES)['users'] operation_logger.start() # We do delete and update before to avoid mail uniqueness issues for user in actions['deleted']: From 57dcf45a7c838d2fecb633a1f1f2f04929b305ce Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 21 Dec 2020 03:40:20 +0100 Subject: [PATCH 07/48] [fix] User import unit test --- src/yunohost/log.py | 3 +++ src/yunohost/tests/test_user-group.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/yunohost/log.py b/src/yunohost/log.py index 9ea2c2024..a8a3281d2 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -32,6 +32,7 @@ import psutil from datetime import datetime, timedelta from logging import FileHandler, getLogger, Formatter +from io import IOBase from moulinette import m18n, msettings from moulinette.core import MoulinetteError @@ -374,6 +375,8 @@ def is_unit_operation( for field, value in context.items(): if isinstance(value, file): context[field] = value.name + elif isinstance(value, IOBase): + context[field] = 'IOBase' operation_logger = OperationLogger(op_key, related_to, args=context) try: diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index e83425df9..3252f0ef8 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -1,6 +1,5 @@ import pytest -<<<<<<< HEAD from .conftest import message, raiseYunohostError from yunohost.user import ( @@ -27,7 +26,7 @@ maindomain = "" def clean_user_groups(): for u in user_list()["users"]: - user_delete(u) + user_delete(u, purge=True) for g in user_group_list()["groups"]: if g not in ["all_users", "visitors"]: From c7c29285795c6823eaaa074e2f163af9136bb0a3 Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 21 Dec 2020 04:27:03 +0100 Subject: [PATCH 08/48] [fix] Column list --- data/actionsmap/yunohost.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index a5fdf5872..4ef9ad008 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -218,7 +218,7 @@ user: api: POST /users/import arguments: csvfile: - help: "CSV file with columns username, email, quota, groups(separated by coma) and optionally password" + help: "CSV file with columns username, firstname, lastname, password, mail, mailbox-quota, mail-alias, mail-forward, groups (separated by coma)" type: open -u: full: --update From 59d7e2f247687143d3cc6fca462e5557e9e37621 Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 21 Dec 2020 04:27:23 +0100 Subject: [PATCH 09/48] [fix] LDAP Size limits --- data/templates/slapd/slapd.ldif | 1 + 1 file changed, 1 insertion(+) diff --git a/data/templates/slapd/slapd.ldif b/data/templates/slapd/slapd.ldif index d3ed2e053..8692d2664 100644 --- a/data/templates/slapd/slapd.ldif +++ b/data/templates/slapd/slapd.ldif @@ -33,6 +33,7 @@ olcAuthzPolicy: none olcConcurrency: 0 olcConnMaxPending: 100 olcConnMaxPendingAuth: 1000 +olcSizeLimit: 10000 olcIdleTimeout: 0 olcIndexSubstrIfMaxLen: 4 olcIndexSubstrIfMinLen: 2 From 8e2f1c696b191b06d40a1646699d81f758aa1d6a Mon Sep 17 00:00:00 2001 From: ljf Date: Mon, 21 Dec 2020 04:27:50 +0100 Subject: [PATCH 10/48] [fix] Home not created --- locales/en.json | 2 +- src/yunohost/user.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/locales/en.json b/locales/en.json index 6a092d108..faa9e4556 100644 --- a/locales/en.json +++ b/locales/en.json @@ -627,7 +627,7 @@ "user_creation_failed": "Could not create user {user}: {error}", "user_deleted": "User deleted", "user_deletion_failed": "Could not delete user {user}: {error}", - "user_home_creation_failed": "Could not create 'home' folder for user", + "user_home_creation_failed": "Could not create '{home}' folder for user", "user_unknown": "Unknown user: {user:s}", "user_update_failed": "Could not update user {user}: {error}", "user_updated": "User info changed", diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 0bcce9cbc..0680af89d 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -254,6 +254,10 @@ def user_create( except subprocess.CalledProcessError: if not os.path.isdir("/home/{0}".format(username)): logger.warning(m18n.n("user_home_creation_failed"), exc_info=1) + home = '/home/{0}'.format(username) + if not os.path.isdir(home): + logger.warning(m18n.n('user_home_creation_failed', home=home), + exc_info=1) try: subprocess.check_call( @@ -726,6 +730,20 @@ def user_import(operation_logger, csvfile, update=False, delete=False): 'errors': 0 } + def progress(info=""): + progress.nb += 1 + width = 20 + bar = int(progress.nb * width / total) + bar = "[" + "#" * bar + "." * (width - bar) + "]" + if info: + bar += " > " + info + if progress.old == bar: + return + progress.old = bar + logger.info(bar) + progress.nb = 0 + progress.old = "" + def on_failure(user, exception): result['errors'] += 1 logger.error(user + ': ' + str(exception)) @@ -768,6 +786,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['deleted'] += 1 except YunohostError as e: on_failure(user, e) + progress("Deletion") for user in actions['updated']: try: @@ -775,6 +794,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['updated'] += 1 except YunohostError as e: on_failure(user['username'], e) + progress("Update") for user in actions['created']: try: @@ -786,6 +806,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['created'] += 1 except YunohostError as e: on_failure(user['username'], e) + progress("Creation") From a07314e66169b81efe270ee8018ee4d6f0629931 Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 3 Jan 2021 19:44:46 +0100 Subject: [PATCH 11/48] [fix] Download CSV from webadmin - missing commit --- src/yunohost/user.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 0680af89d..88279997b 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -632,10 +632,10 @@ def user_export(): if msettings.get('interface') == 'api': # We return a raw bottle HTTPresponse (instead of serializable data like # list/dict, ...), which is gonna be picked and used directly by moulinette - from bottle import LocalResponse - response = LocalResponse(body=body, + from bottle import HTTPResponse + response = HTTPResponse(body=body, headers={ - "Content-Disposition": "attachment; filename='users.csv'", + "Content-Disposition": "attachment; filename=users.csv", "Content-Type": "text/csv", }) return response @@ -652,6 +652,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): csv -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups """ + import csv # CSV are needed only in this function from moulinette.utils.text import random_ascii from yunohost.permission import permission_sync_to_user From a78e4c8eacca2d221b3b91693928437e0577ec05 Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 3 Jan 2021 19:51:43 +0100 Subject: [PATCH 12/48] [fix] 1 letter firstname or lastname --- src/yunohost/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 88279997b..fe114da4b 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -46,8 +46,8 @@ logger = getActionLogger("yunohost.user") CSV_FIELDNAMES = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] VALIDATORS = { 'username': r'^[a-z0-9_]+$', - 'firstname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) - 'lastname': r'^([^\W\d_]{2,30}[ ,.\'-]{0,3})+$', + 'firstname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) + 'lastname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', 'password': r'^|(.{3,})$', 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', From 1d33f333cdff0ea7700b488a695ba8a3cd8b8b30 Mon Sep 17 00:00:00 2001 From: ljf Date: Sat, 8 May 2021 23:39:33 +0200 Subject: [PATCH 13/48] [fix] Python3 migration for export user feature --- src/yunohost/user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index fe114da4b..5487ef18b 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -616,8 +616,8 @@ def user_export(): """ import csv # CSV are needed only in this function - from io import BytesIO - with BytesIO() as csv_io: + from io import StringIO + with StringIO() as csv_io: writer = csv.DictWriter(csv_io, CSV_FIELDNAMES, delimiter=';', quotechar='"') writer.writeheader() @@ -677,7 +677,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): format_errors = [key + ':' + str(user[key]) for key, validator in VALIDATORS.items() if user[key] is None or not re.match(validator, user[key])] - except KeyError, e: + except KeyError as e: logger.error(m18n.n('user_import_missing_column', column=str(e))) is_well_formatted = False From 91e7e5e1c80652a8977bba99d7a01075a1c86e2e Mon Sep 17 00:00:00 2001 From: ljf Date: Sat, 8 May 2021 23:58:36 +0200 Subject: [PATCH 14/48] [fix] Python3 migration: File args with log --- src/yunohost/log.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/yunohost/log.py b/src/yunohost/log.py index a8a3281d2..9e6c8f165 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -373,10 +373,11 @@ def is_unit_operation( if field in context: context.pop(field, None) for field, value in context.items(): - if isinstance(value, file): - context[field] = value.name - elif isinstance(value, IOBase): - context[field] = 'IOBase' + if isinstance(value, IOBase): + try: + context[field] = value.name + except: + context[field] = 'IOBase' operation_logger = OperationLogger(op_key, related_to, args=context) try: From 6e880c8219846ce94a1a870c535334f16c5b1bcd Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 9 May 2021 01:02:51 +0200 Subject: [PATCH 15/48] [fix] Avoid password too small error during import operation --- src/yunohost/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 5487ef18b..ee26533e8 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -413,7 +413,7 @@ def user_update( ] # change_password is None if user_update is not called to change the password - if change_password is not None: + if change_password is not None and change_password != '': # when in the cli interface if the option to change the password is called # without a specified value, change_password will be set to the const 0. # In this case we prompt for the new password. From ad73c29dad7d61db122436e75d394ee12bf70119 Mon Sep 17 00:00:00 2001 From: ljf Date: Sun, 9 May 2021 01:08:43 +0200 Subject: [PATCH 16/48] [fix] Error in import user tests --- src/yunohost/tests/test_user-group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index 3252f0ef8..ee5d07c40 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -116,7 +116,7 @@ def test_del_user(mocker): def test_import_user(mocker): import csv - from io import BytesIO + from io import StringIO fieldnames = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] From 8cf151668fda88b57e765e742e673bd6d1227efc Mon Sep 17 00:00:00 2001 From: ljf Date: Tue, 11 May 2021 12:08:00 +0200 Subject: [PATCH 17/48] [fix] CI test --- locales/de.json | 2 +- src/yunohost/tests/test_user-group.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/locales/de.json b/locales/de.json index 53297ed6d..73efca434 100644 --- a/locales/de.json +++ b/locales/de.json @@ -590,5 +590,5 @@ "diagnosis_sshd_config_insecure": "Die SSH-Konfiguration wurde scheinbar manuell abgeändert, und ist unsicher, weil sie keine 'AllowGroups'- oder 'AllowUsers' -Direktiven für die Begrenzung des Zugriffs durch autorisierte Benutzer enthält.", "backup_create_size_estimation": "Das Archiv wird etwa {size} Daten enthalten", "app_restore_script_failed": "Im Wiederherstellungsskript der Anwendung ist ein Fehler aufgetreten", - "app_restore_failed": "Konnte {apps:s} nicht wiederherstellen: {error:s}" + "app_restore_failed": "Konnte {app:s} nicht wiederherstellen: {error:s}" } diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index ee5d07c40..63d9a1930 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -120,7 +120,7 @@ def test_import_user(mocker): fieldnames = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] - with BytesIO() as csv_io: + with StringIO() as csv_io: writer = csv.DictWriter(csv_io, fieldnames, delimiter=';', quotechar='"') writer.writeheader() @@ -164,10 +164,10 @@ def test_export_user(mocker): result = user_export() should_be = "username;firstname;lastname;password;" should_be += "mailbox-quota;mail;mail-alias;mail-forward;groups" - should_be += "\r\nbob;Bob;Snow;;0;bob@" + maindomain + ";;;apps" should_be += "\r\nalice;Alice;White;;0;alice@" + maindomain + ";" should_be += ','.join([alias + maindomain for alias in FIRST_ALIASES]) should_be += ";;dev" + should_be += "\r\nbob;Bob;Snow;;0;bob@" + maindomain + ";;;apps" should_be += "\r\njack;Jack;Black;;0;jack@" + maindomain + ";;;" assert result == should_be From 4a22f6b390ec4277de478d50f49e9d7df8fa2773 Mon Sep 17 00:00:00 2001 From: ljf Date: Thu, 10 Jun 2021 19:28:49 +0200 Subject: [PATCH 18/48] [fix] yunohost user list --fields mail-alias --- src/yunohost/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index ee26533e8..1037d417f 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -119,7 +119,7 @@ def user_list(fields=None): values = user[ldap_attrs[field]] entry[field] = display.get(field, display_default)(values, user) - users[entry['username']] = entry + users[user['uid'][0]] = entry return {"users": users} From b9e231241b53019a634bc0d43a80284732796611 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 13 Aug 2021 00:49:42 +0200 Subject: [PATCH 19/48] user imports: imported -> from_import, try to improve semantics --- src/yunohost/user.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 1037d417f..545d46a87 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -134,7 +134,7 @@ def user_create( password, mailbox_quota="0", mail=None, - imported=False + from_import=False ): from yunohost.domain import domain_list, _get_maindomain @@ -197,7 +197,7 @@ def user_create( if mail in aliases: raise YunohostValidationError("mail_unavailable") - if not imported: + if not from_import: operation_logger.start() # Get random UID/GID @@ -282,14 +282,14 @@ def user_create( hook_callback("post_user_create", args=[username, mail], env=env_dict) # TODO: Send a welcome mail to user - if not imported: + if not from_import: logger.success(m18n.n('user_created')) return {"fullname": fullname, "username": username, "mail": mail} @is_unit_operation([('username', 'user')]) -def user_delete(operation_logger, username, purge=False, imported=False): +def user_delete(operation_logger, username, purge=False, from_import=False): """ Delete user @@ -304,7 +304,7 @@ def user_delete(operation_logger, username, purge=False, imported=False): if username not in user_list()["users"]: raise YunohostValidationError("user_unknown", user=username) - if not imported: + if not from_import: operation_logger.start() user_group_update("all_users", remove=username, force=True, sync_perm=False) @@ -337,7 +337,7 @@ def user_delete(operation_logger, username, purge=False, imported=False): hook_callback('post_user_delete', args=[username, purge]) - if not imported: + if not from_import: logger.success(m18n.n('user_deleted')) @is_unit_operation([("username", "user")], exclude=["change_password"]) @@ -353,7 +353,7 @@ def user_update( add_mailalias=None, remove_mailalias=None, mailbox_quota=None, - imported=False + from_import=False ): """ Update user informations @@ -502,7 +502,7 @@ def user_update( new_attr_dict["mailuserquota"] = [mailbox_quota] env_dict["YNH_USER_MAILQUOTA"] = mailbox_quota - if not imported: + if not from_import: operation_logger.start() try: @@ -513,7 +513,7 @@ def user_update( # Trigger post_user_update hooks hook_callback("post_user_update", env=env_dict) - if not imported: + if not from_import: app_ssowatconf() logger.success(m18n.n('user_updated')) return user_info(username) @@ -764,7 +764,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): # If the user is in this group (and it's not the primary group), # remove the member from the group if user['username'] != group and user['username'] in infos["members"]: - user_group_update(group, remove=user['username'], sync_perm=False, imported=True) + user_group_update(group, remove=user['username'], sync_perm=False, from_import=True) user_update(user['username'], user['firstname'], user['lastname'], @@ -773,17 +773,17 @@ def user_import(operation_logger, csvfile, update=False, delete=False): mail=user['mail'], add_mailalias=user['mail-alias'], remove_mailalias=remove_alias, remove_mailforward=remove_forward, - add_mailforward=user['mail-forward'], imported=True) + add_mailforward=user['mail-forward'], from_import=True) for group in user['groups']: - user_group_update(group, add=user['username'], sync_perm=False, imported=True) + user_group_update(group, add=user['username'], sync_perm=False, from_import=True) users = user_list(CSV_FIELDNAMES)['users'] operation_logger.start() # We do delete and update before to avoid mail uniqueness issues for user in actions['deleted']: try: - user_delete(user, purge=True, imported=True) + user_delete(user, purge=True, from_import=True) result['deleted'] += 1 except YunohostError as e: on_failure(user, e) @@ -802,7 +802,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): user_create(user['username'], user['firstname'], user['lastname'], user['domain'], user['password'], - user['mailbox-quota'], imported=True) + user['mailbox-quota'], from_import=True) update(user) result['created'] += 1 except YunohostError as e: @@ -1006,7 +1006,7 @@ def user_group_update( remove=None, force=False, sync_perm=True, - imported=False + from_import=False ): """ Update user informations @@ -1076,7 +1076,7 @@ def user_group_update( ] if set(new_group) != set(current_group): - if not imported: + if not from_import: operation_logger.start() ldap = _get_ldap_interface() try: @@ -1090,7 +1090,7 @@ def user_group_update( if sync_perm: permission_sync_to_user() - if not imported: + if not from_import: if groupname != "all_users": logger.success(m18n.n("group_updated", group=groupname)) else: From 5c9fd158d9b31c83c4c9975e8f4609647f9fe17d Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 13 Aug 2021 00:52:42 +0200 Subject: [PATCH 20/48] user imports: more attempts to improve semantics --- src/yunohost/user.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 545d46a87..dfa71708a 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -749,34 +749,34 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['errors'] += 1 logger.error(user + ': ' + str(exception)) - def update(user, info=False): + def update(new_infos, old_infos=False): remove_alias = None remove_forward = None if info: - user['mail'] = None if info['mail'] == user['mail'] else user['mail'] - remove_alias = list(set(info['mail-alias']) - set(user['mail-alias'])) - remove_forward = list(set(info['mail-forward']) - set(user['mail-forward'])) - user['mail-alias'] = list(set(user['mail-alias']) - set(info['mail-alias'])) - user['mail-forward'] = list(set(user['mail-forward']) - set(info['mail-forward'])) + new_infos['mail'] = None if old_infos['mail'] == new_infos['mail'] else new_infos['mail'] + remove_alias = list(set(old_infos['mail-alias']) - set(new_infos['mail-alias'])) + remove_forward = list(set(old_infos['mail-forward']) - set(new_infos['mail-forward'])) + new_infos['mail-alias'] = list(set(new_infos['mail-alias']) - set(old_infos['mail-alias'])) + new_infos['mail-forward'] = list(set(new_infos['mail-forward']) - set(old_infos['mail-forward'])) for group, infos in user_group_list()["groups"].items(): if group == "all_users": continue # If the user is in this group (and it's not the primary group), # remove the member from the group - if user['username'] != group and user['username'] in infos["members"]: - user_group_update(group, remove=user['username'], sync_perm=False, from_import=True) + if new_infos['username'] != group and new_infos['username'] in infos["members"]: + user_group_update(group, remove=new_infos['username'], sync_perm=False, from_import=True) - user_update(user['username'], - user['firstname'], user['lastname'], - user['mail'], user['password'], - mailbox_quota=user['mailbox-quota'], - mail=user['mail'], add_mailalias=user['mail-alias'], - remove_mailalias=remove_alias, - remove_mailforward=remove_forward, - add_mailforward=user['mail-forward'], from_import=True) + user_update(new_infos['username'], + new_infos['firstname'], new_infos['lastname'], + new_infos['mail'], new_infos['password'], + mailbox_quota=new_infos['mailbox-quota'], + mail=new_infos['mail'], add_mailalias=new_infos['mail-alias'], + remove_mailalias=remove_alias, + remove_mailforward=remove_forward, + add_mailforward=new_infos['mail-forward'], from_import=True) - for group in user['groups']: - user_group_update(group, add=user['username'], sync_perm=False, from_import=True) + for group in new_infos['groups']: + user_group_update(group, add=new_infos['username'], sync_perm=False, from_import=True) users = user_list(CSV_FIELDNAMES)['users'] operation_logger.start() @@ -809,8 +809,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False): on_failure(user['username'], e) progress("Creation") - - permission_sync_to_user() app_ssowatconf() From b1102ba56e29f311f400648c9f0b6ff27394985a Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 13 Aug 2021 01:10:59 +0200 Subject: [PATCH 21/48] user imports: misc formating, comments --- locales/en.json | 2 +- src/yunohost/user.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/locales/en.json b/locales/en.json index f8b26c0b1..5b93dbb9f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -638,7 +638,7 @@ "user_import_partial_failed": "The users import operation partially failed", "user_import_failed": "The users import operation completely failed", "user_import_nothing_to_do": "No user needs to be imported", - "user_import_success": "Users have been imported", + "user_import_success": "Users successfully imported", "yunohost_already_installed": "YunoHost is already installed", "yunohost_configured": "YunoHost is now configured", "yunohost_installing": "Installing YunoHost...", diff --git a/src/yunohost/user.py b/src/yunohost/user.py index ca226f654..459b0decf 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -96,11 +96,11 @@ def user_list(fields=None): users = {} if not fields: - fields = ['username', 'fullname', 'mail', 'mailbox-quota', 'shell'] + fields = ['username', 'fullname', 'mail', 'mailbox-quota'] for field in fields: if field in ldap_attrs: - attrs |= set([ldap_attrs[field]]) + attrs.add(ldap_attrs[field]) else: raise YunohostError('field_invalid', field) @@ -252,7 +252,7 @@ def user_create( # Attempt to create user home folder subprocess.check_call(["mkhomedir_helper", username]) except subprocess.CalledProcessError: - home = '/home/{0}'.format(username) + home = f'/home/{username}' if not os.path.isdir(home): logger.warning(m18n.n('user_home_creation_failed', home=home), exc_info=1) @@ -427,8 +427,10 @@ def user_update( main_domain = _get_maindomain() aliases = [alias + main_domain for alias in FIRST_ALIASES] + # If the requested mail address is already as main address or as an alias by this user if mail in user['mail']: user['mail'].remove(mail) + # Othewise, check that this mail address is not already used by this user else: try: ldap.validate_uniqueness({'mail': mail}) @@ -445,6 +447,7 @@ def user_update( if not isinstance(add_mailalias, list): add_mailalias = [add_mailalias] for mail in add_mailalias: + # (c.f. similar stuff as before) if mail in user["mail"]: user["mail"].remove(mail) else: @@ -647,7 +650,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): Import users from CSV Keyword argument: - csv -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups + csvfile -- CSV file with columns username;firstname;lastname;password;mailbox_quota;mail;alias;forward;groups """ @@ -655,6 +658,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): from moulinette.utils.text import random_ascii from yunohost.permission import permission_sync_to_user from yunohost.app import app_ssowatconf + # Pre-validate data and prepare what should be done actions = { 'created': [], From 781bc1cf7fdf3d3de8e1d7eddd9a9562d2149e39 Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Sat, 14 Aug 2021 16:38:21 +0200 Subject: [PATCH 22/48] Wording Co-authored-by: Alexandre Aubin --- data/actionsmap/yunohost.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 91e369dfe..64cef606d 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -222,7 +222,7 @@ user: type: open -u: full: --update - help: Update all existing users contained in the csv file (by default those users are ignored) + help: Update all existing users contained in the csv file (by default existing users are ignored) action: store_true -d: full: --delete From 2e745d2806dfbe416a03cd60197d3175106d3084 Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Sat, 14 Aug 2021 16:38:55 +0200 Subject: [PATCH 23/48] Wording Co-authored-by: Alexandre Aubin --- data/actionsmap/yunohost.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 64cef606d..817adeb92 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -226,7 +226,7 @@ user: action: store_true -d: full: --delete - help: Delete all existing users that are not contained in the csv file (by default those users are ignored) + help: Delete all existing users that are not contained in the csv file (by default existing users are kept) action: store_true subcategories: From 61bc676552a8b388beb5d49a4a0187c695b7482d Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Sat, 14 Aug 2021 16:41:58 +0200 Subject: [PATCH 24/48] [enh] Add a comment --- src/yunohost/log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/yunohost/log.py b/src/yunohost/log.py index 10ac097f5..f9f9334fb 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -372,6 +372,8 @@ def is_unit_operation( for field in exclude: if field in context: context.pop(field, None) + + # Manage file or stream for field, value in context.items(): if isinstance(value, IOBase): try: From 16f564622ee7fe2ff0ee8aa1b9916b1cc7d83e6b Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Sat, 14 Aug 2021 16:46:48 +0200 Subject: [PATCH 25/48] [enh] Remove uneeded comment --- src/yunohost/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 459b0decf..11e82146a 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -46,7 +46,7 @@ logger = getActionLogger("yunohost.user") CSV_FIELDNAMES = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] VALIDATORS = { 'username': r'^[a-z0-9_]+$', - 'firstname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', #FIXME Merge first and lastname and support more name (arabish, chinese...) + 'firstname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', 'lastname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', 'password': r'^|(.{3,})$', 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', From efec34a3a654f5ec312c9dde03e9b13f7b05224d Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 26 Aug 2021 20:31:20 +0200 Subject: [PATCH 26/48] tests: Improve code formatting --- src/yunohost/tests/test_user-group.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index 63d9a1930..344a20fed 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -162,14 +162,13 @@ def test_import_user(mocker): def test_export_user(mocker): result = user_export() - should_be = "username;firstname;lastname;password;" - should_be += "mailbox-quota;mail;mail-alias;mail-forward;groups" - should_be += "\r\nalice;Alice;White;;0;alice@" + maindomain + ";" - should_be += ','.join([alias + maindomain for alias in FIRST_ALIASES]) - should_be += ";;dev" - should_be += "\r\nbob;Bob;Snow;;0;bob@" + maindomain + ";;;apps" - should_be += "\r\njack;Jack;Black;;0;jack@" + maindomain + ";;;" - + aliases = ','.join([alias + maindomain for alias in FIRST_ALIASES]) + should_be = ( + "username;firstname;lastname;password;mailbox-quota;mail;mail-alias;mail-forward;groups\r\n" + f"alice;Alice;White;;0;alice@{maindomain};{aliases};;dev\r\n" + f"bob;Bob;Snow;;0;bob@{maindomain};;;apps\r\n" + f"jack;Jack;Black;;0;jack@{maindomain};;;" + ) assert result == should_be From ad975a2dbb748b19f6abea17527452010ef30ba4 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 26 Aug 2021 20:45:06 +0200 Subject: [PATCH 27/48] user import: clarify user deletion handling --- src/yunohost/user.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 11e82146a..076d930ca 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -670,6 +670,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): def to_list(str_list): return str_list.split(',') if str_list else [] + users_in_csv = [] existing_users = user_list()['users'] past_lines = [] reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') @@ -701,20 +702,26 @@ def user_import(operation_logger, csvfile, update=False, delete=False): user['mail-alias'] = to_list(user['mail-alias']) user['mail-forward'] = to_list(user['mail-forward']) user['domain'] = user['mail'].split('@')[1] + + # User creation if user['username'] not in existing_users: # Generate password if not exists # This could be used when reset password will be merged if not user['password']: user['password'] = random_ascii(70) actions['created'].append(user) - else: - if update: - actions['updated'].append(user) - del existing_users[user['username']] + # User update + elif update: + actions['updated'].append(user) + + users_in_csv.add(user['username']) if delete: - for user in existing_users: - actions['deleted'].append(user) + actions['deleted'] = [user for user in existing_users if user not in users_in_csv] + + if delete and not users_in_csv: + logger.error("You used the delete option with an empty csv file ... You probably did not really mean to do that, did you !?") + is_well_formatted = False if not is_well_formatted: raise YunohostError('user_import_bad_file') From 128eb6a7d46f06c4bb2a0079134a20cf5d687b43 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 26 Aug 2021 21:06:16 +0200 Subject: [PATCH 28/48] user import: Clarify fields validation --- locales/en.json | 2 +- src/yunohost/tests/test_user-group.py | 4 +-- src/yunohost/user.py | 43 +++++++++++++-------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/locales/en.json b/locales/en.json index 5b93dbb9f..06d2df0a4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -634,7 +634,7 @@ "user_updated": "User info changed", "user_import_bad_line": "Incorrect line {line}: {details}", "user_import_bad_file": "Your CSV file is not correctly formatted it will be ignored to avoid potential data loss", - "user_import_missing_column": "The column {column} is missing", + "user_import_missing_columns": "The following columns are missing: {columns}", "user_import_partial_failed": "The users import operation partially failed", "user_import_failed": "The users import operation completely failed", "user_import_nothing_to_do": "No user needs to be imported", diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index 344a20fed..bbedfc27f 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -10,7 +10,7 @@ from yunohost.user import ( user_update, user_import, user_export, - CSV_FIELDNAMES, + FIELDS_FOR_IMPORT, FIRST_ALIASES, user_group_list, user_group_create, @@ -151,7 +151,7 @@ def test_import_user(mocker): user_import(csv_io, update=True, delete=True) group_res = user_group_list()['groups'] - user_res = user_list(CSV_FIELDNAMES)['users'] + user_res = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] assert "albert" in user_res assert "alice" in user_res assert "bob" not in user_res diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 076d930ca..b055d2cdb 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -43,8 +43,7 @@ from yunohost.log import is_unit_operation logger = getActionLogger("yunohost.user") -CSV_FIELDNAMES = [u'username', u'firstname', u'lastname', u'password', u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', u'groups'] -VALIDATORS = { +FIELDS_FOR_IMPORT = { 'username': r'^[a-z0-9_]+$', 'firstname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', 'lastname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', @@ -619,10 +618,10 @@ def user_export(): import csv # CSV are needed only in this function from io import StringIO with StringIO() as csv_io: - writer = csv.DictWriter(csv_io, CSV_FIELDNAMES, + writer = csv.DictWriter(csv_io, list(FIELDS_FOR_IMPORT.keys()), delimiter=';', quotechar='"') writer.writeheader() - users = user_list(CSV_FIELDNAMES)['users'] + users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] for username, user in users.items(): user['mail-alias'] = ','.join(user['mail-alias']) user['mail-forward'] = ','.join(user['mail-forward']) @@ -672,24 +671,24 @@ def user_import(operation_logger, csvfile, update=False, delete=False): users_in_csv = [] existing_users = user_list()['users'] - past_lines = [] reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') - for user in reader: - # Validation - try: - format_errors = [key + ':' + str(user[key]) - for key, validator in VALIDATORS.items() - if user[key] is None or not re.match(validator, user[key])] - except KeyError as e: - logger.error(m18n.n('user_import_missing_column', - column=str(e))) - is_well_formatted = False - break - if 'username' in user: - if user['username'] in past_lines: - format_errors.append('username: %s (duplicated)' % user['username']) - past_lines.append(user['username']) + missing_columns = [key for key in FIELDS_FOR_IMPORT.keys() if key not in reader.fieldnames] + if missing_columns: + raise YunohostValidationError("user_import_missing_columns", columns=', '.join(missing_columns)) + + for user in reader: + + # Validate column values against regexes + format_errors = [key + ':' + str(user[key]) + for key, validator in FIELDS_FOR_IMPORT.items() + if user[key] is None or not re.match(validator, user[key])] + + # Check for duplicated username lines + if user['username'] in users_in_csv: + format_errors.append(f'username: {user[username]} (duplicated)') + users_in_csv.append(user['username']) + if format_errors: logger.error(m18n.n('user_import_bad_line', line=reader.line_num, @@ -714,8 +713,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False): elif update: actions['updated'].append(user) - users_in_csv.add(user['username']) - if delete: actions['deleted'] = [user for user in existing_users if user not in users_in_csv] @@ -787,7 +784,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): for group in new_infos['groups']: user_group_update(group, add=new_infos['username'], sync_perm=False, from_import=True) - users = user_list(CSV_FIELDNAMES)['users'] + users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] operation_logger.start() # We do delete and update before to avoid mail uniqueness issues for user in actions['deleted']: From 8e35cd8103e8eb40c103ed92d80a2086b35a65b0 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 13:52:10 +0200 Subject: [PATCH 29/48] user import: strip spaces --- src/yunohost/user.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index b055d2cdb..ce2c2c34f 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -667,7 +667,9 @@ def user_import(operation_logger, csvfile, update=False, delete=False): is_well_formatted = True def to_list(str_list): - return str_list.split(',') if str_list else [] + L = str_list.split(',') if str_list else [] + L = [l.strip() for l in L] + return L users_in_csv = [] existing_users = user_list()['users'] From 24d87ea40eefbd5ae4fcda45be72314a30bb28d1 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 14:09:13 +0200 Subject: [PATCH 30/48] user import: validate that groups and domains exists --- src/yunohost/user.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index ce2c2c34f..1b0e47f80 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -671,9 +671,12 @@ def user_import(operation_logger, csvfile, update=False, delete=False): L = [l.strip() for l in L] return L - users_in_csv = [] existing_users = user_list()['users'] + existing_groups = user_group_list()["groups"] + existing_domains = domain_list()["domains"] + reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') + users_in_csv = [] missing_columns = [key for key in FIELDS_FOR_IMPORT.keys() if key not in reader.fieldnames] if missing_columns: @@ -688,9 +691,29 @@ def user_import(operation_logger, csvfile, update=False, delete=False): # Check for duplicated username lines if user['username'] in users_in_csv: - format_errors.append(f'username: {user[username]} (duplicated)') + format_errors.append(f"username '{user[username]}' duplicated") users_in_csv.append(user['username']) + # Validate that groups exist + user['groups'] = to_list(user['groups']) + unknown_groups = [g for g in user['groups'] if g not in existing_groups] + if unknown_groups: + format_errors.append(f"username '{user[username]}': unknown groups %s" % ', '.join(unknown_groups)) + + # Validate that domains exist + user['mail-alias'] = to_list(user['mail-alias']) + user['mail-forward'] = to_list(user['mail-forward']) + user['domain'] = user['mail'].split('@')[1] + + unknown_domains = [] + if user['domain'] not in existing_domains: + unknown_domains.append(user['domain']) + + unknown_domains += [mail.split('@')[1:] for mail in user['mail-alias'] if mail.split('@')[1:] not in existing_domains] + + if unknown_domains: + format_errors.append(f"username '{user[username]}': unknown domains %s" % ', '.join(unknown_domains)) + if format_errors: logger.error(m18n.n('user_import_bad_line', line=reader.line_num, @@ -699,10 +722,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False): continue # Choose what to do with this line and prepare data - user['groups'] = to_list(user['groups']) - user['mail-alias'] = to_list(user['mail-alias']) - user['mail-forward'] = to_list(user['mail-forward']) - user['domain'] = user['mail'].split('@')[1] # User creation if user['username'] not in existing_users: From a40084460b96254c4f2b8a3a1ff7031168b60afe Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 14:10:20 +0200 Subject: [PATCH 31/48] csv -> CSV --- data/actionsmap/yunohost.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 6e9509bab..0c1895c47 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -222,11 +222,11 @@ user: type: open -u: full: --update - help: Update all existing users contained in the csv file (by default existing users are ignored) + help: Update all existing users contained in the CSV file (by default existing users are ignored) action: store_true -d: full: --delete - help: Delete all existing users that are not contained in the csv file (by default existing users are kept) + help: Delete all existing users that are not contained in the CSV file (by default existing users are kept) action: store_true subcategories: From 4de6bbdf84eb7965517395adacb7319506dc5ad3 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 14:38:40 +0200 Subject: [PATCH 32/48] user import: Try to optimize group operations --- src/yunohost/user.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 1b0e47f80..bc684ec0c 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -54,8 +54,10 @@ FIELDS_FOR_IMPORT = { 'mailbox-quota': r'^(\d+[bkMGT])|0$', 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' } + FIRST_ALIASES = ['root@', 'admin@', 'webmaster@', 'postmaster@', 'abuse@'] + def user_list(fields=None): from yunohost.utils.ldap import _get_ldap_interface @@ -779,18 +781,26 @@ def user_import(operation_logger, csvfile, update=False, delete=False): def update(new_infos, old_infos=False): remove_alias = None remove_forward = None - if info: + remove_groups = [] + add_groups = new_infos["groups"] + if old_infos: new_infos['mail'] = None if old_infos['mail'] == new_infos['mail'] else new_infos['mail'] remove_alias = list(set(old_infos['mail-alias']) - set(new_infos['mail-alias'])) remove_forward = list(set(old_infos['mail-forward']) - set(new_infos['mail-forward'])) new_infos['mail-alias'] = list(set(new_infos['mail-alias']) - set(old_infos['mail-alias'])) new_infos['mail-forward'] = list(set(new_infos['mail-forward']) - set(old_infos['mail-forward'])) - for group, infos in user_group_list()["groups"].items(): - if group == "all_users": + + remove_groups = list(set(old_infos["groups"]) - set(new_infos["groups"])) + add_groups = list(set(new_infos["groups"]) - set(old_infos["groups"])) + + for group, infos in existing_groups: + # Loop only on groups in 'remove_groups' + # Ignore 'all_users' and primary group + if group in ["all_users", new_infos['username']] or group not in remove_groups: continue # If the user is in this group (and it's not the primary group), # remove the member from the group - if new_infos['username'] != group and new_infos['username'] in infos["members"]: + if new_infos['username'] in infos["members"]: user_group_update(group, remove=new_infos['username'], sync_perm=False, from_import=True) user_update(new_infos['username'], @@ -802,7 +812,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): remove_mailforward=remove_forward, add_mailforward=new_infos['mail-forward'], from_import=True) - for group in new_infos['groups']: + for group in add_groups: user_group_update(group, add=new_infos['username'], sync_perm=False, from_import=True) users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] From 865d8e2c8c7838d8ccab813eb2d3b271e8168545 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 14:48:30 +0200 Subject: [PATCH 33/48] user import: fix typo --- src/yunohost/user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index bc684ec0c..07f3e546a 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -693,14 +693,14 @@ def user_import(operation_logger, csvfile, update=False, delete=False): # Check for duplicated username lines if user['username'] in users_in_csv: - format_errors.append(f"username '{user[username]}' duplicated") + format_errors.append(f"username '{user['username']}' duplicated") users_in_csv.append(user['username']) # Validate that groups exist user['groups'] = to_list(user['groups']) unknown_groups = [g for g in user['groups'] if g not in existing_groups] if unknown_groups: - format_errors.append(f"username '{user[username]}': unknown groups %s" % ', '.join(unknown_groups)) + format_errors.append(f"username '{user['username']}': unknown groups %s" % ', '.join(unknown_groups)) # Validate that domains exist user['mail-alias'] = to_list(user['mail-alias']) @@ -714,7 +714,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): unknown_domains += [mail.split('@')[1:] for mail in user['mail-alias'] if mail.split('@')[1:] not in existing_domains] if unknown_domains: - format_errors.append(f"username '{user[username]}': unknown domains %s" % ', '.join(unknown_domains)) + format_errors.append(f"username '{user['username']}': unknown domains %s" % ', '.join(unknown_domains)) if format_errors: logger.error(m18n.n('user_import_bad_line', From 4f6166f8bba942e420239bb3f8b32327ac3f915e Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 15:47:40 +0200 Subject: [PATCH 34/48] ldap config: Bump DbMaxSize to allow up to 100MB --- data/templates/slapd/config.ldif | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/templates/slapd/config.ldif b/data/templates/slapd/config.ldif index 4d72ab7cc..e1fe3b1b5 100644 --- a/data/templates/slapd/config.ldif +++ b/data/templates/slapd/config.ldif @@ -33,7 +33,7 @@ olcAuthzPolicy: none olcConcurrency: 0 olcConnMaxPending: 100 olcConnMaxPendingAuth: 1000 -olcSizeLimit: 10000 +olcSizeLimit: 50000 olcIdleTimeout: 0 olcIndexSubstrIfMaxLen: 4 olcIndexSubstrIfMinLen: 2 @@ -189,7 +189,7 @@ olcDbIndex: memberUid eq olcDbIndex: uniqueMember eq olcDbIndex: virtualdomain eq olcDbIndex: permission eq -olcDbMaxSize: 10485760 +olcDbMaxSize: 104857600 structuralObjectClass: olcMdbConfig # From 3ad48fd8bc323ad2bd42f4197f0b61cef54dac01 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 16:34:34 +0200 Subject: [PATCH 35/48] Fixes after tests on the battlefield --- src/yunohost/user.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 07f3e546a..f6cbeb9bc 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -655,10 +655,11 @@ def user_import(operation_logger, csvfile, update=False, delete=False): """ - import csv # CSV are needed only in this function + import csv # CSV are needed only in this function from moulinette.utils.text import random_ascii from yunohost.permission import permission_sync_to_user from yunohost.app import app_ssowatconf + from yunohost.domain import domain_list # Pre-validate data and prepare what should be done actions = { @@ -711,7 +712,8 @@ def user_import(operation_logger, csvfile, update=False, delete=False): if user['domain'] not in existing_domains: unknown_domains.append(user['domain']) - unknown_domains += [mail.split('@')[1:] for mail in user['mail-alias'] if mail.split('@')[1:] not in existing_domains] + unknown_domains += [mail.split('@', 1)[1] for mail in user['mail-alias'] if mail.split('@', 1)[1] not in existing_domains] + unknown_domains = set(unknown_domains) if unknown_domains: format_errors.append(f"username '{user['username']}': unknown domains %s" % ', '.join(unknown_domains)) @@ -744,7 +746,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): is_well_formatted = False if not is_well_formatted: - raise YunohostError('user_import_bad_file') + raise YunohostValidationError('user_import_bad_file') total = len(actions['created'] + actions['updated'] + actions['deleted']) @@ -793,7 +795,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): remove_groups = list(set(old_infos["groups"]) - set(new_infos["groups"])) add_groups = list(set(new_infos["groups"]) - set(old_infos["groups"])) - for group, infos in existing_groups: + for group, infos in existing_groups.items(): # Loop only on groups in 'remove_groups' # Ignore 'all_users' and primary group if group in ["all_users", new_infos['username']] or group not in remove_groups: @@ -813,6 +815,8 @@ def user_import(operation_logger, csvfile, update=False, delete=False): add_mailforward=new_infos['mail-forward'], from_import=True) for group in add_groups: + if group in ["all_users", new_infos['username']]: + continue user_group_update(group, add=new_infos['username'], sync_perm=False, from_import=True) users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] @@ -824,7 +828,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['deleted'] += 1 except YunohostError as e: on_failure(user, e) - progress("Deletion") + progress(f"Deleting {user}") for user in actions['updated']: try: @@ -832,7 +836,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['updated'] += 1 except YunohostError as e: on_failure(user['username'], e) - progress("Update") + progress(f"Updating {user['username']}") for user in actions['created']: try: @@ -844,7 +848,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): result['created'] += 1 except YunohostError as e: on_failure(user['username'], e) - progress("Creation") + progress(f"Creating {user['username']}") permission_sync_to_user() app_ssowatconf() From 0e2105311f1e376328a911dc0f961a5e99e87c28 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 16:36:38 +0200 Subject: [PATCH 36/48] user import: fix tests --- src/yunohost/tests/test_user-group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index bbedfc27f..d3b3c81aa 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -164,10 +164,10 @@ def test_export_user(mocker): result = user_export() aliases = ','.join([alias + maindomain for alias in FIRST_ALIASES]) should_be = ( - "username;firstname;lastname;password;mailbox-quota;mail;mail-alias;mail-forward;groups\r\n" - f"alice;Alice;White;;0;alice@{maindomain};{aliases};;dev\r\n" - f"bob;Bob;Snow;;0;bob@{maindomain};;;apps\r\n" - f"jack;Jack;Black;;0;jack@{maindomain};;;" + "username;firstname;lastname;password;mail;mail-alias;mail-forward;mailbox-quota;groups\r\n" + f"alice;Alice;White;;alice@{maindomain};{aliases};;0;dev\r\n" + f"bob;Bob;Snow;;bob@{maindomain};;;0;apps\r\n" + f"jack;Jack;Black;;jack@{maindomain};;;0;" ) assert result == should_be From 5af70af47d0612378cccde87823fe85326825ccb Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 27 Aug 2021 17:09:46 +0200 Subject: [PATCH 37/48] user import: Allow empty value for mailbox quota --- src/yunohost/user.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index f6cbeb9bc..0cdd0d3ae 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -51,7 +51,7 @@ FIELDS_FOR_IMPORT = { 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', 'mail-forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'mailbox-quota': r'^(\d+[bkMGT])|0$', + 'mailbox-quota': r'^(\d+[bkMGT])|0|$', 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' } @@ -688,7 +688,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): for user in reader: # Validate column values against regexes - format_errors = [key + ':' + str(user[key]) + format_errors = [f"{key}: '{user[key]}' doesn't match the expected format" for key, validator in FIELDS_FOR_IMPORT.items() if user[key] is None or not re.match(validator, user[key])] @@ -726,6 +726,7 @@ def user_import(operation_logger, csvfile, update=False, delete=False): continue # Choose what to do with this line and prepare data + user['mailbox-quota'] = user['mailbox-quota'] or "0" # User creation if user['username'] not in existing_users: From 5ec397d3545c0b9268fbfd98fcfbed86091aefcd Mon Sep 17 00:00:00 2001 From: Kay0u Date: Thu, 2 Sep 2021 12:30:49 +0200 Subject: [PATCH 38/48] Fix locales --- locales/fa.json | 2 +- locales/gl.json | 4 +- locales/uk.json | 106 ++++++++++++++++++++++++------------------------ 3 files changed, 56 insertions(+), 56 deletions(-) diff --git a/locales/fa.json b/locales/fa.json index a644716cf..5d9772459 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -602,7 +602,7 @@ "restore_removing_tmp_dir_failed": "پوشه موقت قدیمی حذف نشد", "restore_nothings_done": "هیچ چیز ترمیم و بازسازی نشد", "restore_not_enough_disk_space": "فضای کافی موجود نیست (فضا: {free_space:d} B ، فضای مورد نیاز: {needed_space:d} B ، حاشیه امنیتی: {margin:d} B)", - "restore_may_be_not_enough_disk_space": "به نظر می رسد سیستم شما فضای کافی ندارد (فضای آزاد: {free_space:d} B ، فضای مورد نیاز: {space_space:d} B ، حاشیه امنیتی: {margin:d} B)", + "restore_may_be_not_enough_disk_space": "به نظر می رسد سیستم شما فضای کافی ندارد (فضای آزاد: {free_space:d} B ، فضای مورد نیاز: {needed_space:d} B ، حاشیه امنیتی: {margin:d} B)", "restore_hook_unavailable": "اسکریپت ترمیم و بازسازی برای '{part}' در سیستم شما در دسترس نیست و همچنین در بایگانی نیز وجود ندارد", "restore_failed": "سیستم بازیابی نشد", "restore_extracting": "استخراج فایل های مورد نیاز از بایگانی…", diff --git a/locales/gl.json b/locales/gl.json index 56204a9ea..ca25fc303 100644 --- a/locales/gl.json +++ b/locales/gl.json @@ -546,7 +546,7 @@ "regenconf_file_remove_failed": "Non se puido eliminar o ficheiro de configuración '{conf}'", "service_enable_failed": "Non se puido facer que o servizo '{service}' se inicie automáticamente no inicio.\n\nRexistros recentes do servizo: {logs}", "service_disabled": "O servizo '{service}' xa non vai volver a ser iniciado ao inicio do sistema.", - "service_disable_failed": "Non se puido iniciar o servizo '{servizo}' ao inicio.\n\nRexistro recente do servizo: {logs}", + "service_disable_failed": "Non se puido iniciar o servizo '{service}' ao inicio.\n\nRexistro recente do servizo: {logs}", "service_description_yunohost-firewall": "Xestiona, abre e pecha a conexións dos portos aos servizos", "service_description_yunohost-api": "Xestiona as interaccións entre a interface web de YunoHost e o sistema", "service_description_ssh": "Permíteche conectar de xeito remoto co teu servidor a través dun terminal (protocolo SSH)", @@ -563,7 +563,7 @@ "service_description_dnsmasq": "Xestiona a resolución de nomes de dominio (DNS)", "service_description_yunomdns": "Permíteche chegar ao teu servidor utilizando 'yunohost.local' na túa rede local", "service_cmd_exec_failed": "Non se puido executar o comando '{command}'", - "service_already_stopped": "O servizo '{sevice}' xa está detido", + "service_already_stopped": "O servizo '{service}' xa está detido", "service_already_started": "O servizo '{service}' xa se está a executar", "service_added": "Foi engadido o servizo '{service}'", "service_add_failed": "Non se puido engadir o servizo '{service}'", diff --git a/locales/uk.json b/locales/uk.json index 2e466685b..a9b807981 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -61,14 +61,14 @@ "service_description_dovecot": "Дозволяє поштовим клієнтам отримувати доступ до електронної пошти (через IMAP і POP3)", "service_description_dnsmasq": "Обробляє дозвіл доменних імен (DNS)", "service_description_yunomdns": "Дозволяє вам отримати доступ до вашого сервера, використовуючи 'yunohost.local' у вашій локальній мережі", - "service_cmd_exec_failed": "Не вдалося виконати команду '{команда}'", + "service_cmd_exec_failed": "Не вдалося виконати команду '{command}'", "service_already_stopped": "Служба '{service}' вже зупинена", "service_already_started": "Служба '{service}' вже запущена", "service_added": "Служба '{service}' була додана", "service_add_failed": "Не вдалося додати службу '{service}'", - "server_reboot_confirm": "Сервер негайно перезавантажиться, ви впевнені? [{Відповіді}]", + "server_reboot_confirm": "Сервер негайно перезавантажиться, ви впевнені? [{answers}]", "server_reboot": "сервер перезавантажиться", - "server_shutdown_confirm": "Сервер буде негайно виключений, ви впевнені? [{Відповіді}].", + "server_shutdown_confirm": "Сервер буде негайно виключений, ви впевнені? [{answers}].", "server_shutdown": "сервер вимкнеться", "root_password_replaced_by_admin_password": "Ваш кореневої пароль був замінений на пароль адміністратора.", "root_password_desynchronized": "Пароль адміністратора був змінений, але YunoHost не зміг поширити це на пароль root!", @@ -82,7 +82,7 @@ "restore_hook_unavailable": "Сценарій відновлення для '{part}' недоступним у вашій системі і в архіві його теж немає", "restore_failed": "Не вдалося відновити систему", "restore_extracting": "Витяг необхідних файлів з архіву…", - "restore_confirm_yunohost_installed": "Ви дійсно хочете відновити вже встановлену систему? [{Відповіді}].", + "restore_confirm_yunohost_installed": "Ви дійсно хочете відновити вже встановлену систему? [{answers}].", "restore_complete": "відновлення завершено", "restore_cleaning_failed": "Не вдалося очистити тимчасовий каталог відновлення", "restore_backup_too_old": "Цей архів резервних копій не може бути відновлений, бо він отриманий з дуже старою версією YunoHost.", @@ -91,12 +91,12 @@ "regex_with_only_domain": "Ви не можете використовувати regex для домену, тільки для шляху.", "regex_incompatible_with_tile": "/! \\ Packagers! Дозвіл '{permission}' має значення show_tile 'true', тому ви не можете визначити regex URL в якості основного URL.", "regenconf_need_to_explicitly_specify_ssh": "Конфігурація ssh була змінена вручну, але вам потрібно явно вказати категорію 'ssh' з --force, щоб застосувати зміни.", - "regenconf_pending_applying": "Застосування очікує конфігурації для категорії '{категорія}'...", + "regenconf_pending_applying": "Застосування очікує конфігурації для категорії '{category}'...", "regenconf_failed": "Не вдалося відновити конфігурацію для категорії (категорій): {categories}", - "regenconf_dry_pending_applying": "Перевірка очікує конфігурації, яка була б застосована для категорії '{категорія}'…", - "regenconf_would_be_updated": "Конфігурація була б оновлена для категорії '{категорія}'", + "regenconf_dry_pending_applying": "Перевірка очікує конфігурації, яка була б застосована для категорії '{category}'…", + "regenconf_would_be_updated": "Конфігурація була б оновлена для категорії '{category}'", "regenconf_updated": "Конфігурація оновлена для категорії '{category}'", - "regenconf_up_to_date": "Конфігурація вже оновлена для категорії '{категорія}'", + "regenconf_up_to_date": "Конфігурація вже оновлена для категорії '{category}'", "regenconf_now_managed_by_yunohost": "Конфігураційний файл '{conf}' тепер управляється YunoHost (категорія {category}).", "regenconf_file_updated": "Конфігураційний файл '{conf}' оновлений", "regenconf_file_removed": "Конфігураційний файл '{conf}' видалений", @@ -145,7 +145,7 @@ "packages_upgrade_failed": "Не вдалося оновити всі пакети", "operation_interrupted": "Операція була перервана вручну?", "invalid_number": "Повинно бути число", - "not_enough_disk_space": "Недостатньо вільного місця на \"{шлях} '.", + "not_enough_disk_space": "Недостатньо вільного місця на \"{path} '.", "migrations_to_be_ran_manually": "Міграція {id} повинна бути запущена вручну. Будь ласка, перейдіть в розділ Інструменти → Міграції на сторінці веб-адміністратора або виконайте команду `yunohost tools migrations run`.", "migrations_success_forward": "Міграція {id} завершена", "migrations_skip_migration": "Пропуск міграції {id}...", @@ -258,7 +258,7 @@ "hook_name_unknown": "Невідоме ім'я хука '{name}'", "hook_list_by_invalid": "Це властивість не може бути використано для перерахування хуков", "hook_json_return_error": "Не вдалося розпізнати повернення з хука {path}. Помилка: {msg}. Необроблений контент: {raw_content}", - "hook_exec_not_terminated": "Скрипт не завершився належним чином: {шлях}", + "hook_exec_not_terminated": "Скрипт не завершився належним чином: {path}", "hook_exec_failed": "Не вдалося запустити скрипт: {path}", "group_user_not_in_group": "Користувач {user} не входить в групу {group}", "group_user_already_in_group": "Користувач {user} вже в групі {group}", @@ -297,17 +297,17 @@ "global_settings_setting_security_password_admin_strength": "Надійність пароля адміністратора", "global_settings_setting_security_nginx_compatibility": "Компроміс між сумісністю і безпекою для веб-сервера NGINX. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", "global_settings_setting_pop3_enabled": "Включити протокол POP3 для поштового сервера.", - "global_settings_reset_success": "Попередні настройки тепер збережені в {шлях}.", + "global_settings_reset_success": "Попередні настройки тепер збережені в {path}.", "global_settings_key_doesnt_exists": "Ключ '{settings_key}' не існує в глобальних налаштуваннях, ви можете побачити всі доступні ключі, виконавши команду 'yunohost settings list'.", - "global_settings_cant_write_settings": "Неможливо зберегти файл настройок, причина: {причина}", - "global_settings_cant_serialize_settings": "Не вдалося серіалізовать дані налаштувань, причина: {причина}", - "global_settings_cant_open_settings": "Не вдалося відкрити файл настройок, причина: {причина}", + "global_settings_cant_write_settings": "Неможливо зберегти файл настройок, причина: {reason}", + "global_settings_cant_serialize_settings": "Не вдалося серіалізовать дані налаштувань, причина: {reason}", + "global_settings_cant_open_settings": "Не вдалося відкрити файл настройок, причина: {reason}", "global_settings_bad_type_for_setting": "Поганий тип для настройки {setting}, отриманий {received_type}, очікується {expected_type}", "global_settings_bad_choice_for_enum": "Поганий вибір для настройки {setting}, отримано '{choice}', але доступні наступні варіанти: {available_choices}.", "firewall_rules_cmd_failed": "Деякі команди правил брандмауера не спрацювали. Більш детальна інформація в журналі.", "firewall_reloaded": "брандмауер перезавантажений", "firewall_reload_failed": "Не вдалося перезавантажити брандмауер", - "file_does_not_exist": "Файл {шлях} не існує.", + "file_does_not_exist": "Файл {path} не існує.", "field_invalid": "Неприпустиме поле '{}'", "experimental_feature": "Попередження: Ця функція є експериментальною і не вважається стабільною, ви не повинні використовувати її, якщо не знаєте, що робите.", "extracting": "Витяг...", @@ -321,8 +321,8 @@ "dyndns_key_generating": "Генерація DNS-ключа... Це може зайняти деякий час.", "dyndns_ip_updated": "Оновлення свій IP-адресу в DynDNS", "dyndns_ip_update_failed": "Не вдалося оновити IP-адреса в DynDNS", - "dyndns_could_not_check_available": "Не вдалося перевірити наявність певної {домен} на {провайдера}.", - "dyndns_could_not_check_provide": "Не вдалося перевірити, чи може {провайдер} надати {домен}.", + "dyndns_could_not_check_available": "Не вдалося перевірити наявність певної {domain} на {provider}.", + "dyndns_could_not_check_provide": "Не вдалося перевірити, чи може {provider} надати {domain}.", "dpkg_lock_not_available": "Ця команда не може бути виконана прямо зараз, тому що інша програма, схоже, використовує блокування dpkg (системного менеджера пакетів).", "dpkg_is_broken": "Ви не можете зробити це прямо зараз, тому що dpkg/APT (системні менеджери пакетів), схоже, знаходяться в зламаному стані... Ви можете спробувати вирішити цю проблему, підключившись через SSH і виконавши `sudo apt install --fix-broken` і/або `sudo dpkg --configure -a`.", "downloading": "Завантаження…", @@ -331,7 +331,7 @@ "domain_unknown": "невідомий домен", "domain_name_unknown": "Домен '{domain}' невідомий", "domain_uninstall_app_first": "Ці додатки все ще встановлені на вашому домені: {apps} ласка, видаліть їх за допомогою 'yunohost app remove the_app_id' або перемістити їх на інший домен за допомогою 'yunohost app change-url the_app_id', перш ніж приступити до видалення домену.", - "domain_remove_confirm_apps_removal": "Видалення цього домену призведе до видалення цих додатків: {apps} Ви впевнені, що хочете це зробити? [{Відповіді}].", + "domain_remove_confirm_apps_removal": "Видалення цього домену призведе до видалення цих додатків: {apps} Ви впевнені, що хочете це зробити? [{answers}].", "domain_hostname_failed": "Неможливо встановити нове ім'я хоста. Це може викликати проблеми в подальшому (можливо, все буде в порядку).", "domain_exists": "Домен вже існує", "domain_dyndns_root_unknown": "Невідомий кореневої домен DynDNS", @@ -367,7 +367,7 @@ "diagnosis_http_hairpinning_issue_details": "Можливо, це пов'язано з блоком/маршрутизатором вашого інтернет-провайдера. В результаті, люди ззовні вашої локальної мережі зможуть отримати доступ до вашого сервера, як і очікувалося, але не люди зсередини локальної мережі (як ви, ймовірно?) При використанні доменного імені або глобального IP. Можливо, ви зможете поліпшити ситуацію, глянувши на https://yunohost.org/dns_local_network .", "diagnosis_http_hairpinning_issue": "Схоже, що у вашій локальній мережі не включена проброска.", "diagnosis_ports_forwarding_tip": "Щоб вирішити цю проблему, вам, швидше за все, потрібно налаштувати кидок портів на вашому інтернет-маршрутизатор, як описано в https://yunohost.org/isp_box_config.", - "diagnosis_ports_needed_by": "Відкриття цього порту необхідно для функцій {категорії} (служба {сервіс}).", + "diagnosis_ports_needed_by": "Відкриття цього порту необхідно для функцій {category} (служба {service}).", "diagnosis_ports_ok": "Порт {port} доступний ззовні.", "diagnosis_ports_partially_unreachable": "Порт {port} не доступний ззовні в IPv {failed}.", "diagnosis_ports_unreachable": "Порт {port} недоступний ззовні.", @@ -383,8 +383,8 @@ "diagnosis_description_basesystem": "Базова система", "diagnosis_security_vulnerable_to_meltdown_details": "Щоб виправити це, вам слід оновити систему і перезавантажитися, щоб завантажити нове ядро linux (або звернутися до вашого серверного провайдеру, якщо це не спрацює). Додаткову інформацію див. На сайті https://meltdownattack.com/.", "diagnosis_security_vulnerable_to_meltdown": "Схоже, що ви уразливі до критичної уразливості безпеки Meltdown.", - "diagnosis_rootfstotalspace_critical": "Коренева файлова система має тільки {простір}, що вельми тривожно! Швидше за все, дисковий простір закінчиться дуже швидко! Рекомендується мати не менше 16 ГБ для кореневої файлової системи.", - "diagnosis_rootfstotalspace_warning": "Коренева файлова система має тільки {простір}. Це може бути нормально, але будьте обережні, тому що в кінцевому підсумку дисковий простір може швидко закінчитися... Рекомендується мати не менше 16 ГБ для кореневої файлової системи.", + "diagnosis_rootfstotalspace_critical": "Коренева файлова система має тільки {space}, що вельми тривожно! Швидше за все, дисковий простір закінчиться дуже швидко! Рекомендується мати не менше 16 ГБ для кореневої файлової системи.", + "diagnosis_rootfstotalspace_warning": "Коренева файлова система має тільки {space}. Це може бути нормально, але будьте обережні, тому що в кінцевому підсумку дисковий простір може швидко закінчитися... Рекомендується мати не менше 16 ГБ для кореневої файлової системи.", "diagnosis_regenconf_manually_modified_details": "Це можливо нормально, якщо ви знаєте, що робите! YunoHost перестане оновлювати цей файл автоматично... Але врахуйте, що поновлення YunoHost можуть містити важливі рекомендовані зміни. Якщо ви хочете, ви можете перевірити відмінності за допомогою команди yunohost tools regen-conf {category} --dry-run --with-diff і примусово повернути рекомендовану конфігурацію за допомогою yunohost tools regen- conf {category} --force .", "diagnosis_regenconf_manually_modified": "Конфігураційний файл {file} , схоже, був змінений вручну.", "diagnosis_regenconf_allgood": "Всі конфігураційні файли відповідають рекомендованої конфігурації!", @@ -393,7 +393,7 @@ "diagnosis_mail_queue_unavailable": "Неможливо дізнатися кількість очікують листів в черзі", "diagnosis_mail_queue_ok": "{nb_pending} відкладені листи в поштових чергах", "diagnosis_mail_blacklist_website": "Після визначення причини, по якій ви потрапили в чорний список, і її усунення, ви можете попросити видалити ваш IP або домен на {blacklist_website}.", - "diagnosis_mail_blacklist_reason": "Причина внесення в чорний список: {причина}", + "diagnosis_mail_blacklist_reason": "Причина внесення в чорний список: {reason}", "diagnosis_mail_blacklist_listed_by": "Ваш IP або домен {item} знаходиться в чорному списку {blacklist_name}.", "diagnosis_mail_blacklist_ok": "IP-адреси і домени, які використовуються цим сервером, не внесені в чорний список", "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Поточний зворотний DNS: {rdns_domain}
Очікуване значення: {ehlo_domain} .", @@ -439,11 +439,11 @@ "updating_apt_cache": "Вибірка доступних оновлень для системних пакетів...", "update_apt_cache_warning": "Щось пішло не так при оновленні кеша APT (менеджера пакунків Debian). Ось дамп рядків sources.list, який може допомогти визначити проблемні рядки: {sourceslist}", "update_apt_cache_failed": "Неможливо оновити кеш APT (менеджер пакетів Debian). Ось дамп рядків sources.list, який може допомогти визначити проблемні рядки: {sourceslist}", - "unrestore_app": "{App} не буде поновлено", + "unrestore_app": "{app} не буде поновлено", "unlimit": "немає квоти", "unknown_main_domain_path": "Невідомий домен або шлях для '{app}'. Вам необхідно вказати домен і шлях, щоб мати можливість вказати URL для дозволу.", "unexpected_error": "Щось пішло не так: {error}", - "unbackup_app": "{App} НЕ буде збережений", + "unbackup_app": "{app} НЕ буде збережений", "tools_upgrade_special_packages_completed": "Оновлення пакета YunoHost завершено. Натисніть [Enter] для повернення командного рядка", "tools_upgrade_special_packages_explanation": "Спеціальне оновлення триватиме у фоновому режимі. Будь ласка, не запускайте ніяких інших дій на вашому сервері протягом наступних ~ 10 хвилин (в залежності від швидкості обладнання). Після цього вам, можливо, доведеться заново увійти в веб-адмін. Журнал поновлення буде доступний в Інструменти → Журнал (в веб-адміном) або за допомогою 'yunohost log list' (з командного рядка).", "tools_upgrade_special_packages": "Тепер оновлюємо \"спеціальні\" (пов'язані з yunohost) пакети…", @@ -467,12 +467,12 @@ "service_start_failed": "Не вдалося запустити службу '{service}' Recent service logs: {logs}", "diagnosis_mail_outgoing_port_25_ok": "Поштовий сервер SMTP може відправляти електронні листи (вихідний порт 25 не заблокований).", "diagnosis_swap_tip": "Будь ласка, будьте обережні і знайте, що якщо сервер розміщує своп на SD-карті або SSD-накопичувачі, це може різко скоротити термін служби устройства`.", - "diagnosis_swap_ok": "Система має {усього} свопу!", - "diagnosis_swap_notsomuch": "Система має тільки {усього} свопу. Щоб уникнути ситуацій, коли в системі закінчується пам'ять, слід передбачити наявність не менше {рекомендованого} обсягу підкачки.", - "diagnosis_swap_none": "В системі повністю відсутній своп. Ви повинні розглянути можливість додавання принаймні {рекомендованого} обсягу підкачки, щоб уникнути ситуацій, коли системі не вистачає пам'яті.", - "diagnosis_ram_ok": "Система все ще має {доступно} ({доступний_процент}%) оперативної пам'яті з {усього}.", - "diagnosis_ram_low": "У системі є {доступно} ({доступний_процент}%) оперативної пам'яті (з {усього}). Будьте уважні.", - "diagnosis_ram_verylow": "Система має тільки {доступне} ({доступний_процент}%) оперативної пам'яті! (З {усього})", + "diagnosis_swap_ok": "Система має {total} свопу!", + "diagnosis_swap_notsomuch": "Система має тільки {total} свопу. Щоб уникнути ситуацій, коли в системі закінчується пам'ять, слід передбачити наявність не менше {recommended} обсягу підкачки.", + "diagnosis_swap_none": "В системі повністю відсутній своп. Ви повинні розглянути можливість додавання принаймні {recommended} обсягу підкачки, щоб уникнути ситуацій, коли системі не вистачає пам'яті.", + "diagnosis_ram_ok": "Система все ще має {available} ({available_percent}%) оперативної пам'яті з {total}.", + "diagnosis_ram_low": "У системі є {available} ({available_percent}%) оперативної пам'яті (з {total}). Будьте уважні.", + "diagnosis_ram_verylow": "Система має тільки {available} ({available_percent}%) оперативної пам'яті! (З {total})", "diagnosis_diskusage_ok": "У сховищі {mountpoint} (на пристрої {device} ) залишилося {free} ({free_percent}%) вільного місця (з {total})!", "diagnosis_diskusage_low": "Сховище {mountpoint} (на пристрої {device} ) має тільки {free} ({free_percent}%) вільного місця (з {total}). Будьте уважні.", "diagnosis_diskusage_verylow": "Сховище {mountpoint} (на пристрої {device} ) має тільки {free} ({free_percent}%) вільного місця (з {total}). Вам дійсно варто подумати про очищення простору!", @@ -480,7 +480,7 @@ "diagnosis_services_bad_status": "Сервіс {service} знаходиться в {status} :(", "diagnosis_services_conf_broken": "Конфігурація порушена для служби {service}!", "diagnosis_services_running": "Служба {service} запущена!", - "diagnosis_domain_expires_in": "Термін дії {домену} закінчується через {днів} днів.", + "diagnosis_domain_expires_in": "Термін дії {domain} закінчується через {days} днів.", "diagnosis_domain_expiration_error": "Термін дії деяких доменів закінчується ДУЖЕ СКОРО!", "diagnosis_domain_expiration_warning": "Термін дії деяких доменів закінчиться найближчим часом!", "diagnosis_domain_expiration_success": "Ваші домени зареєстровані і не збираються спливати найближчим часом.", @@ -492,8 +492,8 @@ "diagnosis_dns_point_to_doc": "Якщо вам потрібна допомога з налаштування DNS-записів, зверніться до документації на сайті
https://yunohost.org/dns_config .", "diagnosis_dns_discrepancy": "Наступний запис DNS, схоже, не відповідає рекомендованої конфігурації:
Type: {type}
Name: {name}
Поточне значення: {current}
Очікуване значення: {value} ", "diagnosis_dns_missing_record": "Згідно рекомендованої конфігурації DNS, ви повинні додати запис DNS з наступною інформацією.
Тип: {type}
Name: {name}
Value: < code> {value} .", - "diagnosis_dns_bad_conf": "Деякі DNS-записи відсутні або невірні для домену {домен} (категорія {категорія})", - "diagnosis_dns_good_conf": "DNS-записи правильно налаштовані для домену {домен} (категорія {категорія})", + "diagnosis_dns_bad_conf": "Деякі DNS-записи відсутні або невірні для домену {domain} (категорія {category})", + "diagnosis_dns_good_conf": "DNS-записи правильно налаштовані для домену {domain} (категорія {category})", "diagnosis_ip_weird_resolvconf_details": "Файл /etc/resolv.conf повинен бути симлінк на /etc/resolvconf/run/resolv.conf , що вказує на 127.0.0.1 (dnsmasq ). Якщо ви хочете вручну налаштувати DNS Резолвер, відредагуйте /etc/resolv.dnsmasq.conf .", "diagnosis_ip_weird_resolvconf": "Дозвіл DNS, схоже, працює, але схоже, що ви використовуєте для користувача /etc/resolv.conf .", "diagnosis_ip_broken_resolvconf": "Схоже, що дозвіл доменних імен на вашому сервері порушено, що пов'язано з тим, що /etc/resolv.conf не вказує на 127.0.0.1 .", @@ -507,23 +507,23 @@ "diagnosis_ip_connected_ipv6": "Сервер підключений до Інтернету через IPv6!", "diagnosis_ip_no_ipv4": "Сервер не має працюючого IPv4.", "diagnosis_ip_connected_ipv4": "Сервер підключений до Інтернету через IPv4!", - "diagnosis_no_cache": "Для категорії \"{категорія} 'ще немає кеша діагнозів.", - "diagnosis_failed": "Не вдалося результат діагностики для категорії '{категорія}': {error}", - "diagnosis_everything_ok": "Все виглядає добре для {категорії}!", - "diagnosis_found_warnings": "Знайдено {попередження} пунктів, які можна поліпшити для {категорії}.", - "diagnosis_found_errors_and_warnings": "Знайдено {errors} істотний (і) питання (и) (і {попередження} попередження (я)), що відносяться до {category}!", + "diagnosis_no_cache": "Для категорії \"{category} 'ще немає кеша діагнозів.", + "diagnosis_failed": "Не вдалося результат діагностики для категорії '{category}': {error}", + "diagnosis_everything_ok": "Все виглядає добре для {category}!", + "diagnosis_found_warnings": "Знайдено {warnings} пунктів, які можна поліпшити для {category}.", + "diagnosis_found_errors_and_warnings": "Знайдено {errors} істотний (і) питання (и) (і {warnings} попередження (я)), що відносяться до {category}!", "diagnosis_found_errors": "Знайдена {errors} важлива проблема (і), пов'язана з {category}!", - "diagnosis_ignored_issues": "(+ {Nb_ignored} проігнорована проблема (проблеми))", - "diagnosis_cant_run_because_of_dep": "Неможливо запустити діагностику для {категорії}, поки є важливі проблеми, пов'язані з {глибиною}.", - "diagnosis_cache_still_valid": "(Кеш все ще дійсний для діагностики {категорії}. Повторна діагностика поки не проводиться!)", - "diagnosis_failed_for_category": "Не вдалося провести діагностику для категорії '{категорія}': {error}", + "diagnosis_ignored_issues": "(+ {nb_ignored} проігнорована проблема (проблеми))", + "diagnosis_cant_run_because_of_dep": "Неможливо запустити діагностику для {category}, поки є важливі проблеми, пов'язані з {dep}.", + "diagnosis_cache_still_valid": "(Кеш все ще дійсний для діагностики {category}. Повторна діагностика поки не проводиться!)", + "diagnosis_failed_for_category": "Не вдалося провести діагностику для категорії '{category}': {error}", "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Diagnosis в веб-адміном або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", "diagnosis_package_installed_from_sury_details": "Деякі пакети були ненавмисно встановлені з стороннього сховища під назвою Sury. Команда YunoHost поліпшила стратегію роботи з цими пакетами, але очікується, що в деяких системах, які встановили додатки PHP7.3 ще на Stretch, залишаться деякі невідповідності. Щоб виправити цю ситуацію, спробуйте виконати наступну команду: {cmd_to_fix} .", "diagnosis_package_installed_from_sury": "Деякі системні пакети повинні бути знижені в статусі", "diagnosis_backports_in_sources_list": "Схоже, що apt (менеджер пакетів) налаштований на використання сховища backports. Якщо ви не знаєте, що робите, ми настійно не рекомендуємо встановлювати пакети з backports, тому що це може привести до нестабільності або конфліктів у вашій системі.", "diagnosis_basesystem_ynh_inconsistent_versions": "Ви використовуєте несумісні версії пакетів YunoHost... швидше за все, через невдалий або часткового оновлення.", "diagnosis_basesystem_ynh_main_version": "Сервер працює під управлінням YunoHost {main_version} ({repo})", - "diagnosis_basesystem_ynh_single_version": "{Пакет} версія: {версія} ({repo})", + "diagnosis_basesystem_ynh_single_version": "{package} версія: {version} ({repo})", "diagnosis_basesystem_kernel": "Сервер працює під управлінням ядра Linux {kernel_version}", "diagnosis_basesystem_host": "Сервер працює під управлінням Debian {debian_version}", "diagnosis_basesystem_hardware_model": "Модель сервера - {model}", @@ -531,7 +531,7 @@ "custom_app_url_required": "Ви повинні надати URL для оновлення вашого призначеного для користувача додатки {app}.", "confirm_app_install_thirdparty": "НЕБЕЗПЕЧНО! Ця програма не входить в каталог додатків YunoHost. Установлення сторонніх додатків може порушити цілісність і безпеку вашої системи. Вам не слід встановлювати його, якщо ви не знаєте, що робите. НІЯКОЇ ПІДТРИМКИ НЕ БУДЕ, якщо цей додаток не буде працювати або зламає вашу систему... Якщо ви все одно готові піти на такий ризик, введіть '{answers}'.", "confirm_app_install_danger": "НЕБЕЗПЕЧНО! Відомо, що це додаток все ще експериментальне (якщо не сказати, що воно явно не працює)! Вам не слід встановлювати його, якщо ви не знаєте, що робите. Ніякої підтримки не буде надано, якщо цей додаток не буде працювати або зламає вашу систему... Якщо ви все одно готові ризикнути, введіть '{answers}'.", - "confirm_app_install_warning": "Попередження: Ця програма може працювати, але не дуже добре інтегровано в YunoHost. Деякі функції, такі як єдина реєстрація та резервне копіювання/відновлення, можуть бути недоступні. Все одно встановити? [{Відповіді}]. ", + "confirm_app_install_warning": "Попередження: Ця програма може працювати, але не дуже добре інтегровано в YunoHost. Деякі функції, такі як єдина реєстрація та резервне копіювання/відновлення, можуть бути недоступні. Все одно встановити? [{answers}]. ", "certmanager_unable_to_parse_self_CA_name": "Не вдалося розібрати ім'я самоподпісивающегося центру (файл: {file})", "certmanager_self_ca_conf_file_not_found": "Не вдалося знайти файл конфігурації для самоподпісивающегося центру (файл: {file})", "certmanager_no_cert_file": "Не вдалося розпізнати файл сертифіката для домену {domain} (файл: {file})", @@ -546,12 +546,12 @@ "certmanager_cert_renew_success": "Сертифікат Let's Encrypt оновлений для домену '{domain}'", "certmanager_cert_install_success_selfsigned": "Самоподпісанний сертифікат тепер встановлений для домену '{domain}'", "certmanager_cert_install_success": "Сертифікат Let's Encrypt тепер встановлений для домену '{domain}'", - "certmanager_cannot_read_cert": "Щось не так сталося при спробі відкрити поточний сертифікат для домену {domain} (файл: {файл}), причина: {причина}", + "certmanager_cannot_read_cert": "Щось не так сталося при спробі відкрити поточний сертифікат для домену {domain} (файл: {file}), причина: {reason}", "certmanager_attempt_to_replace_valid_cert": "Ви намагаєтеся перезаписати хороший і дійсний сертифікат для домену {domain}! (Використовуйте --force для обходу)", "certmanager_attempt_to_renew_valid_cert": "Термін дії сертифіката для домену '{domain} \"не закінчується! (Ви можете використовувати --force, якщо знаєте, що робите)", "certmanager_attempt_to_renew_nonLE_cert": "Сертифікат для домену '{domain}' не випущено Let's Encrypt. Неможливо продовжити його автоматично!", "certmanager_acme_not_configured_for_domain": "Завдання ACME не може бути запущена для {domain} прямо зараз, тому що в його nginx conf відсутній відповідний фрагмент коду... Будь ласка, переконайтеся, що конфігурація nginx оновлена за допомогою `yunohost tools regen-conf nginx --dry-run - with-diff`.", - "backup_with_no_restore_script_for_app": "{App} не має скрипта відновлення, ви не зможете автоматично відновити резервну копію цього додатка.", + "backup_with_no_restore_script_for_app": "{app} не має скрипта відновлення, ви не зможете автоматично відновити резервну копію цього додатка.", "backup_with_no_backup_script_for_app": "Додаток '{app}' не має скрипта резервного копіювання. Ігнорування.", "backup_unable_to_organize_files": "Неможливо використовувати швидкий метод для організації файлів в архіві", "backup_system_part_failed": "Не вдалося створити резервну копію системної частини '{part}'.", @@ -565,7 +565,7 @@ "backup_no_uncompress_archive_dir": "Немає такого каталогу нестислого архіву", "backup_mount_archive_for_restore": "Підготовка архіву для відновлення...", "backup_method_tar_finished": "Створено архів резервного копіювання TAR", - "backup_method_custom_finished": "Призначений для користувача метод резервного копіювання '{метод}' завершено", + "backup_method_custom_finished": "Призначений для користувача метод резервного копіювання '{method}' завершено", "backup_method_copy_finished": "Резервне копіювання завершено", "backup_hook_unknown": "Гачок резервного копіювання '{hook}' невідомий", "backup_deleted": "Резервна копія видалена", @@ -575,7 +575,7 @@ "backup_csv_creation_failed": "Не вдалося створити CSV-файл, необхідний для відновлення", "backup_csv_addition_failed": "Не вдалося додати файли для резервного копіювання в CSV-файл", "backup_creation_failed": "Не вдалося створити архів резервного копіювання", - "backup_create_size_estimation": "Архів буде містити близько {розмір} даних.", + "backup_create_size_estimation": "Архів буде містити близько {size} даних.", "backup_created": "Резервна копія створена", "backup_couldnt_bind": "Не вдалося зв'язати {src} з {dest}.", "backup_copying_to_organize_the_archive": "Копіювання {size} MB для організації архіву", @@ -592,7 +592,7 @@ "backup_archive_broken_link": "Не вдалося отримати доступ до архіву резервного копіювання (непрацююча посилання на {path})", "backup_archive_app_not_found": "Не вдалося знайти {app} в архіві резервного копіювання", "backup_applying_method_tar": "Створення резервного TAR-архіву...", - "backup_applying_method_custom": "Виклик для користувача методу резервного копіювання '{метод}'...", + "backup_applying_method_custom": "Виклик для користувача методу резервного копіювання '{method}'...", "backup_applying_method_copy": "Копіювання всіх файлів в резервну копію...", "backup_app_failed": "Не вдалося створити резервну копію {app}", "backup_actually_backuping": "Створення резервного архіву з зібраних файлів...", @@ -612,7 +612,7 @@ "apps_catalog_init_success": "Система каталогу додатків инициализирована!", "apps_already_up_to_date": "Всі додатки вже оновлені", "app_packaging_format_not_supported": "Ця програма не може бути встановлено, тому що формат його упаковки не підтримується вашою версією YunoHost. Можливо, вам слід оновити вашу систему.", - "app_upgraded": "{App} оновлено", + "app_upgraded": "{app} оновлено", "app_upgrade_some_app_failed": "Деякі програми не можуть бути оновлені", "app_upgrade_script_failed": "Сталася помилка в сценарії оновлення програми", "app_upgrade_failed": "Не вдалося оновити {app}: {error}", @@ -630,10 +630,10 @@ "app_remove_after_failed_install": "Видалення програми після збою установки...", "app_requirements_unmeet": "Вимоги не виконані для {app}, пакет {pkgname} ({version}) повинен бути {spec}.", "app_requirements_checking": "Перевірка необхідних пакетів для {app}...", - "app_removed": "{App} видалено", - "app_not_properly_removed": "{App} не було видалено належним чином", + "app_removed": "{app} видалено", + "app_not_properly_removed": "{app} не було видалено належним чином", "app_not_installed": "Не вдалося знайти {app} в списку встановлених додатків: {all_apps}", - "app_not_correctly_installed": "{App}, схоже, неправильно встановлено", + "app_not_correctly_installed": "{app}, схоже, неправильно встановлено", "app_not_upgraded": "Додаток '{failed_app}' не вдалося оновити, і, як наслідок, оновлення таких програмах було скасовано: {apps}", "app_manifest_install_ask_is_public": "Чи повинно це додаток бути відкрито для анонімних відвідувачів?", "app_manifest_install_ask_admin": "Виберіть користувача-адміністратора для цього додатка", From caef1e0577a1b0f3d0136d337193bc1c65280b4c Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Thu, 2 Sep 2021 14:08:18 +0200 Subject: [PATCH 39/48] Update src/yunohost/log.py --- src/yunohost/log.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/yunohost/log.py b/src/yunohost/log.py index f9f9334fb..4994d608c 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -373,7 +373,11 @@ def is_unit_operation( if field in context: context.pop(field, None) - # Manage file or stream + # Context is made from args given to main function by argparse + # This context will be added in extra parameters in yml file, so this context should + # be serializable and short enough (it will be displayed in webadmin) + # Argparse can provide some File or Stream, so here we display the filename or + # the IOBase, if we have no name. for field, value in context.items(): if isinstance(value, IOBase): try: From f2487a2251fdc8c19dc5dbd14a8eb3707f97156f Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Thu, 2 Sep 2021 14:24:06 +0200 Subject: [PATCH 40/48] Avoid confusing things in user_list --- src/yunohost/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 0cdd0d3ae..65edf5821 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -64,7 +64,7 @@ def user_list(fields=None): ldap_attrs = { 'username': 'uid', - 'password': 'uid', + 'password': '', # We can't request password in ldap 'fullname': 'cn', 'firstname': 'givenName', 'lastname': 'sn', From e27f38ae69aeb9308d8166e366f764f8afd8378f Mon Sep 17 00:00:00 2001 From: "ljf (zamentur)" Date: Thu, 2 Sep 2021 14:33:06 +0200 Subject: [PATCH 41/48] Test group remove on csv import --- src/yunohost/tests/test_user-group.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index d3b3c81aa..ab7e72555 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -158,6 +158,7 @@ def test_import_user(mocker): assert len(user_res['alice']['mail-alias']) == 2 assert "albert" in group_res['dev']['members'] assert "alice" in group_res['apps']['members'] + assert "alice" not in group_res['dev']['members'] def test_export_user(mocker): From 57a2e4032fecb9eb4d2b59b4ab483fad50621eeb Mon Sep 17 00:00:00 2001 From: Kayou Date: Thu, 2 Sep 2021 16:52:16 +0200 Subject: [PATCH 42/48] replace msettings by Moulinette --- src/yunohost/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 57e8aac41..07d1773b3 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -631,7 +631,7 @@ def user_export(): writer.writerow(user) body = csv_io.getvalue().rstrip() - if msettings.get('interface') == 'api': + if Moulinette.interface.type == 'api': # We return a raw bottle HTTPresponse (instead of serializable data like # list/dict, ...), which is gonna be picked and used directly by moulinette from bottle import HTTPResponse From c197e171bbc3d18a095d0b9bb3a0f72d57afab3e Mon Sep 17 00:00:00 2001 From: yunohost-bot Date: Thu, 2 Sep 2021 15:04:02 +0000 Subject: [PATCH 43/48] [CI] Format code --- src/yunohost/log.py | 4 +- src/yunohost/tests/test_user-group.py | 80 +++--- src/yunohost/user.py | 386 +++++++++++++++----------- 3 files changed, 269 insertions(+), 201 deletions(-) diff --git a/src/yunohost/log.py b/src/yunohost/log.py index 6460d8d4a..3f6382af2 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -373,7 +373,7 @@ def is_unit_operation( context.pop(field, None) # Context is made from args given to main function by argparse - # This context will be added in extra parameters in yml file, so this context should + # This context will be added in extra parameters in yml file, so this context should # be serializable and short enough (it will be displayed in webadmin) # Argparse can provide some File or Stream, so here we display the filename or # the IOBase, if we have no name. @@ -382,7 +382,7 @@ def is_unit_operation( try: context[field] = value.name except: - context[field] = 'IOBase' + context[field] = "IOBase" operation_logger = OperationLogger(op_key, related_to, args=context) try: diff --git a/src/yunohost/tests/test_user-group.py b/src/yunohost/tests/test_user-group.py index ab7e72555..60e748108 100644 --- a/src/yunohost/tests/test_user-group.py +++ b/src/yunohost/tests/test_user-group.py @@ -117,53 +117,65 @@ def test_del_user(mocker): def test_import_user(mocker): import csv from io import StringIO - fieldnames = [u'username', u'firstname', u'lastname', u'password', - u'mailbox-quota', u'mail', u'mail-alias', u'mail-forward', - u'groups'] + + fieldnames = [ + "username", + "firstname", + "lastname", + "password", + "mailbox-quota", + "mail", + "mail-alias", + "mail-forward", + "groups", + ] with StringIO() as csv_io: - writer = csv.DictWriter(csv_io, fieldnames, delimiter=';', - quotechar='"') + writer = csv.DictWriter(csv_io, fieldnames, delimiter=";", quotechar='"') writer.writeheader() - writer.writerow({ - 'username': "albert", - 'firstname': "Albert", - 'lastname': "Good", - 'password': "", - 'mailbox-quota': "1G", - 'mail': "albert@" + maindomain, - 'mail-alias': "albert2@" + maindomain, - 'mail-forward': "albert@example.com", - 'groups': "dev", - }) - writer.writerow({ - 'username': "alice", - 'firstname': "Alice", - 'lastname': "White", - 'password': "", - 'mailbox-quota': "1G", - 'mail': "alice@" + maindomain, - 'mail-alias': "alice1@" + maindomain + ",alice2@" + maindomain, - 'mail-forward': "", - 'groups': "apps", - }) + writer.writerow( + { + "username": "albert", + "firstname": "Albert", + "lastname": "Good", + "password": "", + "mailbox-quota": "1G", + "mail": "albert@" + maindomain, + "mail-alias": "albert2@" + maindomain, + "mail-forward": "albert@example.com", + "groups": "dev", + } + ) + writer.writerow( + { + "username": "alice", + "firstname": "Alice", + "lastname": "White", + "password": "", + "mailbox-quota": "1G", + "mail": "alice@" + maindomain, + "mail-alias": "alice1@" + maindomain + ",alice2@" + maindomain, + "mail-forward": "", + "groups": "apps", + } + ) csv_io.seek(0) with message(mocker, "user_import_success"): user_import(csv_io, update=True, delete=True) - group_res = user_group_list()['groups'] - user_res = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] + group_res = user_group_list()["groups"] + user_res = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] assert "albert" in user_res assert "alice" in user_res assert "bob" not in user_res - assert len(user_res['alice']['mail-alias']) == 2 - assert "albert" in group_res['dev']['members'] - assert "alice" in group_res['apps']['members'] - assert "alice" not in group_res['dev']['members'] + assert len(user_res["alice"]["mail-alias"]) == 2 + assert "albert" in group_res["dev"]["members"] + assert "alice" in group_res["apps"]["members"] + assert "alice" not in group_res["dev"]["members"] def test_export_user(mocker): result = user_export() - aliases = ','.join([alias + maindomain for alias in FIRST_ALIASES]) + aliases = ",".join([alias + maindomain for alias in FIRST_ALIASES]) should_be = ( "username;firstname;lastname;password;mail;mail-alias;mail-forward;mailbox-quota;groups\r\n" f"alice;Alice;White;;alice@{maindomain};{aliases};;0;dev\r\n" diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 07d1773b3..c89f9a05f 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -44,18 +44,18 @@ from yunohost.log import is_unit_operation logger = getActionLogger("yunohost.user") FIELDS_FOR_IMPORT = { - 'username': r'^[a-z0-9_]+$', - 'firstname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', - 'lastname': r'^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$', - 'password': r'^|(.{3,})$', - 'mail': r'^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$', - 'mail-alias': r'^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'mail-forward': r'^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$', - 'mailbox-quota': r'^(\d+[bkMGT])|0|$', - 'groups': r'^|([a-z0-9_]+(,?[a-z0-9_]+)*)$' + "username": r"^[a-z0-9_]+$", + "firstname": r"^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$", + "lastname": r"^([^\W\d_]{1,30}[ ,.\'-]{0,3})+$", + "password": r"^|(.{3,})$", + "mail": r"^([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}))$", + "mail-alias": r"^|([\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$", + "mail-forward": r"^|([\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,}),?)+$", + "mailbox-quota": r"^(\d+[bkMGT])|0|$", + "groups": r"^|([a-z0-9_]+(,?[a-z0-9_]+)*)$", } -FIRST_ALIASES = ['root@', 'admin@', 'webmaster@', 'postmaster@', 'abuse@'] +FIRST_ALIASES = ["root@", "admin@", "webmaster@", "postmaster@", "abuse@"] def user_list(fields=None): @@ -63,47 +63,51 @@ def user_list(fields=None): from yunohost.utils.ldap import _get_ldap_interface ldap_attrs = { - 'username': 'uid', - 'password': '', # We can't request password in ldap - 'fullname': 'cn', - 'firstname': 'givenName', - 'lastname': 'sn', - 'mail': 'mail', - 'mail-alias': 'mail', - 'mail-forward': 'maildrop', - 'mailbox-quota': 'mailuserquota', - 'groups': 'memberOf', - 'shell': 'loginShell', - 'home-path': 'homeDirectory' + "username": "uid", + "password": "", # We can't request password in ldap + "fullname": "cn", + "firstname": "givenName", + "lastname": "sn", + "mail": "mail", + "mail-alias": "mail", + "mail-forward": "maildrop", + "mailbox-quota": "mailuserquota", + "groups": "memberOf", + "shell": "loginShell", + "home-path": "homeDirectory", } def display_default(values, _): return values[0] if len(values) == 1 else values display = { - 'password': lambda values, user: '', - 'mail': lambda values, user: display_default(values[:1], user), - 'mail-alias': lambda values, _: values[1:], - 'mail-forward': lambda values, user: [forward for forward in values if forward != user['uid'][0]], - 'groups': lambda values, user: [ - group[3:].split(',')[0] + "password": lambda values, user: "", + "mail": lambda values, user: display_default(values[:1], user), + "mail-alias": lambda values, _: values[1:], + "mail-forward": lambda values, user: [ + forward for forward in values if forward != user["uid"][0] + ], + "groups": lambda values, user: [ + group[3:].split(",")[0] for group in values - if not group.startswith('cn=all_users,') and - not group.startswith('cn=' + user['uid'][0] + ',')], - 'shell': lambda values, _: len(values) > 0 and values[0].strip() == "/bin/false" + if not group.startswith("cn=all_users,") + and not group.startswith("cn=" + user["uid"][0] + ",") + ], + "shell": lambda values, _: len(values) > 0 + and values[0].strip() == "/bin/false", } - attrs = set(['uid']) + attrs = set(["uid"]) users = {} if not fields: - fields = ['username', 'fullname', 'mail', 'mailbox-quota'] + fields = ["username", "fullname", "mail", "mailbox-quota"] for field in fields: if field in ldap_attrs: attrs.add(ldap_attrs[field]) else: - raise YunohostError('field_invalid', field) + raise YunohostError("field_invalid", field) ldap = _get_ldap_interface() result = ldap.search( @@ -120,7 +124,7 @@ def user_list(fields=None): values = user[ldap_attrs[field]] entry[field] = display.get(field, display_default)(values, user) - users[user['uid'][0]] = entry + users[user["uid"][0]] = entry return {"users": users} @@ -135,7 +139,7 @@ def user_create( password, mailbox_quota="0", mail=None, - from_import=False + from_import=False, ): from yunohost.domain import domain_list, _get_maindomain @@ -253,10 +257,9 @@ def user_create( # Attempt to create user home folder subprocess.check_call(["mkhomedir_helper", username]) except subprocess.CalledProcessError: - home = f'/home/{username}' + home = f"/home/{username}" if not os.path.isdir(home): - logger.warning(m18n.n('user_home_creation_failed', home=home), - exc_info=1) + logger.warning(m18n.n("user_home_creation_failed", home=home), exc_info=1) try: subprocess.check_call( @@ -282,12 +285,12 @@ def user_create( # TODO: Send a welcome mail to user if not from_import: - logger.success(m18n.n('user_created')) + logger.success(m18n.n("user_created")) return {"fullname": fullname, "username": username, "mail": mail} -@is_unit_operation([('username', 'user')]) +@is_unit_operation([("username", "user")]) def user_delete(operation_logger, username, purge=False, from_import=False): """ Delete user @@ -331,13 +334,14 @@ def user_delete(operation_logger, username, purge=False, from_import=False): subprocess.call(["nscd", "-i", "passwd"]) if purge: - subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) - subprocess.call(['rm', '-rf', '/var/mail/{0}'.format(username)]) + subprocess.call(["rm", "-rf", "/home/{0}".format(username)]) + subprocess.call(["rm", "-rf", "/var/mail/{0}".format(username)]) - hook_callback('post_user_delete', args=[username, purge]) + hook_callback("post_user_delete", args=[username, purge]) if not from_import: - logger.success(m18n.n('user_deleted')) + logger.success(m18n.n("user_deleted")) + @is_unit_operation([("username", "user")], exclude=["change_password"]) def user_update( @@ -352,7 +356,7 @@ def user_update( add_mailalias=None, remove_mailalias=None, mailbox_quota=None, - from_import=False + from_import=False, ): """ Update user informations @@ -412,7 +416,7 @@ def user_update( ] # change_password is None if user_update is not called to change the password - if change_password is not None and change_password != '': + if change_password is not None and change_password != "": # when in the cli interface if the option to change the password is called # without a specified value, change_password will be set to the const 0. # In this case we prompt for the new password. @@ -429,20 +433,22 @@ def user_update( aliases = [alias + main_domain for alias in FIRST_ALIASES] # If the requested mail address is already as main address or as an alias by this user - if mail in user['mail']: - user['mail'].remove(mail) + if mail in user["mail"]: + user["mail"].remove(mail) # Othewise, check that this mail address is not already used by this user else: try: - ldap.validate_uniqueness({'mail': mail}) + ldap.validate_uniqueness({"mail": mail}) except Exception as e: - raise YunohostError('user_update_failed', user=username, error=e) - if mail[mail.find('@') + 1:] not in domains: - raise YunohostError('mail_domain_unknown', domain=mail[mail.find('@') + 1:]) + raise YunohostError("user_update_failed", user=username, error=e) + if mail[mail.find("@") + 1 :] not in domains: + raise YunohostError( + "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] + ) if mail in aliases: raise YunohostValidationError("mail_unavailable") - new_attr_dict['mail'] = [mail] + user['mail'][1:] + new_attr_dict["mail"] = [mail] + user["mail"][1:] if add_mailalias: if not isinstance(add_mailalias, list): @@ -455,12 +461,10 @@ def user_update( try: ldap.validate_uniqueness({"mail": mail}) except Exception as e: - raise YunohostError( - "user_update_failed", user=username, error=e - ) - if mail[mail.find("@") + 1:] not in domains: + raise YunohostError("user_update_failed", user=username, error=e) + if mail[mail.find("@") + 1 :] not in domains: raise YunohostError( - "mail_domain_unknown", domain=mail[mail.find("@") + 1:] + "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] ) user["mail"].append(mail) new_attr_dict["mail"] = user["mail"] @@ -517,7 +521,7 @@ def user_update( if not from_import: app_ssowatconf() - logger.success(m18n.n('user_updated')) + logger.success(m18n.n("user_updated")) return user_info(username) @@ -548,13 +552,13 @@ def user_info(username): raise YunohostValidationError("user_unknown", user=username) result_dict = { - 'username': user['uid'][0], - 'fullname': user['cn'][0], - 'firstname': user['givenName'][0], - 'lastname': user['sn'][0], - 'mail': user['mail'][0], - 'mail-aliases': [], - 'mail-forward': [] + "username": user["uid"][0], + "fullname": user["cn"][0], + "firstname": user["givenName"][0], + "lastname": user["sn"][0], + "mail": user["mail"][0], + "mail-aliases": [], + "mail-forward": [], } if len(user["mail"]) > 1: @@ -619,27 +623,32 @@ def user_export(): """ import csv # CSV are needed only in this function from io import StringIO + with StringIO() as csv_io: - writer = csv.DictWriter(csv_io, list(FIELDS_FOR_IMPORT.keys()), - delimiter=';', quotechar='"') + writer = csv.DictWriter( + csv_io, list(FIELDS_FOR_IMPORT.keys()), delimiter=";", quotechar='"' + ) writer.writeheader() - users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] + users = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] for username, user in users.items(): - user['mail-alias'] = ','.join(user['mail-alias']) - user['mail-forward'] = ','.join(user['mail-forward']) - user['groups'] = ','.join(user['groups']) + user["mail-alias"] = ",".join(user["mail-alias"]) + user["mail-forward"] = ",".join(user["mail-forward"]) + user["groups"] = ",".join(user["groups"]) writer.writerow(user) body = csv_io.getvalue().rstrip() - if Moulinette.interface.type == 'api': + if Moulinette.interface.type == "api": # We return a raw bottle HTTPresponse (instead of serializable data like # list/dict, ...), which is gonna be picked and used directly by moulinette from bottle import HTTPResponse - response = HTTPResponse(body=body, - headers={ - "Content-Disposition": "attachment; filename=users.csv", - "Content-Type": "text/csv", - }) + + response = HTTPResponse( + body=body, + headers={ + "Content-Disposition": "attachment; filename=users.csv", + "Content-Type": "text/csv", + }, + ) return response else: return body @@ -662,106 +671,121 @@ def user_import(operation_logger, csvfile, update=False, delete=False): from yunohost.domain import domain_list # Pre-validate data and prepare what should be done - actions = { - 'created': [], - 'updated': [], - 'deleted': [] - } + actions = {"created": [], "updated": [], "deleted": []} is_well_formatted = True def to_list(str_list): - L = str_list.split(',') if str_list else [] + L = str_list.split(",") if str_list else [] L = [l.strip() for l in L] return L - existing_users = user_list()['users'] + existing_users = user_list()["users"] existing_groups = user_group_list()["groups"] existing_domains = domain_list()["domains"] - reader = csv.DictReader(csvfile, delimiter=';', quotechar='"') + reader = csv.DictReader(csvfile, delimiter=";", quotechar='"') users_in_csv = [] - missing_columns = [key for key in FIELDS_FOR_IMPORT.keys() if key not in reader.fieldnames] + missing_columns = [ + key for key in FIELDS_FOR_IMPORT.keys() if key not in reader.fieldnames + ] if missing_columns: - raise YunohostValidationError("user_import_missing_columns", columns=', '.join(missing_columns)) + raise YunohostValidationError( + "user_import_missing_columns", columns=", ".join(missing_columns) + ) for user in reader: # Validate column values against regexes - format_errors = [f"{key}: '{user[key]}' doesn't match the expected format" - for key, validator in FIELDS_FOR_IMPORT.items() - if user[key] is None or not re.match(validator, user[key])] + format_errors = [ + f"{key}: '{user[key]}' doesn't match the expected format" + for key, validator in FIELDS_FOR_IMPORT.items() + if user[key] is None or not re.match(validator, user[key]) + ] # Check for duplicated username lines - if user['username'] in users_in_csv: + if user["username"] in users_in_csv: format_errors.append(f"username '{user['username']}' duplicated") - users_in_csv.append(user['username']) + users_in_csv.append(user["username"]) # Validate that groups exist - user['groups'] = to_list(user['groups']) - unknown_groups = [g for g in user['groups'] if g not in existing_groups] + user["groups"] = to_list(user["groups"]) + unknown_groups = [g for g in user["groups"] if g not in existing_groups] if unknown_groups: - format_errors.append(f"username '{user['username']}': unknown groups %s" % ', '.join(unknown_groups)) + format_errors.append( + f"username '{user['username']}': unknown groups %s" + % ", ".join(unknown_groups) + ) # Validate that domains exist - user['mail-alias'] = to_list(user['mail-alias']) - user['mail-forward'] = to_list(user['mail-forward']) - user['domain'] = user['mail'].split('@')[1] + user["mail-alias"] = to_list(user["mail-alias"]) + user["mail-forward"] = to_list(user["mail-forward"]) + user["domain"] = user["mail"].split("@")[1] unknown_domains = [] - if user['domain'] not in existing_domains: - unknown_domains.append(user['domain']) + if user["domain"] not in existing_domains: + unknown_domains.append(user["domain"]) - unknown_domains += [mail.split('@', 1)[1] for mail in user['mail-alias'] if mail.split('@', 1)[1] not in existing_domains] + unknown_domains += [ + mail.split("@", 1)[1] + for mail in user["mail-alias"] + if mail.split("@", 1)[1] not in existing_domains + ] unknown_domains = set(unknown_domains) if unknown_domains: - format_errors.append(f"username '{user['username']}': unknown domains %s" % ', '.join(unknown_domains)) + format_errors.append( + f"username '{user['username']}': unknown domains %s" + % ", ".join(unknown_domains) + ) if format_errors: - logger.error(m18n.n('user_import_bad_line', - line=reader.line_num, - details=', '.join(format_errors))) + logger.error( + m18n.n( + "user_import_bad_line", + line=reader.line_num, + details=", ".join(format_errors), + ) + ) is_well_formatted = False continue # Choose what to do with this line and prepare data - user['mailbox-quota'] = user['mailbox-quota'] or "0" + user["mailbox-quota"] = user["mailbox-quota"] or "0" # User creation - if user['username'] not in existing_users: + if user["username"] not in existing_users: # Generate password if not exists # This could be used when reset password will be merged - if not user['password']: - user['password'] = random_ascii(70) - actions['created'].append(user) + if not user["password"]: + user["password"] = random_ascii(70) + actions["created"].append(user) # User update elif update: - actions['updated'].append(user) + actions["updated"].append(user) if delete: - actions['deleted'] = [user for user in existing_users if user not in users_in_csv] + actions["deleted"] = [ + user for user in existing_users if user not in users_in_csv + ] if delete and not users_in_csv: - logger.error("You used the delete option with an empty csv file ... You probably did not really mean to do that, did you !?") + logger.error( + "You used the delete option with an empty csv file ... You probably did not really mean to do that, did you !?" + ) is_well_formatted = False if not is_well_formatted: - raise YunohostValidationError('user_import_bad_file') + raise YunohostValidationError("user_import_bad_file") - total = len(actions['created'] + actions['updated'] + actions['deleted']) + total = len(actions["created"] + actions["updated"] + actions["deleted"]) if total == 0: - logger.info(m18n.n('user_import_nothing_to_do')) + logger.info(m18n.n("user_import_nothing_to_do")) return # Apply creation, update and deletion operation - result = { - 'created': 0, - 'updated': 0, - 'deleted': 0, - 'errors': 0 - } + result = {"created": 0, "updated": 0, "deleted": 0, "errors": 0} def progress(info=""): progress.nb += 1 @@ -774,12 +798,13 @@ def user_import(operation_logger, csvfile, update=False, delete=False): return progress.old = bar logger.info(bar) + progress.nb = 0 progress.old = "" def on_failure(user, exception): - result['errors'] += 1 - logger.error(user + ': ' + str(exception)) + result["errors"] += 1 + logger.error(user + ": " + str(exception)) def update(new_infos, old_infos=False): remove_alias = None @@ -787,11 +812,21 @@ def user_import(operation_logger, csvfile, update=False, delete=False): remove_groups = [] add_groups = new_infos["groups"] if old_infos: - new_infos['mail'] = None if old_infos['mail'] == new_infos['mail'] else new_infos['mail'] - remove_alias = list(set(old_infos['mail-alias']) - set(new_infos['mail-alias'])) - remove_forward = list(set(old_infos['mail-forward']) - set(new_infos['mail-forward'])) - new_infos['mail-alias'] = list(set(new_infos['mail-alias']) - set(old_infos['mail-alias'])) - new_infos['mail-forward'] = list(set(new_infos['mail-forward']) - set(old_infos['mail-forward'])) + new_infos["mail"] = ( + None if old_infos["mail"] == new_infos["mail"] else new_infos["mail"] + ) + remove_alias = list( + set(old_infos["mail-alias"]) - set(new_infos["mail-alias"]) + ) + remove_forward = list( + set(old_infos["mail-forward"]) - set(new_infos["mail-forward"]) + ) + new_infos["mail-alias"] = list( + set(new_infos["mail-alias"]) - set(old_infos["mail-alias"]) + ) + new_infos["mail-forward"] = list( + set(new_infos["mail-forward"]) - set(old_infos["mail-forward"]) + ) remove_groups = list(set(old_infos["groups"]) - set(new_infos["groups"])) add_groups = list(set(new_infos["groups"]) - set(old_infos["groups"])) @@ -799,69 +834,90 @@ def user_import(operation_logger, csvfile, update=False, delete=False): for group, infos in existing_groups.items(): # Loop only on groups in 'remove_groups' # Ignore 'all_users' and primary group - if group in ["all_users", new_infos['username']] or group not in remove_groups: + if ( + group in ["all_users", new_infos["username"]] + or group not in remove_groups + ): continue # If the user is in this group (and it's not the primary group), # remove the member from the group - if new_infos['username'] in infos["members"]: - user_group_update(group, remove=new_infos['username'], sync_perm=False, from_import=True) + if new_infos["username"] in infos["members"]: + user_group_update( + group, + remove=new_infos["username"], + sync_perm=False, + from_import=True, + ) - user_update(new_infos['username'], - new_infos['firstname'], new_infos['lastname'], - new_infos['mail'], new_infos['password'], - mailbox_quota=new_infos['mailbox-quota'], - mail=new_infos['mail'], add_mailalias=new_infos['mail-alias'], - remove_mailalias=remove_alias, - remove_mailforward=remove_forward, - add_mailforward=new_infos['mail-forward'], from_import=True) + user_update( + new_infos["username"], + new_infos["firstname"], + new_infos["lastname"], + new_infos["mail"], + new_infos["password"], + mailbox_quota=new_infos["mailbox-quota"], + mail=new_infos["mail"], + add_mailalias=new_infos["mail-alias"], + remove_mailalias=remove_alias, + remove_mailforward=remove_forward, + add_mailforward=new_infos["mail-forward"], + from_import=True, + ) for group in add_groups: - if group in ["all_users", new_infos['username']]: + if group in ["all_users", new_infos["username"]]: continue - user_group_update(group, add=new_infos['username'], sync_perm=False, from_import=True) + user_group_update( + group, add=new_infos["username"], sync_perm=False, from_import=True + ) - users = user_list(list(FIELDS_FOR_IMPORT.keys()))['users'] + users = user_list(list(FIELDS_FOR_IMPORT.keys()))["users"] operation_logger.start() # We do delete and update before to avoid mail uniqueness issues - for user in actions['deleted']: + for user in actions["deleted"]: try: user_delete(user, purge=True, from_import=True) - result['deleted'] += 1 + result["deleted"] += 1 except YunohostError as e: on_failure(user, e) progress(f"Deleting {user}") - for user in actions['updated']: + for user in actions["updated"]: try: - update(user, users[user['username']]) - result['updated'] += 1 + update(user, users[user["username"]]) + result["updated"] += 1 except YunohostError as e: - on_failure(user['username'], e) + on_failure(user["username"], e) progress(f"Updating {user['username']}") - for user in actions['created']: + for user in actions["created"]: try: - user_create(user['username'], - user['firstname'], user['lastname'], - user['domain'], user['password'], - user['mailbox-quota'], from_import=True) + user_create( + user["username"], + user["firstname"], + user["lastname"], + user["domain"], + user["password"], + user["mailbox-quota"], + from_import=True, + ) update(user) - result['created'] += 1 + result["created"] += 1 except YunohostError as e: - on_failure(user['username'], e) + on_failure(user["username"], e) progress(f"Creating {user['username']}") permission_sync_to_user() app_ssowatconf() - if result['errors']: - msg = m18n.n('user_import_partial_failed') - if result['created'] + result['updated'] + result['deleted'] == 0: - msg = m18n.n('user_import_failed') + if result["errors"]: + msg = m18n.n("user_import_partial_failed") + if result["created"] + result["updated"] + result["deleted"] == 0: + msg = m18n.n("user_import_failed") logger.error(msg) operation_logger.error(msg) else: - logger.success(m18n.n('user_import_success')) + logger.success(m18n.n("user_import_success")) operation_logger.success() return result @@ -1038,7 +1094,7 @@ def user_group_delete(operation_logger, groupname, force=False, sync_perm=True): logger.debug(m18n.n("group_deleted", group=groupname)) -@is_unit_operation([('groupname', 'group')]) +@is_unit_operation([("groupname", "group")]) def user_group_update( operation_logger, groupname, @@ -1046,7 +1102,7 @@ def user_group_update( remove=None, force=False, sync_perm=True, - from_import=False + from_import=False, ): """ Update user informations From 10fddc427ed9dc8dad1b2bc7c1250e2383fb30bb Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 3 Sep 2021 02:01:16 +0200 Subject: [PATCH 44/48] ci: Add magic script to automagically fix i18n string format in trivial case --- .gitlab/ci/translation.gitlab-ci.yml | 11 ++++--- tests/autofix_locale_format.py | 47 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 tests/autofix_locale_format.py diff --git a/.gitlab/ci/translation.gitlab-ci.yml b/.gitlab/ci/translation.gitlab-ci.yml index d7962436c..5e50cd20e 100644 --- a/.gitlab/ci/translation.gitlab-ci.yml +++ b/.gitlab/ci/translation.gitlab-ci.yml @@ -2,7 +2,7 @@ # TRANSLATION ######################################## -remove-stale-translated-strings: +autofix-translated-strings: stage: translation image: "before-install" needs: [] @@ -14,12 +14,13 @@ remove-stale-translated-strings: script: - cd tests # Maybe move this script location to another folder? # create a local branch that will overwrite distant one - - git checkout -b "ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" --no-track + - git checkout -b "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}" --no-track - python3 remove_stale_translated_strings.py + - python3 autofix_locale_format.py - '[ $(git diff -w | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit - - git commit -am "[CI] Remove stale translated strings" || true - - git push -f origin "ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" - - hub pull-request -m "[CI] Remove stale translated strings" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + - git commit -am "[CI] Autofix translated strings" || true + - git push -f origin "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}":"ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Autofix translated strings" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd only: variables: - $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH diff --git a/tests/autofix_locale_format.py b/tests/autofix_locale_format.py new file mode 100644 index 000000000..f777f06f1 --- /dev/null +++ b/tests/autofix_locale_format.py @@ -0,0 +1,47 @@ +import re +import json +import glob + +# List all locale files (except en.json being the ref) +locale_folder = "locales/" +locale_files = glob.glob(locale_folder + "*.json") +locale_files = [filename.split("/")[-1] for filename in locale_files] +locale_files.remove("en.json") + +reference = json.loads(open(locale_folder + "en.json").read()) + + +def fix_locale(locale_file): + + this_locale = json.loads(open(locale_folder + locale_file).read()) + fixed_stuff = False + + # We iterate over all keys/string in en.json + for key, string in reference.items(): + + # Ignore check if there's no translation yet for this key + if key not in this_locale: + continue + + # Then we check that every "{stuff}" (for python's .format()) + # should also be in the translated string, otherwise the .format + # will trigger an exception! + subkeys_in_ref = [k[0] for k in re.findall(r"{(\w+)(:\w)?}", string)] + subkeys_in_this_locale = [k[0] for k in re.findall(r"{(\w+)(:\w)?}", this_locale[key])] + + if set(subkeys_in_ref) != set(subkeys_in_this_locale) and (len(subkeys_in_ref) == len(subkeys_in_this_locale)): + for i, subkey in enumerate(subkeys_in_ref): + this_locale[key] = this_locale[key].replace('{%s}' % subkeys_in_this_locale[i], '{%s}' % subkey) + fixed_stuff = True + + if fixed_stuff: + json.dump( + this_locale, + open(locale_folder + locale_file, "w"), + indent=4, + ensure_ascii=False, + ) + + +for locale_file in locale_files: + fix_locale(locale_file) From fd0c283c536aa3bf7d0dfa91f7e4b34639c0a077 Mon Sep 17 00:00:00 2001 From: Weblate Admin Date: Thu, 2 Sep 2021 11:04:30 +0000 Subject: [PATCH 45/48] Translated using Weblate (French) Currently translated at 100.0% (651 of 651 strings) Translation: YunoHost/core Translate-URL: https://translate.yunohost.org/projects/yunohost/core/fr/ --- locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/fr.json b/locales/fr.json index e3a32d639..a7fe39bd9 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -291,7 +291,7 @@ "password_too_simple_3": "Le mot de passe doit comporter au moins 8 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", "password_too_simple_4": "Le mot de passe doit comporter au moins 12 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", "root_password_desynchronized": "Le mot de passe administrateur a été changé, mais YunoHost n’a pas pu le propager au mot de passe root !", - "aborting": "Annulation en cours.", + "aborting": "Annulation en cours (do not merge).", "app_not_upgraded": "L’application {failed_app} n’a pas été mise à jour et par conséquence les applications suivantes n’ont pas été mises à jour : {apps}", "app_start_install": "Installation de {app}...", "app_start_remove": "Suppression de {app}...", From ab388dc167c1c5002890f6f5424963e98a2d51b8 Mon Sep 17 00:00:00 2001 From: Weblate Admin Date: Thu, 2 Sep 2021 11:05:08 +0000 Subject: [PATCH 46/48] Translated using Weblate (French) Currently translated at 100.0% (651 of 651 strings) Translation: YunoHost/core Translate-URL: https://translate.yunohost.org/projects/yunohost/core/fr/ --- locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/fr.json b/locales/fr.json index a7fe39bd9..e3a32d639 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -291,7 +291,7 @@ "password_too_simple_3": "Le mot de passe doit comporter au moins 8 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", "password_too_simple_4": "Le mot de passe doit comporter au moins 12 caractères et contenir des chiffres, des majuscules, des minuscules et des caractères spéciaux", "root_password_desynchronized": "Le mot de passe administrateur a été changé, mais YunoHost n’a pas pu le propager au mot de passe root !", - "aborting": "Annulation en cours (do not merge).", + "aborting": "Annulation en cours.", "app_not_upgraded": "L’application {failed_app} n’a pas été mise à jour et par conséquence les applications suivantes n’ont pas été mises à jour : {apps}", "app_start_install": "Installation de {app}...", "app_start_remove": "Suppression de {app}...", From 6e1018fd4e6342f18cccce2a18d47ccfd5c812f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Gaspar?= Date: Thu, 2 Sep 2021 14:14:24 +0000 Subject: [PATCH 47/48] Translated using Weblate (French) Currently translated at 100.0% (659 of 659 strings) Translation: YunoHost/core Translate-URL: https://translate.yunohost.org/projects/yunohost/core/fr/ --- locales/fr.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/locales/fr.json b/locales/fr.json index e3a32d639..a20a62db8 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -142,7 +142,7 @@ "user_creation_failed": "Impossible de créer l’utilisateur {user} : {error}", "user_deleted": "L’utilisateur a été supprimé", "user_deletion_failed": "Impossible de supprimer l’utilisateur {user} : {error}", - "user_home_creation_failed": "Impossible de créer le dossier personnel de l’utilisateur", + "user_home_creation_failed": "Impossible de créer le dossier personnel '{home}' de l’utilisateur", "user_unknown": "L’utilisateur {user} est inconnu", "user_update_failed": "Impossible de mettre à jour l’utilisateur {user} : {error}", "user_updated": "L’utilisateur a été modifié", @@ -170,7 +170,7 @@ "mailbox_used_space_dovecot_down": "Le service de courriel Dovecot doit être démarré si vous souhaitez voir l’espace disque occupé par la messagerie", "domains_available": "Domaines disponibles :", "backup_archive_broken_link": "Impossible d’accéder à l’archive de sauvegarde (lien invalide vers {path})", - "certmanager_acme_not_configured_for_domain": "Le challenge ACME n'a pas pu être validé pour le domaine {domain} pour le moment car le code de la configuration NGINX est manquant... Merci de vérifier que votre configuration NGINX est à jour avec la commande: `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "certmanager_acme_not_configured_for_domain": "Le challenge ACME n'a pas pu être validé pour le domaine {domain} pour le moment car le code de la configuration NGINX est manquant... Merci de vérifier que votre configuration NGINX est à jour avec la commande : `yunohost tools regen-conf nginx --dry-run --with-diff`.", "domain_hostname_failed": "Échec de l’utilisation d’un nouveau nom d’hôte. Cela pourrait causer des soucis plus tard (cela n’en causera peut-être pas).", "app_already_installed_cant_change_url": "Cette application est déjà installée. L’URL ne peut pas être changé simplement par cette fonction. Vérifiez si cela est disponible avec `app changeurl`.", "app_change_url_failed_nginx_reload": "Le redémarrage de NGINX a échoué. Voici la sortie de 'nginx -t' :\n{nginx_errors}", @@ -649,5 +649,13 @@ "diagnosis_apps_not_in_app_catalog": "Cette application est absente ou ne figure plus dans le catalogue d'applications de YunoHost. Vous devriez envisager de la désinstaller car elle ne recevra pas de mise à jour et pourrait compromettre l'intégrité et la sécurité de votre système.", "diagnosis_apps_issue": "Un problème a été détecté pour l'application {app}", "diagnosis_apps_allgood": "Toutes les applications installées respectent les pratiques de packaging de base", - "diagnosis_description_apps": "Applications" + "diagnosis_description_apps": "Applications", + "user_import_success": "Utilisateurs importés avec succès", + "user_import_nothing_to_do": "Aucun utilisateur n'a besoin d'être importé", + "user_import_failed": "L'opération d'importation des utilisateurs a totalement échoué", + "user_import_partial_failed": "L'opération d'importation des utilisateurs a partiellement échoué", + "user_import_missing_columns": "Les colonnes suivantes sont manquantes : {columns}", + "user_import_bad_file": "Votre fichier CSV n'est pas correctement formaté, il sera ignoré afin d'éviter une potentielle perte de données", + "user_import_bad_line": "Ligne incorrecte {line} : {details}", + "log_user_import": "Importer des utilisateurs" } From bf7b51de18b6af3980ccd37a3d54237d518532c5 Mon Sep 17 00:00:00 2001 From: Tymofii-Lytvynenko Date: Thu, 2 Sep 2021 21:52:46 +0000 Subject: [PATCH 48/48] Translated using Weblate (Ukrainian) Currently translated at 30.3% (200 of 659 strings) Translation: YunoHost/core Translate-URL: https://translate.yunohost.org/projects/yunohost/core/uk/ --- locales/uk.json | 222 ++++++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/locales/uk.json b/locales/uk.json index a9b807981..47c5991b0 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -55,7 +55,7 @@ "service_description_postfix": "Використовується для відправки та отримання електронної пошти", "service_description_php7.3-fpm": "Запускає програми, написані на мові програмування PHP, за допомогою NGINX", "service_description_nginx": "Обслуговує або надає доступ до всіх веб-сайтів, розміщених на вашому сервері", - "service_description_mysql": "Зберігає дані додатків (база даних SQL)", + "service_description_mysql": "Зберігає дані застосунків (база даних SQL)", "service_description_metronome": "Служба захисту миттєвого обміну повідомленнями XMPP", "service_description_fail2ban": "Захист від перебору та інших видів атак з Інтернету", "service_description_dovecot": "Дозволяє поштовим клієнтам отримувати доступ до електронної пошти (через IMAP і POP3)", @@ -87,7 +87,7 @@ "restore_cleaning_failed": "Не вдалося очистити тимчасовий каталог відновлення", "restore_backup_too_old": "Цей архів резервних копій не може бути відновлений, бо він отриманий з дуже старою версією YunoHost.", "restore_already_installed_apps": "Наступні програми не можуть бути відновлені, тому що вони вже встановлені: {apps}", - "restore_already_installed_app": "Додаток з ідентифікатором \"{app} 'вже встановлено", + "restore_already_installed_app": "Застосунок з ідентифікатором \"{app} 'вже встановлено", "regex_with_only_domain": "Ви не можете використовувати regex для домену, тільки для шляху.", "regex_incompatible_with_tile": "/! \\ Packagers! Дозвіл '{permission}' має значення show_tile 'true', тому ви не можете визначити regex URL в якості основного URL.", "regenconf_need_to_explicitly_specify_ssh": "Конфігурація ssh була змінена вручну, але вам потрібно явно вказати категорію 'ssh' з --force, щоб застосувати зміни.", @@ -117,7 +117,7 @@ "permission_deletion_failed": "Не вдалося видалити дозвіл '{permission}': {error}", "permission_deleted": "Дозвіл '{permission}' видалено", "permission_cant_add_to_all_users": "Дозвіл {permission} не може бути додано всім користувачам.", - "permission_currently_allowed_for_all_users": "В даний час цей дозвіл надається всім користувачам на додаток до інших груп. Ймовірно, вам потрібно або видалити дозвіл 'all_users', або видалити інші групи, яким воно зараз надано.", + "permission_currently_allowed_for_all_users": "Наразі цей дозвіл надається всім користувачам на додачу до інших груп. Імовірно, вам потрібно або видалити дозвіл 'all_users', або видалити інші групи, яким його зараз надано.", "permission_creation_failed": "Не вдалося створити дозвіл '{permission}': {error}", "permission_created": "Дозвіл '{permission}' створено", "permission_cannot_remove_main": "Видалення основного дозволу заборонено", @@ -175,8 +175,8 @@ "migration_0015_cleaning_up": "Очищення кеш-пам'яті і пакетів, які більше не потрібні...", "migration_0015_specific_upgrade": "Початок поновлення системних пакетів, які повинні бути оновлені незалежно...", "migration_0015_modified_files": "Зверніть увагу, що такі файли були змінені вручну і можуть бути перезаписані після поновлення: {manually_modified_files}.", - "migration_0015_problematic_apps_warning": "Зверніть увагу, що були виявлені наступні, можливо, проблемні встановлені додатки. Схоже, що вони не були встановлені з каталогу додатків YunoHost або не зазначені як \"робочі\". Отже, не можна гарантувати, що вони будуть працювати після оновлення: {problematic_apps}.", - "migration_0015_general_warning": "Будь ласка, зверніть увагу, що ця міграція є делікатною операцією. Команда YunoHost зробила все можливе, щоб перевірити і протестувати її, але міграція все ще може порушити частина системи або її додатків. Тому рекомендується: - Виконати резервне копіювання всіх важливих даних або додатків. Більш детальна інформація на сайті https://yunohost.org/backup; - Наберіться терпіння після запуску міграції: В залежності від вашого підключення до Інтернету і апаратного забезпечення, оновлення може зайняти до декількох годин.", + "migration_0015_problematic_apps_warning": "Зверніть увагу, що були виявлені наступні, можливо, проблемні встановлені додатки. Схоже, що вони не були встановлені з каталогу застосунків YunoHost або не зазначені як \"робочі\". Отже, не можна гарантувати, що вони будуть працювати після оновлення: {problematic_apps}.", + "migration_0015_general_warning": "Будь ласка, зверніть увагу, що ця міграція є делікатною операцією. Команда YunoHost зробила все можливе, щоб перевірити і протестувати її, але міграція все ще може порушити частина системи або її застосунків. Тому рекомендується: - Виконати резервне копіювання всіх важливих даних або застосунків. Більш детальна інформація на сайті https://yunohost.org/backup; - Наберіться терпіння після запуску міграції: В залежності від вашого підключення до Інтернету і апаратного забезпечення, оновлення може зайняти до декількох годин.", "migration_0015_system_not_fully_up_to_date": "Ваша система не повністю оновлена. Будь ласка, виконайте регулярне оновлення перед запуском міграції на Buster.", "migration_0015_not_enough_free_space": "Вільного місця в/var/досить мало! У вас повинно бути не менше 1 ГБ вільного місця, щоб запустити цю міграцію.", "migration_0015_not_stretch": "Поточний дистрибутив Debian не є Stretch!", @@ -189,9 +189,9 @@ "migration_ldap_rollback_success": "Система відкотилася.", "migration_ldap_migration_failed_trying_to_rollback": "Не вдалося виконати міграцію... спроба відкату системи.", "migration_ldap_can_not_backup_before_migration": "Не вдалося завершити резервне копіювання системи перед невдалої міграцією. Помилка: {error}", - "migration_ldap_backup_before_migration": "Створення резервної копії бази даних LDAP і установки додатків перед фактичної міграцією.", + "migration_ldap_backup_before_migration": "Створення резервної копії бази даних LDAP і установки застосунків перед фактичної міграцією.", "migration_description_0020_ssh_sftp_permissions": "Додайте підтримку дозволів SSH і SFTP", - "migration_description_0019_extend_permissions_features": "Розширення/переробка системи управління дозволами додатків", + "migration_description_0019_extend_permissions_features": "Розширення/переробка системи управління дозволами застосунків", "migration_description_0018_xtable_to_nftable": "Перенесення старих правил мережевого трафіку в нову систему nftable", "migration_description_0017_postgresql_9p6_to_11": "Перенесення баз даних з PostgreSQL 9.6 на 11", "migration_description_0016_php70_to_php73_pools": "Перенесіть php7.0-fpm 'pool' conf файли на php7.3", @@ -240,9 +240,9 @@ "log_app_config_show_panel": "Показати панель конфігурації програми \"{} '", "log_app_action_run": "Активації дії додатка \"{} '", "log_app_makedefault": "Зробити '{}' додатком за замовчуванням", - "log_app_upgrade": "Оновити додаток '{}'", + "log_app_upgrade": "Оновити застосунок '{}'", "log_app_remove": "Для видалення програми '{}'", - "log_app_install": "Встановіть додаток '{}'", + "log_app_install": "Встановіть застосунок '{}'", "log_app_change_url": "Змініть URL-адресу додатка \"{} '", "log_operation_unit_unclosed_properly": "Блок операцій не був закритий належним чином", "log_does_exists": "Немає журналу операцій з ім'ям '{log}', використовуйте 'yunohost log list', щоб подивитися всі публічні журнали операцій", @@ -331,7 +331,7 @@ "domain_unknown": "невідомий домен", "domain_name_unknown": "Домен '{domain}' невідомий", "domain_uninstall_app_first": "Ці додатки все ще встановлені на вашому домені: {apps} ласка, видаліть їх за допомогою 'yunohost app remove the_app_id' або перемістити їх на інший домен за допомогою 'yunohost app change-url the_app_id', перш ніж приступити до видалення домену.", - "domain_remove_confirm_apps_removal": "Видалення цього домену призведе до видалення цих додатків: {apps} Ви впевнені, що хочете це зробити? [{answers}].", + "domain_remove_confirm_apps_removal": "Видалення цього домену призведе до видалення цих застосунків: {apps} Ви впевнені, що хочете це зробити? [{answers}].", "domain_hostname_failed": "Неможливо встановити нове ім'я хоста. Це може викликати проблеми в подальшому (можливо, все буде в порядку).", "domain_exists": "Домен вже існує", "domain_dyndns_root_unknown": "Невідомий кореневої домен DynDNS", @@ -485,157 +485,157 @@ "diagnosis_domain_expiration_warning": "Термін дії деяких доменів закінчиться найближчим часом!", "diagnosis_domain_expiration_success": "Ваші домени зареєстровані і не збираються спливати найближчим часом.", "diagnosis_domain_expiration_not_found_details": "Інформація WHOIS для домену {domain} не містить інформації про термін дії?", - "diagnosis_domain_not_found_details": "Домен {domain} не існує в базі даних WHOIS або термін його дії закінчився!", - "diagnosis_domain_expiration_not_found": "Неможливо перевірити термін дії деяких доменів", + "diagnosis_domain_not_found_details": "Домен {domain} не існує в базі даних WHOIS або строк його дії сплив!", + "diagnosis_domain_expiration_not_found": "Неможливо перевірити строк дії деяких доменів", "diagnosis_dns_specialusedomain": "Домен {domain} заснований на домені верхнього рівня спеціального призначення (TLD) і тому не очікується, що у нього будуть актуальні записи DNS.", - "diagnosis_dns_try_dyndns_update_force": "Конфігурація DNS цього домену повинна автоматично управлятися YunoHost. Якщо це не так, ви можете спробувати примусово оновити її за допомогою команди yunohost dyndns update --force .", - "diagnosis_dns_point_to_doc": "Якщо вам потрібна допомога з налаштування DNS-записів, зверніться до документації на сайті https://yunohost.org/dns_config .", - "diagnosis_dns_discrepancy": "Наступний запис DNS, схоже, не відповідає рекомендованої конфігурації:
Type: {type}
Name: {name}
Поточне значення: {current}
Очікуване значення: {value} ", - "diagnosis_dns_missing_record": "Згідно рекомендованої конфігурації DNS, ви повинні додати запис DNS з наступною інформацією.
Тип: {type}
Name: {name}
Value: < code> {value} .", - "diagnosis_dns_bad_conf": "Деякі DNS-записи відсутні або невірні для домену {domain} (категорія {category})", + "diagnosis_dns_try_dyndns_update_force": "Конфігурація DNS цього домену повинна автоматично управлятися YunoHost. Якщо це не так, ви можете спробувати примусово оновити її за допомогою команди yunohost dyndns update --force.", + "diagnosis_dns_point_to_doc": "Якщо вам потрібна допомога з налаштування DNS-записів, зверніться до документації на сайті https://yunohost.org/dns_config.", + "diagnosis_dns_discrepancy": "Наступний запис DNS, схоже, не відповідає рекомендованій конфігурації:
Тип: {type}
Назва: {name}
Поточне значення: {current}
Очікуване значення: {value}", + "diagnosis_dns_missing_record": "Згідно рекомендованої конфігурації DNS, ви повинні додати запис DNS з наступними відомостями.\n
Тип: {type}\n
Назва: {name}\n
Значення: {value}", + "diagnosis_dns_bad_conf": "Деякі DNS-записи відсутні або неправильні для домену {domain} (категорія {category})", "diagnosis_dns_good_conf": "DNS-записи правильно налаштовані для домену {domain} (категорія {category})", - "diagnosis_ip_weird_resolvconf_details": "Файл /etc/resolv.conf повинен бути симлінк на /etc/resolvconf/run/resolv.conf , що вказує на 127.0.0.1 (dnsmasq ). Якщо ви хочете вручну налаштувати DNS Резолвер, відредагуйте /etc/resolv.dnsmasq.conf .", - "diagnosis_ip_weird_resolvconf": "Дозвіл DNS, схоже, працює, але схоже, що ви використовуєте для користувача /etc/resolv.conf .", - "diagnosis_ip_broken_resolvconf": "Схоже, що дозвіл доменних імен на вашому сервері порушено, що пов'язано з тим, що /etc/resolv.conf не вказує на 127.0.0.1 .", - "diagnosis_ip_broken_dnsresolution": "Дозвіл доменних імен, схоже, з якоїсь причини не працює... Брандмауер блокує DNS-запити?", - "diagnosis_ip_dnsresolution_working": "Дозвіл доменних імен працює!", - "diagnosis_ip_not_connected_at_all": "Здається, що сервер взагалі не підключений до Інтернету !?", - "diagnosis_ip_local": "Локальний IP: {local} .", - "diagnosis_ip_global": "Глобальний IP: {global} ", - "diagnosis_ip_no_ipv6_tip": "Наявність працюючого IPv6 не є обов'язковим для роботи вашого сервера, але це краще для здоров'я Інтернету в цілому. IPv6 зазвичай автоматично налаштовується системою або вашим провайдером, якщо він доступний. В іншому випадку вам, можливо, доведеться налаштувати деякі речі вручну, як пояснюється в документації тут: https://yunohost.org/#/ipv6. Якщо ви не можете включити IPv6 або якщо це здається вам занадто технічним, ви також можете сміливо ігнорувати це попередження.", - "diagnosis_ip_no_ipv6": "Сервер не має працюючого IPv6.", - "diagnosis_ip_connected_ipv6": "Сервер підключений до Інтернету через IPv6!", - "diagnosis_ip_no_ipv4": "Сервер не має працюючого IPv4.", - "diagnosis_ip_connected_ipv4": "Сервер підключений до Інтернету через IPv4!", - "diagnosis_no_cache": "Для категорії \"{category} 'ще немає кеша діагнозів.", - "diagnosis_failed": "Не вдалося результат діагностики для категорії '{category}': {error}", - "diagnosis_everything_ok": "Все виглядає добре для {category}!", + "diagnosis_ip_weird_resolvconf_details": "Файл /etc/resolv.conf повинен бути символічним посиланням на /etc/resolvconf/run/resolv.conf, що вказує на 127.0.0.1(dnsmasq). Якщо ви хочете вручну налаштувати DNS вирішувачі (resolvers), відредагуйте /etc/resolv.dnsmasq.conf.", + "diagnosis_ip_weird_resolvconf": "Роздільність DNS, схоже, працює, але схоже, що ви використовуєте користувацьку /etc/resolv.conf.", + "diagnosis_ip_broken_resolvconf": "Схоже, що роздільність доменних імен на вашому сервері порушено, що пов'язано з тим, що /etc/resolv.conf не вказує на 127.0.0.1.", + "diagnosis_ip_broken_dnsresolution": "Роздільність доменних імен, схоже, з якоїсь причини не працює... Фаєрвол блокує DNS-запити?", + "diagnosis_ip_dnsresolution_working": "Роздільність доменних імен працює!", + "diagnosis_ip_not_connected_at_all": "Здається, сервер взагалі не під'єднаний до Інтернету!?", + "diagnosis_ip_local": "Локальний IP: {local}.", + "diagnosis_ip_global": "Глобальний IP: {global}", + "diagnosis_ip_no_ipv6_tip": "Наявність робочого IPv6 не є обов'язковим для роботи вашого сервера, але це краще для здоров'я Інтернету в цілому. IPv6 зазвичай автоматично налаштовується системою або вашим провайдером, якщо він доступний. В іншому випадку вам, можливо, доведеться налаштувати деякі речі вручну, як пояснюється в документації тут: https://yunohost.org/#/ipv6. Якщо ви не можете увімкнути IPv6 або якщо це здається вам занадто технічним, ви також можете сміливо нехтувати цим попередженням.", + "diagnosis_ip_no_ipv6": "Сервер не має робочого IPv6.", + "diagnosis_ip_connected_ipv6": "Сервер під'єднаний до Інтернету через IPv6!", + "diagnosis_ip_no_ipv4": "Сервер не має робочого IPv4.", + "diagnosis_ip_connected_ipv4": "Сервер під'єднаний до Інтернету через IPv4!", + "diagnosis_no_cache": "Для категорії \"{category} 'ще немає кеша діагностики.", + "diagnosis_failed": "Не вдалося отримати результат діагностики для категорії '{category}': {error}", + "diagnosis_everything_ok": "Усе виглядає добре для {category}!", "diagnosis_found_warnings": "Знайдено {warnings} пунктів, які можна поліпшити для {category}.", "diagnosis_found_errors_and_warnings": "Знайдено {errors} істотний (і) питання (и) (і {warnings} попередження (я)), що відносяться до {category}!", "diagnosis_found_errors": "Знайдена {errors} важлива проблема (і), пов'язана з {category}!", - "diagnosis_ignored_issues": "(+ {nb_ignored} проігнорована проблема (проблеми))", + "diagnosis_ignored_issues": "(+ {nb_ignored} знехтувана проблема (проблеми))", "diagnosis_cant_run_because_of_dep": "Неможливо запустити діагностику для {category}, поки є важливі проблеми, пов'язані з {dep}.", "diagnosis_cache_still_valid": "(Кеш все ще дійсний для діагностики {category}. Повторна діагностика поки не проводиться!)", "diagnosis_failed_for_category": "Не вдалося провести діагностику для категорії '{category}': {error}", - "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Diagnosis в веб-адміном або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", - "diagnosis_package_installed_from_sury_details": "Деякі пакети були ненавмисно встановлені з стороннього сховища під назвою Sury. Команда YunoHost поліпшила стратегію роботи з цими пакетами, але очікується, що в деяких системах, які встановили додатки PHP7.3 ще на Stretch, залишаться деякі невідповідності. Щоб виправити цю ситуацію, спробуйте виконати наступну команду: {cmd_to_fix} .", - "diagnosis_package_installed_from_sury": "Деякі системні пакети повинні бути знижені в статусі", - "diagnosis_backports_in_sources_list": "Схоже, що apt (менеджер пакетів) налаштований на використання сховища backports. Якщо ви не знаєте, що робите, ми настійно не рекомендуємо встановлювати пакети з backports, тому що це може привести до нестабільності або конфліктів у вашій системі.", - "diagnosis_basesystem_ynh_inconsistent_versions": "Ви використовуєте несумісні версії пакетів YunoHost... швидше за все, через невдалий або часткового оновлення.", + "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Діагностика в вебадміністраторі або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", + "diagnosis_package_installed_from_sury_details": "Деякі пакети були ненавмисно встановлені зі стороннього репозиторію під назвою Sury. Команда YunoHost поліпшила стратегію роботи з цими пакетами, але очікується, що в деяких системах, які встановили застосунки PHP7.3 ще на Stretch, залишаться деякі невідповідності. Щоб виправити це становище, спробуйте виконати наступну команду: {cmd_to_fix}.", + "diagnosis_package_installed_from_sury": "Деякі системні пакети мають бути зістарені у версії", + "diagnosis_backports_in_sources_list": "Схоже, що apt (менеджер пакетів) налаштований на використання репозиторія backports. Якщо ви не знаєте, що робите, ми наполегливо не радимо встановлювати пакети з backports, тому що це може привести до нестабільності або конфліктів у вашій системі.", + "diagnosis_basesystem_ynh_inconsistent_versions": "Ви використовуєте несумісні версії пакетів YunoHost... швидше за все, через невдале або часткове оновлення.", "diagnosis_basesystem_ynh_main_version": "Сервер працює під управлінням YunoHost {main_version} ({repo})", "diagnosis_basesystem_ynh_single_version": "{package} версія: {version} ({repo})", "diagnosis_basesystem_kernel": "Сервер працює під управлінням ядра Linux {kernel_version}", "diagnosis_basesystem_host": "Сервер працює під управлінням Debian {debian_version}", "diagnosis_basesystem_hardware_model": "Модель сервера - {model}", "diagnosis_basesystem_hardware": "Архітектура апаратного забезпечення сервера - {virt} {arch}", - "custom_app_url_required": "Ви повинні надати URL для оновлення вашого призначеного для користувача додатки {app}.", - "confirm_app_install_thirdparty": "НЕБЕЗПЕЧНО! Ця програма не входить в каталог додатків YunoHost. Установлення сторонніх додатків може порушити цілісність і безпеку вашої системи. Вам не слід встановлювати його, якщо ви не знаєте, що робите. НІЯКОЇ ПІДТРИМКИ НЕ БУДЕ, якщо цей додаток не буде працювати або зламає вашу систему... Якщо ви все одно готові піти на такий ризик, введіть '{answers}'.", - "confirm_app_install_danger": "НЕБЕЗПЕЧНО! Відомо, що це додаток все ще експериментальне (якщо не сказати, що воно явно не працює)! Вам не слід встановлювати його, якщо ви не знаєте, що робите. Ніякої підтримки не буде надано, якщо цей додаток не буде працювати або зламає вашу систему... Якщо ви все одно готові ризикнути, введіть '{answers}'.", - "confirm_app_install_warning": "Попередження: Ця програма може працювати, але не дуже добре інтегровано в YunoHost. Деякі функції, такі як єдина реєстрація та резервне копіювання/відновлення, можуть бути недоступні. Все одно встановити? [{answers}]. ", - "certmanager_unable_to_parse_self_CA_name": "Не вдалося розібрати ім'я самоподпісивающегося центру (файл: {file})", - "certmanager_self_ca_conf_file_not_found": "Не вдалося знайти файл конфігурації для самоподпісивающегося центру (файл: {file})", + "custom_app_url_required": "Ви повинні надати URL-адресу для оновлення вашого користувацького застосунку {app}.", + "confirm_app_install_thirdparty": "НЕБЕЗПЕЧНО! Цей застосунок не входить в каталог застосунків YunoHost. Установлення сторонніх застосунків може порушити цілісність і безпеку вашої системи. Вам не слід встановлювати його, якщо ви не знаєте, що робите. НІЯКОЇ ПІДТРИМКИ НЕ БУДЕ, якщо цей застосунок не буде працювати або зламає вашу систему... Якщо ви все одно готові піти на такий ризик, введіть '{answers}'.", + "confirm_app_install_danger": "НЕБЕЗПЕЧНО! Відомо, що цей застосунок все ще експериментальний (якщо не сказати, що він явно не працює)! Вам не слід встановлювати його, якщо ви не знаєте, що робите. Ніякої підтримки не буде надано, якщо цей застосунок не буде працювати або зламає вашу систему... Якщо ви все одно готові ризикнути, введіть '{answers}'.", + "confirm_app_install_warning": "Попередження: Цей застосунок може працювати, але він не дуже добре інтегрований в YunoHost. Деякі функції, такі як єдина реєстрація та резервне копіювання/відновлення, можуть бути недоступні. Все одно встановити? [{answers}]. ", + "certmanager_unable_to_parse_self_CA_name": "Не вдалося розібрати назву самопідписного центру (файл: {file})", + "certmanager_self_ca_conf_file_not_found": "Не вдалося знайти файл конфігурації для самопідписного центру (файл: {file})", "certmanager_no_cert_file": "Не вдалося розпізнати файл сертифіката для домену {domain} (файл: {file})", - "certmanager_hit_rate_limit": "Для цього набору доменів {domain} недавно було випущено дуже багато сертифікатів. Будь ласка, спробуйте ще раз пізніше. Див. Https://letsencrypt.org/docs/rate-limits/ для отримання більш докладної інформації.", - "certmanager_warning_subdomain_dns_record": "Піддомен '{subdomain} \"не дозволяється на той же IP-адресу, що і' {domain} '. Деякі функції будуть недоступні, поки ви не виправите це і не перегенеріруете сертифікат.", - "certmanager_domain_http_not_working": "Домен {domain}, схоже, не доступний через HTTP. Будь ласка, перевірте категорію 'Web' в діагностиці для отримання додаткової інформації. (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб відключити ці перевірки).", - "certmanager_domain_dns_ip_differs_from_public_ip": "DNS-записи для домену '{domain}' відрізняються від IP цього сервера. Будь ласка, перевірте категорію 'DNS-записи' (основні) в діагностиці для отримання додаткової інформації. Якщо ви недавно змінили запис A, будь ласка, зачекайте, поки вона пошириться (деякі програми перевірки поширення DNS доступні в Інтернеті). (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб відключити ці перевірки).", - "certmanager_domain_cert_not_selfsigned": "Сертифікат для домену {domain} не є самоподпісанного. Ви впевнені, що хочете замінити його? (Для цього використовуйте '--force').", - "certmanager_domain_not_diagnosed_yet": "Поки немає результатів діагностики для домену {domain}. Будь ласка, повторно проведіть діагностику для категорій 'DNS-записи' і 'Web' в розділі діагностики, щоб перевірити, чи готовий домен до Let's Encrypt. (Або, якщо ви знаєте, що робите, використовуйте '--no-checks', щоб відключити ці перевірки).", + "certmanager_hit_rate_limit": "Для цього набору доменів {domain} недавно було випущено дуже багато сертифікатів. Будь ласка, спробуйте ще раз пізніше. Див. https://letsencrypt.org/docs/rate-limits/ для отримання подробиць.", + "certmanager_warning_subdomain_dns_record": "Піддомен '{subdomain}' не дозволяється на тій же IP-адресі, що і '{domain}'. Деякі функції будуть недоступні, поки ви не виправите це і не перестворите сертифікат.", + "certmanager_domain_http_not_working": "Домен {domain}, схоже, не доступний через HTTP. Будь ласка, перевірте категорію 'Мережа' в діагностиці для отримання додаткових даних. (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", + "certmanager_domain_dns_ip_differs_from_public_ip": "DNS-записи для домену '{domain}' відрізняються від IP цього сервера. Будь ласка, перевірте категорію 'DNS-записи' (основні) в діагностиці для отримання додаткових даних. Якщо ви недавно змінили запис A, будь ласка, зачекайте, поки він пошириться (деякі програми перевірки поширення DNS доступні в Інтернеті). (Якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", + "certmanager_domain_cert_not_selfsigned": "Сертифікат для домену {domain} не є самопідписаним. Ви впевнені, що хочете замінити його? (Для цього використовуйте '--force').", + "certmanager_domain_not_diagnosed_yet": "Поки немає результатів діагностики для домену {domain}. Будь ласка, повторно проведіть діагностику для категорій 'DNS-записи' і 'Мережа' в розділі діагностики, щоб перевірити, чи готовий домен до Let's Encrypt. (Або, якщо ви знаєте, що робите, використовуйте '--no-checks', щоб вимкнути ці перевірки).", "certmanager_certificate_fetching_or_enabling_failed": "Спроба використовувати новий сертифікат для {domain} не спрацювала...", "certmanager_cert_signing_failed": "Не вдалося підписати новий сертифікат", "certmanager_cert_renew_success": "Сертифікат Let's Encrypt оновлений для домену '{domain}'", - "certmanager_cert_install_success_selfsigned": "Самоподпісанний сертифікат тепер встановлений для домену '{domain}'", - "certmanager_cert_install_success": "Сертифікат Let's Encrypt тепер встановлений для домену '{domain}'", - "certmanager_cannot_read_cert": "Щось не так сталося при спробі відкрити поточний сертифікат для домену {domain} (файл: {file}), причина: {reason}", - "certmanager_attempt_to_replace_valid_cert": "Ви намагаєтеся перезаписати хороший і дійсний сертифікат для домену {domain}! (Використовуйте --force для обходу)", - "certmanager_attempt_to_renew_valid_cert": "Термін дії сертифіката для домену '{domain} \"не закінчується! (Ви можете використовувати --force, якщо знаєте, що робите)", + "certmanager_cert_install_success_selfsigned": "Самопідписаний сертифікат тепер встановлений для домену '{domain}'", + "certmanager_cert_install_success": "Сертифікат Let's Encrypt тепер встановлений для домена '{domain}'", + "certmanager_cannot_read_cert": "Щось не так сталося при спробі відкрити поточний сертифікат для домена {domain} (файл: {file}), причина: {reason}", + "certmanager_attempt_to_replace_valid_cert": "Ви намагаєтеся перезаписати хороший дійсний сертифікат для домену {domain}! (Використовуйте --force для обходу)", + "certmanager_attempt_to_renew_valid_cert": "Строк дії сертифіката для домена '{domain}' не закінчується! (Ви можете використовувати --force, якщо знаєте, що робите)", "certmanager_attempt_to_renew_nonLE_cert": "Сертифікат для домену '{domain}' не випущено Let's Encrypt. Неможливо продовжити його автоматично!", - "certmanager_acme_not_configured_for_domain": "Завдання ACME не може бути запущена для {domain} прямо зараз, тому що в його nginx conf відсутній відповідний фрагмент коду... Будь ласка, переконайтеся, що конфігурація nginx оновлена за допомогою `yunohost tools regen-conf nginx --dry-run - with-diff`.", - "backup_with_no_restore_script_for_app": "{app} не має скрипта відновлення, ви не зможете автоматично відновити резервну копію цього додатка.", - "backup_with_no_backup_script_for_app": "Додаток '{app}' не має скрипта резервного копіювання. Ігнорування.", - "backup_unable_to_organize_files": "Неможливо використовувати швидкий метод для організації файлів в архіві", + "certmanager_acme_not_configured_for_domain": "Завдання ACME не може бути запущене для {domain} прямо зараз, тому що в його nginx-конфігурації відсутній відповідний фрагмент коду... Будь ласка, переконайтеся, що конфігурація nginx оновлена за допомогою `yunohost tools regen-conf nginx --dry-run --with-diff`.", + "backup_with_no_restore_script_for_app": "{app} не має скрипта відновлення, ви не зможете автоматично відновити резервну копію цього застосунку.", + "backup_with_no_backup_script_for_app": "Застосунок '{app}' не має скрипта резервного копіювання. Нехтую ним.", + "backup_unable_to_organize_files": "Неможливо використовувати швидкий спосіб для організації файлів в архіві", "backup_system_part_failed": "Не вдалося створити резервну копію системної частини '{part}'.", - "backup_running_hooks": "Запуск гачків резервного копіювання...", + "backup_running_hooks": "Запуск гачків (hook) резервного копіювання...", "backup_permission": "Дозвіл на резервне копіювання для {app}", - "backup_output_symlink_dir_broken": "Ваш архівний каталог '{path}' є непрацюючою симлінк. Можливо, ви забули перемонтувати або підключити носій, на який вона вказує.", + "backup_output_symlink_dir_broken": "Ваш архівний каталог '{path}' є неробочим символічним посиланням. Можливо, ви забули перемонтувати або підключити носій, на який вона вказує.", "backup_output_directory_required": "Ви повинні вказати вихідний каталог для резервного копіювання", "backup_output_directory_not_empty": "Ви повинні вибрати порожній вихідний каталог", - "backup_output_directory_forbidden": "Виберіть інший вихідний каталог. Резервні копії не можуть бути створені в підкаталогах/bin,/boot,/dev,/etc,/lib,/root,/run,/sbin,/sys,/usr,/var або /home/yunohost.backup/archives.", - "backup_nothings_done": "нічого зберігати", + "backup_output_directory_forbidden": "Виберіть інший вихідний каталог. Резервні копії не можуть бути створені в підкаталогах /bin,/boot,/dev,/etc,/lib,/root,/run,/sbin,/sys,/usr,/var або /home/yunohost.backup/archives.", + "backup_nothings_done": "Нема що зберігати", "backup_no_uncompress_archive_dir": "Немає такого каталогу нестислого архіву", - "backup_mount_archive_for_restore": "Підготовка архіву для відновлення...", + "backup_mount_archive_for_restore": "Підготовлення архіву для відновлення...", "backup_method_tar_finished": "Створено архів резервного копіювання TAR", - "backup_method_custom_finished": "Призначений для користувача метод резервного копіювання '{method}' завершено", + "backup_method_custom_finished": "Користувацький спосіб резервного копіювання '{method}' завершено", "backup_method_copy_finished": "Резервне копіювання завершено", - "backup_hook_unknown": "Гачок резервного копіювання '{hook}' невідомий", + "backup_hook_unknown": "Гачок (hook) резервного копіювання '{hook}' невідомий", "backup_deleted": "Резервна копія видалена", "backup_delete_error": "Не вдалося видалити '{path}'", - "backup_custom_mount_error": "Призначений для користувача метод резервного копіювання не зміг пройти етап 'монтування'", - "backup_custom_backup_error": "Призначений для користувача метод резервного копіювання не зміг пройти етап 'резервне копіювання'", + "backup_custom_mount_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'монтування'", + "backup_custom_backup_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'резервне копіювання'", "backup_csv_creation_failed": "Не вдалося створити CSV-файл, необхідний для відновлення", "backup_csv_addition_failed": "Не вдалося додати файли для резервного копіювання в CSV-файл", "backup_creation_failed": "Не вдалося створити архів резервного копіювання", "backup_create_size_estimation": "Архів буде містити близько {size} даних.", "backup_created": "Резервна копія створена", "backup_couldnt_bind": "Не вдалося зв'язати {src} з {dest}.", - "backup_copying_to_organize_the_archive": "Копіювання {size} MB для організації архіву", - "backup_cleaning_failed": "Не вдалося очистити тимчасову папку резервного копіювання", + "backup_copying_to_organize_the_archive": "Копіювання {size} МБ для організації архіву", + "backup_cleaning_failed": "Не вдалося очистити тимчасовий каталог резервного копіювання", "backup_cant_mount_uncompress_archive": "Не вдалося змонтувати нестислий архів як захищений від запису", - "backup_ask_for_copying_if_needed": "Чи хочете ви тимчасово виконати резервне копіювання з використанням {size} MB? (Цей спосіб використовується, оскільки деякі файли не можуть бути підготовлені більш ефективним методом).", + "backup_ask_for_copying_if_needed": "Ви бажаєте тимчасово виконати резервне копіювання з використанням {size} МБ? (Цей спосіб використовується, оскільки деякі файли не можуть бути підготовлені дієвіше).", "backup_archive_writing_error": "Не вдалося додати файли '{source}' (названі в архіві '{dest}') для резервного копіювання в стислий архів '{archive}'.", - "backup_archive_system_part_not_available": "Системна частина '{part}' недоступна в цій резервної копії", - "backup_archive_corrupted": "Схоже, що архів резервної копії \"{archive} 'пошкоджений: {error}", - "backup_archive_cant_retrieve_info_json": "Не вдалося завантажити інформацію для архіву '{archive}'... info.json не може бути отриманий (або не є коректним json).", - "backup_archive_open_failed": "Не вдалося відкрити архів резервних копій", - "backup_archive_name_unknown": "Невідомий локальний архів резервного копіювання з ім'ям '{name}'", - "backup_archive_name_exists": "Архів резервного копіювання з таким ім'ям вже існує.", - "backup_archive_broken_link": "Не вдалося отримати доступ до архіву резервного копіювання (непрацююча посилання на {path})", + "backup_archive_system_part_not_available": "Системна частина '{part}' недоступна в цій резервній копії", + "backup_archive_corrupted": "Схоже, що архів резервної копії '{archive}' пошкоджений: {error}", + "backup_archive_cant_retrieve_info_json": "Не вдалося завантажити відомості для архіву '{archive}'... info.json не може бути отриманий(або не є правильним json).", + "backup_archive_open_failed": "Не вдалося відкрити архів резервної копії", + "backup_archive_name_unknown": "Невідомий локальний архів резервного копіювання з назвою '{name}'", + "backup_archive_name_exists": "Архів резервного копіювання з такою назвою вже існує.", + "backup_archive_broken_link": "Не вдалося отримати доступ до архіву резервного копіювання (неробоче посилання на {path})", "backup_archive_app_not_found": "Не вдалося знайти {app} в архіві резервного копіювання", "backup_applying_method_tar": "Створення резервного TAR-архіву...", - "backup_applying_method_custom": "Виклик для користувача методу резервного копіювання '{method}'...", - "backup_applying_method_copy": "Копіювання всіх файлів в резервну копію...", + "backup_applying_method_custom": "Виклик користувацького способу резервного копіювання '{method}'...", + "backup_applying_method_copy": "Копіювання всіх файлів у резервну копію...", "backup_app_failed": "Не вдалося створити резервну копію {app}", "backup_actually_backuping": "Створення резервного архіву з зібраних файлів...", - "backup_abstract_method": "Цей метод резервного копіювання ще не реалізований", - "ask_password": "пароль", + "backup_abstract_method": "Цей спосіб резервного копіювання ще не реалізований", + "ask_password": "Пароль", "ask_new_path": "Новий шлях", - "ask_new_domain": "новий домен", - "ask_new_admin_password": "Новий адміністративний пароль", - "ask_main_domain": "основний домен", + "ask_new_domain": "Новий домен", + "ask_new_admin_password": "Новий пароль адміністратора", + "ask_main_domain": "Основний домен", "ask_lastname": "Прізвище", - "ask_firstname": "ім'я", - "ask_user_domain": "Домен для адреси електронної пошти користувача і облікового запису XMPP", - "apps_catalog_update_success": "Каталог додатків був оновлений!", - "apps_catalog_obsolete_cache": "Кеш каталогу додатків порожній або застарів.", - "apps_catalog_failed_to_download": "Неможливо завантажити каталог додатків {apps_catalog}: {error}", - "apps_catalog_updating": "Оновлення каталогу додатків…", - "apps_catalog_init_success": "Система каталогу додатків инициализирована!", - "apps_already_up_to_date": "Всі додатки вже оновлені", - "app_packaging_format_not_supported": "Ця програма не може бути встановлено, тому що формат його упаковки не підтримується вашою версією YunoHost. Можливо, вам слід оновити вашу систему.", + "ask_firstname": "Ім'я", + "ask_user_domain": "Домен для адреси е-пошти користувача і облікового запису XMPP", + "apps_catalog_update_success": "Каталог застосунків був оновлений!", + "apps_catalog_obsolete_cache": "Кеш каталогу застосунків порожній або застарів.", + "apps_catalog_failed_to_download": "Неможливо завантажити каталог застосунків {apps_catalog}: {error}", + "apps_catalog_updating": "Оновлення каталогу застосунків…", + "apps_catalog_init_success": "Систему каталогу застосунків ініціалізовано!", + "apps_already_up_to_date": "Усі застосунки вже оновлено", + "app_packaging_format_not_supported": "Цей застосунок не може бути встановлено, тому що формат його упакування не підтримується вашою версією YunoHost. Можливо, вам слід оновити систему.", "app_upgraded": "{app} оновлено", - "app_upgrade_some_app_failed": "Деякі програми не можуть бути оновлені", - "app_upgrade_script_failed": "Сталася помилка в сценарії оновлення програми", + "app_upgrade_some_app_failed": "Деякі застосунки не можуть бути оновлені", + "app_upgrade_script_failed": "Сталася помилка в скрипті оновлення застосунку", "app_upgrade_failed": "Не вдалося оновити {app}: {error}", "app_upgrade_app_name": "Зараз оновлюємо {app}...", - "app_upgrade_several_apps": "Наступні додатки будуть оновлені: {apps}", - "app_unsupported_remote_type": "Для додатка використовується непідтримуваний віддалений тип.", - "app_unknown": "невідоме додаток", + "app_upgrade_several_apps": "Наступні застосунки буде оновлено: {apps}", + "app_unsupported_remote_type": "Для застосунку використовується непідтримуваний віддалений тип.", + "app_unknown": "Невідомий застосунок", "app_start_restore": "Відновлення {app}...", - "app_start_backup": "Збір файлів для резервного копіювання {app}...", - "app_start_remove": "Видалення {app}...", + "app_start_backup": "Збирання файлів для резервного копіювання {app}...", + "app_start_remove": "Вилучення {app}...", "app_start_install": "Установлення {app}...", - "app_sources_fetch_failed": "Не вдалося вихідні файли, URL коректний?", - "app_restore_script_failed": "Сталася помилка всередині скрипта відновити оригінальну програму", + "app_sources_fetch_failed": "Не вдалося отримати джерельні файли, URL-адреса правильна?", + "app_restore_script_failed": "Сталася помилка всередині скрипта відновлення застосунку", "app_restore_failed": "Не вдалося відновити {app}: {error}", - "app_remove_after_failed_install": "Видалення програми після збою установки...", + "app_remove_after_failed_install": "Вилучення застосунку після збою встановлення...", "app_requirements_unmeet": "Вимоги не виконані для {app}, пакет {pkgname} ({version}) повинен бути {spec}.", - "app_requirements_checking": "Перевірка необхідних пакетів для {app}...", + "app_requirements_checking": "Перевіряння необхідних пакетів для {app}...", "app_removed": "{app} видалено", "app_not_properly_removed": "{app} не було видалено належним чином", - "app_not_installed": "Не вдалося знайти {app} в списку встановлених додатків: {all_apps}", + "app_not_installed": "Не вдалося знайти {app} в списку встановлених застосунків: {all_apps}", "app_not_correctly_installed": "{app}, схоже, неправильно встановлено", - "app_not_upgraded": "Додаток '{failed_app}' не вдалося оновити, і, як наслідок, оновлення таких програмах було скасовано: {apps}", - "app_manifest_install_ask_is_public": "Чи повинно це додаток бути відкрито для анонімних відвідувачів?", - "app_manifest_install_ask_admin": "Виберіть користувача-адміністратора для цього додатка", - "app_manifest_install_ask_password": "Виберіть пароль адміністратора для цього додатка" + "app_not_upgraded": "Застосунок '{failed_app}' не вдалося оновити, і, як наслідок, оновлення таких застосунків було скасовано: {apps}", + "app_manifest_install_ask_is_public": "Чи має цей застосунок бути відкритим для анонімних відвідувачів?", + "app_manifest_install_ask_admin": "Виберіть користувача-адміністратора для цього застосунку", + "app_manifest_install_ask_password": "Виберіть пароль адміністратора для цього застосунку" }