Commit 505f9eae authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Refactored user metadata edit dialog.

parent 863a86f8
Pipeline #64180 failed with stages
in 15 minutes and 26 seconds
......@@ -40,6 +40,7 @@ import hashlib
import uuid
from nomad import config, processing, utils, infrastructure, datamodel
from nomad.metainfo.flask_restplus import generate_flask_restplus_model
from .api import api
......@@ -220,14 +221,12 @@ class AuthResource(Resource):
abort(401, 'The authenticated user does not exist')
user_model = generate_flask_restplus_model(api, datamodel.User.m_def)
users_model = api.model('UsersModel', {
'users': fields.Nested(api.model('UserModel', {
'name': fields.String(description='The full name of the user as presented in the UI.'),
'user_id': fields.String(description='The unique user UUID.'),
'email': fields.String(description='The email.')
}))
'users': fields.Nested(user_model, skip_none=True)
})
users_parser = api.parser()
users_parser.add_argument(
'query', default='',
......@@ -240,10 +239,35 @@ class UsersResource(Resource):
@api.marshal_with(users_model, code=200, description='User suggestions send')
@api.expect(users_parser, validate=True)
def get(self):
""" Get existing users. """
args = users_parser.parse_args()
return dict(users=infrastructure.keycloak.search_user(args.get('query')))
@api.doc('invite_user')
@api.marshal_with(user_model, code=200, skip_none=True, description='User invited')
@api.expect(user_model, validate=True)
def put(self):
""" Invite a new user. """
json_data = request.get_json()
try:
user = datamodel.User.m_from_dict(json_data)
except Exception as e:
abort(400, 'Invalid user data: %s' % str(e))
if user.email is None:
abort(400, 'Invalid user data: email is required')
try:
error = infrastructure.keycloak.add_user(user, invite=True)
except KeyError as e:
abort(400, 'Invalid user data: %s' % str(e))
if error is not None:
abort(400, 'Could not invite user: %s' % error)
return datamodel.User.get(email=user.email), 200
def with_signature_token(func):
"""
......
......@@ -330,8 +330,13 @@ def repo_edit_action_field(quantity):
fields.Nested(repo_edit_action_model, skip_none=True), description=quantity.description)
class NullableString(fields.String):
__schema_type__ = ['string', 'null']
__schema_example__ = 'nullable string'
repo_edit_action_model = api.model('RepoEditAction', {
'value': fields.String(description='The value/values that is set as a string.'),
'value': NullableString(description='The value/values that is set as a string.'),
'success': fields.Boolean(description='If this can/could be done. Only in API response.'),
'message': fields.String(descriptin='A message that details the action result. Only in API response.')
})
......@@ -379,6 +384,14 @@ def edit(parsed_query: Dict[str, Any], logger, mongo_update: Dict[str, Any] = No
payload=mongo_update, nfailed=len(failed))
def get_uploader_ids(query):
""" Get all the uploader from the query, to check coauthers and shared_with for uploaders. """
search_request = search.SearchRequest()
add_query(search_request, query)
search_request.quantity(name='uploader_id')
return search_request.execute()['quantities']['uploader_id']['values']
@ns.route('/edit')
class EditRepoCalcsResource(Resource):
@api.doc('edit_repo')
......@@ -406,8 +419,19 @@ class EditRepoCalcsResource(Resource):
actions = json_data['actions']
verify = json_data.get('verify', False)
# preparing the query of entries that are edited
parsed_query = {}
for quantity_name, quantity in search.quantities.items():
if quantity_name in query:
value = query[quantity_name]
if quantity.multi and quantity.argparse_action == 'split' and not isinstance(value, list):
value = value.split(',')
parsed_query[quantity_name] = value
parsed_query['owner'] = owner
# checking the edit actions and preparing a mongo update on the fly
mongo_update = {}
uploader_ids = None
for action_quantity_name, quantity_actions in actions.items():
quantity = UserMetadata.m_def.all_quantities.get(action_quantity_name)
if quantity is None:
......@@ -428,23 +452,38 @@ class EditRepoCalcsResource(Resource):
quantity_actions = [quantity_actions]
flask_verify = quantity_flask.get('verify', None)
mongo_key = 'metadata__%s' % quantity.name
has_error = False
for action in quantity_actions:
action['success'] = True
action['message'] = None
action_value = action['value'].strip()
action_value = action.get('value')
if action_value is None:
continue
action_value = action_value.strip()
if action_value == '':
mongo_value = None
elif flask_verify == datamodel.User:
try:
mongo_value = User.get(email=action_value).user_id
mongo_value = User.get(user_id=action_value).user_id
except KeyError:
action['success'] = False
has_error = True
action['message'] = 'User does not exist'
continue
if uploader_ids is None:
uploader_ids = get_uploader_ids(parsed_query)
if action_value in uploader_ids:
action['success'] = False
has_error = True
action['message'] = 'This user is already an uploader of one entry in the query'
continue
elif flask_verify == datamodel.Dataset:
try:
mongo_value = Dataset.m_def.m_x('me').get(
......@@ -462,14 +501,21 @@ class EditRepoCalcsResource(Resource):
else:
mongo_value = action_value
mongo_key = 'metadata__%s' % quantity.name
if len(quantity.shape) == 0:
mongo_update[mongo_key] = mongo_value
else:
mongo_values = mongo_update.setdefault(mongo_key, [])
if mongo_value is not None:
if mongo_value in mongo_values:
action['success'] = False
has_error = True
action['message'] = 'Duplicate values are not allowed'
continue
mongo_values.append(mongo_value)
if len(quantity_actions) == 0 and len(quantity.shape) > 0:
mongo_update[mongo_key] = []
# stop here, if client just wants to verify its actions
if verify:
return json_data, 200
......@@ -478,16 +524,6 @@ class EditRepoCalcsResource(Resource):
if has_error:
return json_data, 400
# get all calculations that have to change
parsed_query = {}
for quantity_name, quantity in search.quantities.items():
if quantity_name in query:
value = query[quantity_name]
if quantity.multi and quantity.argparse_action == 'split' and not isinstance(value, list):
value = value.split(',')
parsed_query[quantity_name] = value
parsed_query['owner'] = owner
# perform the change
edit(parsed_query, logger, mongo_update, True)
......
......@@ -295,6 +295,9 @@ class Domain:
description=(
'Search for the given author. Exact keyword matches in the form "Lastname, '
'Firstname".')),
uploader_id=DomainQuantity(
elastic_field='uploader.user_id', multi=False, aggregations=5,
description=('Search for the given uploader id.')),
comment=DomainQuantity(
elastic_search_type='match', multi=True,
description='Search within the comments. This is a text search ala google.'),
......
......@@ -208,7 +208,7 @@ class Keycloak():
# Do not return an error. This is the case were there are no credentials
return None
def add_user(self, user, bcrypt_password=None):
def add_user(self, user, bcrypt_password=None, invite=False):
"""
Adds the given :class:`nomad.datamodel.User` instance to the configured keycloak
realm using the keycloak admin API.
......@@ -241,6 +241,10 @@ class Keycloak():
enabled=True,
emailVerified=True)
if invite:
keycloak_user['requiredActions'] = [
'UPDATE_PASSWORD', 'UPDATE_PROFILE', 'VERIFY_EMAIL']
if bcrypt_password is not None:
keycloak_user['credentials'] = [dict(
type='password',
......@@ -266,6 +270,10 @@ class Keycloak():
except Exception as e:
return str(e)
if invite:
# TODO send invite
pass
return None
def __user_from_keycloak_user(self, keycloak_user):
......
from flask_restplus import fields
from .metainfo import Section, Quantity
from nomad.app.utils import RFC3339DateTime
from .metainfo import Section, Quantity, Datetime
def field(quantity: Quantity):
......@@ -14,6 +16,8 @@ def field(quantity: Quantity):
field = fields.String
elif quantity.type == bool:
field = fields.Boolean
elif quantity.type == Datetime:
field = RFC3339DateTime
else:
raise NotImplementedError
......
......@@ -139,11 +139,25 @@ class TestAuth:
assert len(data['users'])
keys = data['users'][0].keys()
required_keys = ['name', 'email', 'user_id']
assert len(keys) == len(required_keys)
assert all(key in keys for key in required_keys)
for key in keys:
assert data['users'][0].get(key) is not None
def test_invite(self, api, test_user_auth):
rv = api.put(
'/auth/users', headers=test_user_auth, content_type='application/json',
data=json.dumps({
'first_name': 'John',
'last_name': 'Doe',
'affiliation': 'Affiliation',
'email': 'john.doe@affiliation.edu'
}))
assert rv.status_code == 200
data = json.loads(rv.data)
keys = data.keys()
required_keys = ['name', 'email', 'user_id']
assert all(key in keys for key in required_keys)
class TestUploads:
......@@ -1066,9 +1080,13 @@ class TestEditRepo():
quantity_actions = actions[quantity]
if not isinstance(quantity_actions, list):
quantity_actions = [quantity_actions]
has_failure = False
has_message = False
for action in quantity_actions:
assert action['success'] == success
assert ('message' in action) == message
has_failure = has_failure or not action['success']
has_message = has_message or ('message' in action)
assert not has_failure == success
assert has_message == message
def mongo(self, *args, **kwargs):
for calc_id in args:
......@@ -1097,8 +1115,8 @@ class TestEditRepo():
edit_data = dict(
comment='test_edit_props',
references=['http://test', 'http://test2'],
coauthors=[other_test_user.email],
shared_with=[other_test_user.email])
coauthors=[other_test_user.user_id],
shared_with=[other_test_user.user_id])
rv = self.perform_edit(**edit_data, query=dict(upload_id='upload_1'))
result = json.loads(rv.data)
actions = result.get('actions')
......@@ -1150,6 +1168,21 @@ class TestEditRepo():
self.assert_edit(rv, quantity='comment', success=True, message=False)
assert not self.mongo(1, comment='test_edit_verify')
def test_edit_empty_list(self, other_test_user):
rv = self.perform_edit(coauthors=[other_test_user.user_id], query=dict(upload_id='upload_1'))
self.assert_edit(rv, quantity='coauthors', success=True, message=False)
rv = self.perform_edit(coauthors=[], query=dict(upload_id='upload_1'))
self.assert_edit(rv, quantity='coauthors', success=True, message=False)
assert self.mongo(1, coauthors=[])
def test_edit_duplicate_value(self, other_test_user):
rv = self.perform_edit(coauthors=[other_test_user.user_id, other_test_user.user_id], query=dict(upload_id='upload_1'))
self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
def test_edit_uploader_as_coauthor(self, test_user):
rv = self.perform_edit(coauthors=[test_user.user_id], query=dict(upload_id='upload_1'))
self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
def test_edit_ds(self):
rv = self.perform_edit(
datasets=[self.example_dataset.name], query=dict(upload_id='upload_1'))
......@@ -1183,7 +1216,7 @@ class TestEditRepo():
self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
def test_edit_user(self, other_test_user):
rv = self.perform_edit(coauthors=[other_test_user.email], query=dict(upload_id='upload_1'))
rv = self.perform_edit(coauthors=[other_test_user.user_id], query=dict(upload_id='upload_1'))
self.assert_edit(rv, quantity='coauthors', success=True, message=False)
def test_admin_only(self, other_test_user):
......
......@@ -210,17 +210,27 @@ test_users = {
class KeycloakMock:
def __init__(self):
self.id_counter = 2
self.users = dict(**test_users)
def authorize_flask(self, *args, **kwargs):
if 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '):
user_id = request.headers['Authorization'].split(None, 1)[1].strip()
g.oidc_access_token = user_id
g.user = User(**test_users[user_id])
g.user = User(**self.users[user_id])
def add_user(self, user, *args, **kwargs):
self.id_counter += 1
user.user_id = test_user_uuid(self.id_counter)
self.users[user.user_id] = dict(email=user.email, first_name=user.first_name, last_name=user.last_name, user_id=user.user_id)
return None
def get_user(self, user_id=None, email=None):
if user_id is not None:
return User(**test_users[user_id])
return User(**self.users[user_id])
elif email is not None:
for user_id, user_values in test_users.items():
for user_id, user_values in self.users.items():
if user_values['email'] == email:
return User(**user_values)
raise KeyError('Only test user emails are recognized')
......@@ -229,7 +239,7 @@ class KeycloakMock:
def search_user(self, query):
return [
User(**test_user) for test_user in test_users.values()
User(**test_user) for test_user in self.users.values()
if query in ' '.join(test_user.values())]
@property
......@@ -240,9 +250,9 @@ class KeycloakMock:
_keycloak = infrastructure.keycloak
@pytest.fixture(scope='session', autouse=True)
def mocked_keycloak(monkeysession):
monkeysession.setattr('nomad.infrastructure.keycloak', KeycloakMock())
@pytest.fixture(scope='function', autouse=True)
def mocked_keycloak(monkeypatch):
monkeypatch.setattr('nomad.infrastructure.keycloak', KeycloakMock())
@pytest.fixture(scope='function')
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment