diff --git a/aiida/cmdline/commands/cmd_setup.py b/aiida/cmdline/commands/cmd_setup.py index 3f46b83e7d..b715cf6fd5 100644 --- a/aiida/cmdline/commands/cmd_setup.py +++ b/aiida/cmdline/commands/cmd_setup.py @@ -108,8 +108,7 @@ def setup( ) if created: user.store() - profile.default_user_email = user.email - config.update_profile(profile) + config.set_default_user_email(profile, user.email) # store the updated configuration config.store() diff --git a/aiida/cmdline/commands/cmd_user.py b/aiida/cmdline/commands/cmd_user.py index 28b4173e4a..8f8dbeff3c 100644 --- a/aiida/cmdline/commands/cmd_user.py +++ b/aiida/cmdline/commands/cmd_user.py @@ -8,45 +8,14 @@ # For further information please visit http://www.aiida.net # ########################################################################### """`verdi user` command.""" - -from functools import partial - import click from aiida.cmdline.commands.cmd_verdi import verdi from aiida.cmdline.params import arguments, options, types +from aiida.cmdline.params.options.commands import setup as options_setup from aiida.cmdline.utils import decorators, echo -def set_default_user(profile, user): - """Set the user as the default user for the given profile. - - :param profile: the profile - :param user: the user - """ - from aiida.manage.configuration import get_config - config = get_config() - profile.default_user_email = user.email - config.update_profile(profile) - config.store() - - -def get_user_attribute_default(attribute, ctx): - """Return the default value for the given attribute of the user passed in the context. - - :param attribute: attribute for which to get the current value - :param ctx: click context which should contain the selected user - :return: user attribute default value if set, or None - """ - default = getattr(ctx.params['user'], attribute) - - # None or empty string means there is no default - if not default: - return None - - return default - - @verdi.group('user') def verdi_user(): """Inspect and manage users.""" @@ -58,15 +27,22 @@ def user_list(): """Show a list of all users.""" from aiida.orm import User - default_user = User.collection.get_default() + table = [] + + for user in sorted(User.collection.all(), key=lambda user: user.email): + row = ['*' if user.is_default else '', user.email, user.first_name, user.last_name, user.institution] + if user.is_default: + table.append(list(map(echo.highlight_string, row))) + else: + table.append(row) - if default_user is None: - echo.echo_warning('no default user has been configured') + echo.echo_tabulate(table, headers=['', 'Email', 'First name', 'Last name', 'Institution']) + echo.echo('') - attributes = ['email', 'first_name', 'last_name'] - sort = lambda user: user.email - highlight = lambda x: x.email == default_user.email if default_user else None - echo.echo_formatted_list(User.collection.all(), attributes, sort=sort, highlight=highlight) + if User.collection.get_default() is None: + echo.echo_warning('No default user has been configured') + else: + echo.echo_report('The user highlighted and marked with a * is the default user.') @verdi_user.command('configure') @@ -78,57 +54,31 @@ def user_list(): type=types.UserParamType(create=True), cls=options.interactive.InteractiveOption ) -@click.option( - '--first-name', - prompt='First name', - help='First name of the user.', - type=click.STRING, - contextual_default=partial(get_user_attribute_default, 'first_name'), - cls=options.interactive.InteractiveOption -) -@click.option( - '--last-name', - prompt='Last name', - help='Last name of the user.', - type=click.STRING, - contextual_default=partial(get_user_attribute_default, 'last_name'), - cls=options.interactive.InteractiveOption -) -@click.option( - '--institution', - prompt='Institution', - help='Institution of the user.', - type=click.STRING, - contextual_default=partial(get_user_attribute_default, 'institution'), - cls=options.interactive.InteractiveOption -) +@options_setup.SETUP_USER_FIRST_NAME(contextual_default=lambda ctx: ctx.params['user'].first_name) +@options_setup.SETUP_USER_LAST_NAME(contextual_default=lambda ctx: ctx.params['user'].last_name) +@options_setup.SETUP_USER_INSTITUTION(contextual_default=lambda ctx: ctx.params['user'].institution) @click.option( '--set-default', prompt='Set as default?', help='Set the user as the default user for the current profile.', is_flag=True, - cls=options.interactive.InteractiveOption + cls=options.interactive.InteractiveOption, + contextual_default=lambda ctx: ctx.params['user'].is_default ) @click.pass_context @decorators.with_dbenv() -def user_configure(ctx, user, first_name, last_name, institution, set_default): +def user_configure(ctx, user, first_name, last_name, institution, set_default): # pylint: disable=too-many-arguments """Configure a new or existing user. An e-mail address is used as the user name. """ - # pylint: disable=too-many-arguments - if first_name is not None: - user.first_name = first_name - if last_name is not None: - user.last_name = last_name - if institution is not None: - user.institution = institution - action = 'updated' if user.is_stored else 'created' - + user.first_name = first_name + user.last_name = last_name + user.institution = institution user.store() - echo.echo_success(f'{user.email} successfully {action}') + echo.echo_success(f'Successfully {action} `{user.email}`.') if set_default: ctx.invoke(user_set_default, user=user) @@ -140,5 +90,6 @@ def user_configure(ctx, user, first_name, last_name, institution, set_default): @decorators.with_dbenv() def user_set_default(ctx, user): """Set a user as the default user for the profile.""" - set_default_user(ctx.obj.profile, user) - echo.echo_success(f'set `{user.email}` as the new default user for profile `{ctx.obj.profile.name}`') + from aiida.manage import get_manager + get_manager().set_default_user_email(ctx.obj.profile, user.email) + echo.echo_success(f'Set `{user.email}` as the default user for profile `{ctx.obj.profile.name}.`') diff --git a/aiida/cmdline/params/options/commands/setup.py b/aiida/cmdline/params/options/commands/setup.py index 88d7216072..1c66b27793 100644 --- a/aiida/cmdline/params/options/commands/setup.py +++ b/aiida/cmdline/params/options/commands/setup.py @@ -186,21 +186,21 @@ def get_quicksetup_password(ctx, param, value): # pylint: disable=unused-argume SETUP_USER_FIRST_NAME = options.USER_FIRST_NAME.clone( prompt='First name', - default=functools.partial(get_config_option, 'autofill.user.first_name'), + default=lambda: get_config_option('autofill.user.first_name') or 'John', required=True, cls=options.interactive.InteractiveOption ) SETUP_USER_LAST_NAME = options.USER_LAST_NAME.clone( prompt='Last name', - default=functools.partial(get_config_option, 'autofill.user.last_name'), + default=lambda: get_config_option('autofill.user.last_name') or 'Doe', required=True, cls=options.interactive.InteractiveOption ) SETUP_USER_INSTITUTION = options.USER_INSTITUTION.clone( prompt='Institution', - default=functools.partial(get_config_option, 'autofill.user.institution'), + default=lambda: get_config_option('autofill.user.institution') or 'Unknown', required=True, cls=options.interactive.InteractiveOption ) diff --git a/aiida/cmdline/utils/echo.py b/aiida/cmdline/utils/echo.py index 5cef7732dc..887fcba732 100644 --- a/aiida/cmdline/utils/echo.py +++ b/aiida/cmdline/utils/echo.py @@ -46,6 +46,18 @@ class ExitCode(enum.IntEnum): } +def highlight_string(string: str, color: str = 'highlight') -> str: + """Highlight a string with a certain color. + + Uses ``click.style`` to highlight the string. + + :param string: The string to highlight. + :param color: The color to use. + :returns: The highlighted string. + """ + return click.style(string, fg=COLORS[color]) + + def echo(message: Any, fg: Optional[str] = None, bold: bool = False, nl: bool = True, err: bool = False) -> None: """Log a message to the cmdline logger. diff --git a/aiida/manage/configuration/__init__.py b/aiida/manage/configuration/__init__.py index 4a5873c88e..f5d35ebb6c 100644 --- a/aiida/manage/configuration/__init__.py +++ b/aiida/manage/configuration/__init__.py @@ -213,10 +213,9 @@ def create_profile( with profile_context(profile.name, allow_switch=True): user = User(email=email, first_name=first_name, last_name=last_name, institution=institution).store() - profile.default_user_email = user.email - - config.update_profile(profile) - config.store() + # We can safely use ``Config.set_default_user_email`` here instead of ``Manager.set_default_user_email`` since + # the storage backend of this new profile is not loaded yet. + config.set_default_user_email(profile, user.email) return profile diff --git a/aiida/manage/configuration/config.py b/aiida/manage/configuration/config.py index 89014670bb..5ecc64bce2 100644 --- a/aiida/manage/configuration/config.py +++ b/aiida/manage/configuration/config.py @@ -600,6 +600,21 @@ def set_default_profile(self, name, overwrite=False): self._default_profile = name return self + def set_default_user_email(self, profile: Profile, user_email: str) -> None: + """Set the default user for the given profile. + + .. warning:: + + This does not update the cached default user on the storage backend associated with the profile. To do so, + use :meth:`aiida.manage.manager.Manager.set_default_user_email` instead. + + :param profile: The profile to update. + :param user_email: The email of the user to set as the default user. + """ + profile.default_user_email = user_email + self.update_profile(profile) + self.store() + @property def options(self): return self._options diff --git a/aiida/manage/manager.py b/aiida/manage/manager.py index 3e6959d064..3e33f822c3 100644 --- a/aiida/manage/manager.py +++ b/aiida/manage/manager.py @@ -179,6 +179,16 @@ def unload_profile(self) -> None: self.reset_profile() self._profile = None + def set_default_user_email(self, profile: 'Profile', user_email: str) -> None: + """Set the default user for the given profile. + + :param profile: The profile to update. + :param user_email: The email of the user to set as the default user. + """ + self.get_config().set_default_user_email(profile, user_email) + if self.profile_storage_loaded: + self.get_profile_storage().reset_default_user() + @property def profile_storage_loaded(self) -> bool: """Return whether a storage backend has been loaded. diff --git a/aiida/orm/implementation/storage_backend.py b/aiida/orm/implementation/storage_backend.py index 54f2b6b803..d16bccc901 100644 --- a/aiida/orm/implementation/storage_backend.py +++ b/aiida/orm/implementation/storage_backend.py @@ -141,7 +141,15 @@ def _clear(self) -> None: .. warning:: This is a destructive operation, and should only be used for testing purposes. """ from aiida.orm.autogroup import AutogroupManager + self.reset_default_user() self._autogroup = AutogroupManager(self) + + def reset_default_user(self) -> None: + """Reset the default user. + + This should be done when the default user of the storage backend is changed on the corresponding profile because + the old default user is cached on this instance. + """ self._default_user = None @property diff --git a/aiida/orm/users.py b/aiida/orm/users.py index 3f2b49c288..fab3517d58 100644 --- a/aiida/orm/users.py +++ b/aiida/orm/users.py @@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, Optional, Tuple, Type from aiida.common import exceptions -from aiida.common.lang import classproperty from aiida.manage import get_manager from . import entities @@ -84,6 +83,15 @@ def normalize_email(email: str) -> str: email = '@'.join([email_name, domain_part.lower()]) return email + @property + def is_default(self) -> bool: + """Return whether the user is the default user. + + :returns: Boolean, ``True`` if the user is the default, ``False`` otherwise. + """ + default_user = self.collection.get_default() + return default_user is not None and self.pk == default_user.pk + @property def email(self) -> str: return self._backend_entity.email diff --git a/aiida/transports/cli.py b/aiida/transports/cli.py index 037c0391d9..1d8daec205 100644 --- a/aiida/transports/cli.py +++ b/aiida/transports/cli.py @@ -17,7 +17,6 @@ from aiida.cmdline.utils import echo from aiida.cmdline.utils.decorators import with_dbenv from aiida.common.exceptions import NotExistent -from aiida.manage import get_manager TRANSPORT_PARAMS = [] @@ -40,7 +39,7 @@ def configure_computer_main(computer, user, **kwargs): user = user or orm.User.collection.get_default() echo.echo_report(f'Configuring computer {computer.label} for user {user.email}.') - if user.email != get_manager().get_profile().default_user_email: + if not user.is_default: echo.echo_report('Configuring different user, defaults may not be appropriate.') computer.configure(user=user, **kwargs) diff --git a/tests/cmdline/commands/test_setup.py b/tests/cmdline/commands/test_setup.py index 4818a220ca..fc4fb9a637 100644 --- a/tests/cmdline/commands/test_setup.py +++ b/tests/cmdline/commands/test_setup.py @@ -84,6 +84,22 @@ def test_quicksetup(self, tmp_path): backend = profile.storage_cls(profile) assert backend.get_global_variable('repository|uuid') == backend.get_repository().uuid + def test_quicksetup_default_user(self, tmp_path): + """Test `verdi quicksetup` and ensure that user details (apart from the email) are optional.""" + profile_name = 'testing-default-user-details' + user_email = 'some@email.com' + + options = [ + '--non-interactive', '--profile', profile_name, '--email', user_email, '--db-port', + self.pg_test.dsn['port'], '--db-backend', self.storage_backend_name, '--repository', + str(tmp_path) + ] + + self.cli_runner(cmd_setup.quicksetup, options, use_subprocess=False) + + config = configuration.get_config() + assert profile_name in config.profile_names + def test_quicksetup_from_config_file(self, tmp_path): """Test `verdi quicksetup` from configuration file.""" with tempfile.NamedTemporaryFile('w') as handle: diff --git a/tests/cmdline/commands/test_user.py b/tests/cmdline/commands/test_user.py index d10ca0f3c1..b31a481cca 100644 --- a/tests/cmdline/commands/test_user.py +++ b/tests/cmdline/commands/test_user.py @@ -7,92 +7,81 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### +# pylint: disable=redefined-outer-name """Tests for `verdi user`.""" +import itertools +import secrets + import pytest from aiida import orm from aiida.cmdline.commands import cmd_user -USER_1 = { # pylint: disable=invalid-name - 'email': 'testuser1@localhost', - 'first_name': 'Max', - 'last_name': 'Mueller', - 'institution': 'Testing Instiute' -} -USER_2 = { # pylint: disable=invalid-name - 'email': 'testuser2@localhost', - 'first_name': 'Sabine', - 'last_name': 'Garching', - 'institution': 'Second testing instiute' -} - - -class TestVerdiUserCommand: - """Test verdi user.""" - - @pytest.fixture(autouse=True) - def init_profile(self): # pylint: disable=unused-argument - """Initialize the profile.""" - # pylint: disable=attribute-defined-outside-init - created, user = orm.User.collection.get_or_create(email=USER_1['email']) - for key, value in USER_1.items(): - if key != 'email': - setattr(user, key, value) - if created: - orm.User(**USER_1).store() - - def test_user_list(self, run_cli_command): - """Test `verdi user list`.""" - from aiida.cmdline.commands.cmd_user import user_list as list_user - - result = run_cli_command(list_user, []) - assert USER_1['email'] in result.output - - def test_user_create(self, run_cli_command): - """Create a new user with `verdi user configure`.""" - cli_options = [ - '--email', - USER_2['email'], - '--first-name', - USER_2['first_name'], - '--last-name', - USER_2['last_name'], - '--institution', - USER_2['institution'], - '--set-default', - ] - - result = run_cli_command(cmd_user.user_configure, cli_options) - assert USER_2['email'] in result.output - assert 'created' in result.output - assert 'updated' not in result.output - - user_obj = orm.User.collection.get(email=USER_2['email']) - for key, val in USER_2.items(): - assert val == getattr(user_obj, key) - - def test_user_update(self, run_cli_command): - """Reconfigure an existing user with `verdi user configure`.""" - email = USER_1['email'] - - cli_options = [ - '--email', - USER_1['email'], - '--first-name', - USER_2['first_name'], - '--last-name', - USER_2['last_name'], - '--institution', - USER_2['institution'], - '--set-default', - ] - - result = run_cli_command(cmd_user.user_configure, cli_options) - assert email in result.output - assert 'updated' in result.output - assert 'created' not in result.output - - # Check it's all been changed to user2's attributes except the email - for key, _ in USER_2.items(): - if key != 'email': - setattr(cmd_user, key, USER_1[key]) + +@pytest.fixture +def create_user(): + """Create a dictionary with random attributes for a new user.""" + return { + 'email': f'{secrets.token_hex(2)}@localhost', + 'first_name': secrets.token_hex(2), + 'last_name': secrets.token_hex(2), + 'institution': secrets.token_hex(2), + } + + +@pytest.mark.usefixtures('aiida_profile') +def test_user_list(run_cli_command): + """Test `verdi user list`.""" + default_user = orm.User.collection.get_default() + result = run_cli_command(cmd_user.user_list) + assert default_user.email in result.output + + +@pytest.mark.usefixtures('aiida_profile') +def test_user_configure_create(run_cli_command, create_user): + """Create a new user with `verdi user configure`.""" + new_user = create_user + options = list( + itertools.chain(*zip(['--email', '--first-name', '--last-name', '--institution'], list(new_user.values()))) + ) + + result = run_cli_command(cmd_user.user_configure, options) + assert new_user['email'] in result.output + assert 'created' in result.output + assert 'updated' not in result.output + + user = orm.User.collection.get(email=new_user['email']) + for key, val in new_user.items(): + assert val == getattr(user, key) + + +@pytest.mark.usefixtures('aiida_profile') +def test_user_configure_update(run_cli_command, create_user): + """Reconfigure an existing user with `verdi user configure`.""" + new_user = create_user + default_user = orm.User.collection.get_default() + new_user['email'] = default_user + options = list( + itertools.chain(*zip(['--email', '--first-name', '--last-name', '--institution'], list(new_user.values()))) + ) + + result = run_cli_command(cmd_user.user_configure, options) + assert default_user.email in result.output + assert 'updated' in result.output + assert 'created' not in result.output + + for key, val in new_user.items(): + if key == 'email': + continue + assert val == getattr(default_user, key) + + +@pytest.mark.usefixtures('aiida_profile') +def test_set_default(run_cli_command, create_user): + """Reconfigure an existing user with `verdi user configure`.""" + new_user = orm.User(**create_user).store() + assert orm.User.collection.get_default().email != new_user.email + + result = run_cli_command(cmd_user.user_set_default, [new_user.email]) + assert f'Set `{new_user.email}` as the default user' in result.output + assert orm.User.collection.get_default().email == new_user.email diff --git a/tests/manage/configuration/test_config.py b/tests/manage/configuration/test_config.py index 35786ff683..13f9bd54b7 100644 --- a/tests/manage/configuration/test_config.py +++ b/tests/manage/configuration/test_config.py @@ -274,6 +274,18 @@ def test_default_profile(empty_config, profile_factory): assert config.default_profile_name == alternative_profile_name +def test_set_default_user_email(config_with_profile): + """Test the :meth:`aiida.manage.configuration.config.Config.set_default_user_email`.""" + config = config_with_profile + profile = config.get_profile() + default_user_email = profile.default_user_email + default_user_email_new = uuid.uuid4().hex + assert default_user_email != default_user_email_new + config.set_default_user_email(profile, default_user_email_new) + assert profile.default_user_email == default_user_email_new + assert config.get_profile(profile.name).default_user_email == default_user_email_new + + def test_profiles(config_with_profile, profile_factory): """Test the properties related to retrieving, creating, updating and removing profiles.""" config = config_with_profile diff --git a/tests/orm/test_users.py b/tests/orm/test_users.py new file mode 100644 index 0000000000..4c82d43fa0 --- /dev/null +++ b/tests/orm/test_users.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Tests for :mod:`aiida.orm.users`.""" +from aiida.orm.users import User + + +def test_user_is_default(default_user): + """Test the :meth:`aiida.orm.users.User.is_default` property.""" + assert default_user.is_default + user = User('other@localhost').store() + assert not user.is_default