diff --git a/nomad/app/v1/models/graph/graph_models.py b/nomad/app/v1/models/graph/graph_models.py index f9eaac851f050b923dbe983b357fd86d555fa2b0..00eb8e2b3e2b94843ca41e8aaefd2f577d498c10 100644 --- a/nomad/app/v1/models/graph/graph_models.py +++ b/nomad/app/v1/models/graph/graph_models.py @@ -20,7 +20,7 @@ from __future__ import annotations from typing import Optional, List, Union, Any, Literal from pydantic import BaseModel, Field, Extra -from nomad.metainfo import Package +from nomad.groups import UserGroupModel from nomad.graph.model import ( RequestConfig, DatasetQuery, @@ -240,6 +240,15 @@ class GraphMetainfo(BaseModel): m_children: MSection +class GraphGroup(mapped(UserGroupModel, owner=GraphUser, members=List[GraphUser])): # type: ignore + m_errors: List[Error] + + +class GraphGroups(BaseModel): + m_errors: List[Error] + m_children: GraphGroup + + class Graph(BaseModel): users: GraphUsers entries: GraphEntries @@ -247,6 +256,7 @@ class Graph(BaseModel): datasets: GraphDatasets search: GraphSearch metainfo: GraphMetainfo + groups: GraphGroups GraphRequest = generate_request_model(Graph) diff --git a/nomad/app/v1/routers/groups.py b/nomad/app/v1/routers/groups.py index ae5361d00bf561f3057222ca5095abbb899310c6..e7a3f34aaed9b7b50fd82ffc03c11fbf2e3edc71 100644 --- a/nomad/app/v1/routers/groups.py +++ b/nomad/app/v1/routers/groups.py @@ -46,7 +46,7 @@ class UserGroupEdit(BaseModel): description=group_name_description, min_length=3, max_length=32, - regex=r'^[a-zA-Z0-9][a-zA-Z0-9 \.\_\-]+[a-zA-Z0-9]$', + regex=r'^[a-zA-Z0-9][a-zA-Z0-9 ._\-]+[a-zA-Z0-9]$', ) members: Optional[Set[str]] = Field( default=None, description=group_members_description diff --git a/nomad/graph/graph_reader.py b/nomad/graph/graph_reader.py index 29ded7015f26658106c778373e182217a1daa4b4..87d40ea078917841885da1288576e6eb056cf689 100644 --- a/nomad/graph/graph_reader.py +++ b/nomad/graph/graph_reader.py @@ -64,7 +64,10 @@ from nomad.graph.model import ( EntryQuery, MetainfoQuery, MetainfoPagination, + UserGroupQuery, + UserGroupPagination, ) +from nomad.groups import UserGroup from nomad.metainfo import ( SubSection, QuantityReference, @@ -101,6 +104,8 @@ class Token: USERS = 'users' DATASET = 'dataset' DATASETS = 'm_datasets' + GROUP = 'group' + GROUPS = 'groups' METAINFO = 'metainfo' SEARCH = 'search' ERROR = 'm_errors' @@ -125,6 +130,14 @@ def dataset_to_pydantic(item): return item.to_json() +def group_to_pydantic(item): + """ + Do NOT optimise this function. + Function names are used to determine the type of the object. + """ + return item.to_json() + + class ArchiveError(Exception): """ An exception raised when an error occurs in the archive. @@ -571,6 +584,9 @@ def _normalise_required( if name in GeneralReader.__USER_ID__ or name == Token.USER or name == Token.USERS: reader_type = UserReader + elif name == Token.GROUP or name == Token.GROUPS: + reader_type = UserGroupReader + can_query = True elif name in GeneralReader.__UPLOAD_ID__: reader_type = UploadReader elif name == Token.UPLOAD or name == Token.UPLOADS: @@ -748,6 +764,8 @@ class GeneralReader: 'writers', 'entry_coauthors', 'user_id', + 'owner', # from UserGroup + 'members', # from UserGroup } # controls the names of fields that are treated as entry id, for those fields, # implicit resolve is supported and explicit resolve does not require an explicit resolve type @@ -894,6 +912,45 @@ class GeneralReader: return user.m_to_dict(with_out_meta=True, include_derived=True) + async def _overwrite_group(self, item: UserGroup): + # todo: this is a quick dirty fix to convert the group object to a dictionary + # todo: it shall be formalised in the future + group_dict = item.to_mongo().to_dict() + group_dict['group_id'] = group_dict.pop('_id', None) + + # to be consistent with the other parts + # all user ids are resolved to user objects + group_dict['owner'] = await self.retrieve_user(group_dict['owner']) + group_dict['members'] = [ + await self.retrieve_user(member) for member in group_dict['members'] + ] + + return group_dict + + async def retrieve_group(self, group_id: str) -> str | dict: + """ + Retrieve the group for the given group id. + Returns a plain dictionary if the group is found, otherwise return the given group id. + """ + + def _retrieve(): + return UserGroup.objects(group_id=group_id).first() + + try: + group: UserGroup = await asyncio.to_thread(_retrieve) + except Exception as e: + self._log(str(e), to_response=False) + return group_id + + if group is None: + self._log( + f'The value {group_id} is not a valid group id.', + error_type=QueryError.NOTFOUND, + ) + return group_id + + return await self._overwrite_group(group) + async def _overwrite_upload(self, item: Upload): plain_dict = orjson.loads(upload_to_pydantic(item).json()) if n_entries := plain_dict.pop('entries', None): @@ -1306,6 +1363,25 @@ class MongoReader(GeneralReader): return config.query.dict(exclude_unset=True), self.datasets.filter(mongo_query) + async def _query_groups(self, config: RequestConfig): + # todo: extend and refine the query + if config.query: + assert isinstance(config.query, UserGroupQuery) + default_query = config.query + else: + default_query = UserGroupQuery(user_id=self.user.user_id) + + # replace shortcut for myself + if default_query.user_id in ('me', None): + default_query.user_id = self.user.user_id + + mongo_query = Q() + if default_query.user_id: + mongo_query &= Q(members=default_query.user_id) + + # todo: maybe it is necessary to further refine the scope based on current user's visibility + return default_query.dict(exclude_unset=True), UserGroup.objects(mongo_query) + async def _normalise( self, mongo_result, config: RequestConfig, transformer: Callable ) -> tuple[dict, PaginationResponse | None]: @@ -1345,6 +1421,8 @@ class MongoReader(GeneralReader): return _item.dataset_id if transformer == entry_to_pydantic: return _item.entry_id + if transformer == group_to_pydantic: + return _item.group_id raise ValueError(f'Should not reach here.') @@ -1370,6 +1448,11 @@ class MongoReader(GeneralReader): v['entry_id']: v for v in [self._overwrite_entry(item) for item in mongo_result] } + elif transformer == group_to_pydantic: + mongo_dict = { + v['group_id']: v + for v in [await self._overwrite_group(item) for item in mongo_result] + } else: raise ValueError(f'Should not reach here.') @@ -1655,6 +1738,9 @@ class MongoReader(GeneralReader): if key == Token.DATASET or key == Token.DATASETS: await offload_func(await self._query_datasets(config), dataset_to_pydantic) return True + if key == Token.GROUP or key == Token.GROUPS: + await offload_func(await self._query_groups(config), group_to_pydantic) + return True if key == Token.USER or key == Token.USERS: await offload_func( ( @@ -1988,42 +2074,50 @@ class UserReader(MongoReader): if user_id == 'me': user_id = self.user.user_id - mongo_query = ( - Q(main_author=user_id) | Q(reviewers=user_id) | Q(coauthors=user_id) - ) - # self.user must have access to the upload - if user_id != self.user.user_id and not self.user.is_admin: - mongo_query &= ( - Q(main_author=self.user.user_id) - | Q(reviewers=self.user.user_id) - | Q(coauthors=self.user.user_id) + if isinstance(target_user := await self.retrieve_user(user_id), str): + # does not exist + self._log( + f'User ID {user_id} does not exist.', error_type=QueryError.NOTFOUND + ) + else: + mongo_query = ( + Q(main_author=user_id) | Q(reviewers=user_id) | Q(coauthors=user_id) ) + # self.user must have access to the upload + if user_id != self.user.user_id and not self.user.is_admin: + mongo_query &= ( + Q(main_author=self.user.user_id) + | Q(reviewers=self.user.user_id) + | Q(coauthors=self.user.user_id) + ) - self.uploads = Upload.objects(mongo_query) - self.entries = Entry.objects(upload_id__in=[v.upload_id for v in self.uploads]) - self.datasets = Dataset.m_def.a_mongo.objects( - dataset_id__in=set( - v for e in self.entries if e.datasets for v in e.datasets + self.uploads = Upload.objects(mongo_query) + self.entries = Entry.objects( + upload_id__in=[v.upload_id for v in self.uploads] + ) + self.datasets = Dataset.m_def.a_mongo.objects( + dataset_id__in=set( + v for e in self.entries if e.datasets for v in e.datasets + ) ) - ) - await self._walk( - GraphNode( - upload_id='__NOT_NEEDED__', - entry_id='__NOT_NEEDED__', - current_path=[], - result_root=response, - ref_result_root=self.global_root, - archive=await self.retrieve_user(user_id), - archive_root=None, - definition=None, - visited_path=set(), - current_depth=0, - reader=self, - ), - self.required_query, - self.global_config, - ) + await self._walk( + GraphNode( + upload_id='__NOT_NEEDED__', + entry_id='__NOT_NEEDED__', + current_path=[], + result_root=response, + ref_result_root=self.global_root, + archive=target_user, + archive_root=None, + definition=None, + visited_path=set(), + current_depth=0, + reader=self, + ), + self.required_query, + self.global_config, + ) self._populate_error_list(response) @@ -2039,7 +2133,62 @@ class UserReader(MongoReader): if config.pagination is not None: raise ConfigError('User reader does not support pagination.') - return MongoReader.validate_config(key, config) + return super().validate_config(key, config) + + +class UserGroupReader(MongoReader): + # noinspection PyMethodOverriding + async def read(self, group_id: str): # type: ignore + response: dict = {} + + if self.global_root is None: + self.global_root = response + has_global_root: bool = False + else: + has_global_root = True + + if isinstance(target_group := await self.retrieve_group(group_id), str): + # does not exist + self._log( + f'Group ID {group_id} does not exist.', error_type=QueryError.NOTFOUND + ) + else: + await self._walk( + GraphNode( + upload_id='__NOT_NEEDED__', + entry_id='__NOT_NEEDED__', + current_path=[], + result_root=response, + ref_result_root=self.global_root, + archive=target_group, + archive_root=None, + definition=None, + visited_path=set(), + current_depth=0, + reader=self, + ), + self.required_query, + self.global_config, + ) + + self._populate_error_list(response) + + if not has_global_root: + self.global_root = None + + return response + + @classmethod + def validate_config(cls, key: str, config: RequestConfig): + try: + if config.query is not None: + config.query = UserGroupQuery.parse_obj(config.query) + if config.pagination is not None: + config.pagination = UserGroupPagination.parse_obj(config.pagination) + except Exception as e: + raise ConfigError(str(e)) + + return super().validate_config(key, config) class FileSystemReader(GeneralReader): @@ -2403,7 +2552,7 @@ class ArchiveReader(ArchiveLikeReader): if required.pop(GeneralReader.__WILDCARD__, None): self._log( - "Wildcard '*' as field name is not supported in archive query as its data is not homogeneous", + "Wildcard '*' as field name is not supported in archive query as its data is not homogeneous.", error_type=QueryError.NOTFOUND, ) @@ -3327,4 +3476,6 @@ __M_SEARCHABLE__: dict = { Token.USERS: UserReader, Token.DATASET: DatasetReader, Token.DATASETS: DatasetReader, + Token.GROUP: UserGroupReader, + Token.GROUPS: UserGroupReader, } diff --git a/nomad/graph/model.py b/nomad/graph/model.py index c726b45536bc65b6fcb0a3bc1da6ab4353d830e7..b4db9892e3ed3eaaf5512550a1da07b1bcf2d2e1 100644 --- a/nomad/graph/model.py +++ b/nomad/graph/model.py @@ -96,6 +96,23 @@ class MetainfoPagination(Pagination): return [] if first == last else result[first:last] +class UserGroupQuery(BaseModel): + # todo: define query specifics + user_id: str = Field(None) + + +class UserGroupPagination(Pagination): + # todo: refine pagination logic + def order_result(self, result): + if self.order_by is None: + return result + + prefix: str = '-' if self.order == Direction.desc else '+' + order_list: list = [f'{prefix}{self.order_by}', 'group_id'] + + return result.order_by(*order_list) + + class DirectiveType(Enum): plain = 'plain' resolved = 'resolved' @@ -253,12 +270,13 @@ class RequestConfig(BaseModel): ) pagination: Union[ dict, - RawDirPagination, DatasetPagination, - UploadProcDataPagination, - MetadataPagination, EntryProcDataPagination, + MetadataPagination, MetainfoPagination, + RawDirPagination, + UploadProcDataPagination, + UserGroupPagination, ] = Field( None, description=""" @@ -269,7 +287,13 @@ class RequestConfig(BaseModel): """, ) query: Union[ - dict, DatasetQuery, UploadProcDataQuery, Metadata, EntryQuery, MetainfoQuery + dict, + DatasetQuery, + EntryQuery, + Metadata, + MetainfoQuery, + UploadProcDataQuery, + UserGroupQuery, ] = Field( None, description=""" @@ -278,7 +302,8 @@ class RequestConfig(BaseModel): It can only be defined at the root levels including Token.ENTRIES, Token.UPLOADS and 'm_datasets'. For Token.ENTRIES, the query is used in elastic search. It must comply with `WithQuery`. For Token.UPLOADS, the query is used in mongo search. It must comply with `UploadProcDataQuery`. - For 'm_datasets', the query is used in mongo search. It must comply with `DatasetQuery`. + For Token.DATASETS, the query is used in mongo search. It must comply with `DatasetQuery`. + For Token.GROUPS, the query is used in mongo search. It must comply with `UserGroupQuery`. """, ) diff --git a/nomad/groups.py b/nomad/groups.py index c0637226a440dbdcf019410791ffd80f381db9af..d002d66ee81a240208ee45793c469888423b12cc 100644 --- a/nomad/groups.py +++ b/nomad/groups.py @@ -15,16 +15,31 @@ # See the License for the specific language governing permissions and # limitations under the License. # + +from __future__ import annotations + import operator from functools import reduce from typing import Iterable, Optional, Union from mongoengine import Document, ListField, StringField from mongoengine.queryset.visitor import Q +from pydantic import BaseModel, Field from nomad.utils import create_uuid +# todo: this is just my quick fix to get things glued together +# todo: UserGroup shall be a MSection just like User so that +# todo: mongo document and pydantic model can be generated from the same source +# todo: see also dataset +class UserGroupModel(BaseModel): + group_id: str = Field() + group_name: Optional[str] = Field() + owner: str = Field() + members: Optional[list[str]] = Field() + + class UserGroup(Document): """ A group of users. One user is the owner, all others are members. diff --git a/tests/graph/test_graph_reader.py b/tests/graph/test_graph_reader.py index 091c65c344b637cce6051abadb0bb823dd4a4c2c..e62ab1e4581c59699dd65e5266cbc7b2a3d976e0 100644 --- a/tests/graph/test_graph_reader.py +++ b/tests/graph/test_graph_reader.py @@ -97,16 +97,18 @@ user_dict = { } -# noinspection SpellCheckingInspection,DuplicatedCode -def test_remote_reference(json_dict, example_data_with_reference, user1): - def increment(): - n = 0 - while True: - n += 1 - yield n +def increment(): + n = 0 + while True: + n += 1 + yield n + - counter = increment() +counter = increment() + +# noinspection SpellCheckingInspection,DuplicatedCode +def test_remote_reference(json_dict, example_data_with_reference, user1): def __user_print(msg, required, *, result: dict = None): with UserReader(required, user=user1) as reader: if result: @@ -2117,15 +2119,103 @@ def test_remote_reference(json_dict, example_data_with_reference, user1): # noinspection DuplicatedCode,SpellCheckingInspection -def test_general_reader(json_dict, example_data_with_reference, user1): - def increment(): - n = 0 - while True: - n += 1 - yield n +def test_group_reader(groups_function, user1): + def __ge_print(msg, required, *, to_file: bool = False, result: dict = None): + with MongoReader(required, user=user1) as reader: + if result: + assert_dict(reader.sync_read(), result) + else: + rprint(f'\n\nExample: {next(counter)} -> {msg}:') + rprint(required) + if not to_file: + rprint('output:') + rprint(reader.sync_read()) + else: + with open('archive_reader_test.json', 'w') as f: + f.write(json.dumps(reader.sync_read())) + + __ge_print( + 'general start from group', + { + Token.GROUP: { + 'GGGGGGGGGGGGGGGGGGGG14': '*', + } + }, + result={ + 'group': { + 'GGGGGGGGGGGGGGGGGGGG14': { + 'group_id': 'GGGGGGGGGGGGGGGGGGGG14', + 'group_name': 'Group 14', + 'owner': { + 'name': 'Sheldon Cooper', + 'first_name': 'Sheldon', + 'last_name': 'Cooper', + 'email': 'sheldon.cooper@nomad-coe.eu', + 'user_id': '00000000-0000-0000-0000-000000000001', + 'username': 'scooper', + 'is_admin': False, + 'is_oasis_admin': True, + }, + 'members': [ + { + 'name': 'Rajesh Koothrappali', + 'first_name': 'Rajesh', + 'last_name': 'Koothrappali', + 'email': 'rajesh.koothrappali@nomad-fairdi.tests.de', + 'user_id': '00000000-0000-0000-0000-000000000004', + 'username': 'rkoothrappali', + 'is_admin': False, + } + ], + } + } + }, + ) + __ge_print( + 'general start from group', + { + Token.GROUP: { + 'GGGGGGGGGGGGGGGGGGGG14': { + 'owner': { + 'email': '*', + }, + }, + } + }, + result={ + 'group': { + 'GGGGGGGGGGGGGGGGGGGG14': { + 'owner': {'email': 'sheldon.cooper@nomad-coe.eu'} + } + } + }, + ) + __ge_print( + 'general start from group with query', + { + Token.GROUP: { + 'm_request': { + 'query': {'user_id': '00000000-0000-0000-0000-000000000004'} + }, + '*': { + 'owner': { + 'email': '*', + }, + }, + } + }, + result={ + 'group': { + 'GGGGGGGGGGGGGGGGGGGG14': { + 'owner': {'email': 'sheldon.cooper@nomad-coe.eu'} + }, + } + }, + ) - counter = increment() +# noinspection DuplicatedCode,SpellCheckingInspection +def test_general_reader(json_dict, example_data_with_reference, user1): def __ge_print(msg, required, *, to_file: bool = False, result: dict = None): with MongoReader(required, user=user1) as reader: if result: @@ -2513,14 +2603,6 @@ def test_general_reader(json_dict, example_data_with_reference, user1): # noinspection DuplicatedCode,SpellCheckingInspection def test_metainfo_reader(mongo_infra, user1): - def increment(): - n = 0 - while True: - n += 1 - yield n - - counter = increment() - def __ge_print(msg, required, *, to_file: bool = False, result: dict = None): with MongoReader(required, user=user1) as reader: if result: @@ -3012,14 +3094,6 @@ def test_metainfo_reader(mongo_infra, user1): # noinspection DuplicatedCode,SpellCheckingInspection def test_general_reader_search(json_dict, example_data_with_reference, user1): - def increment(): - n = 0 - while True: - n += 1 - yield n - - counter = increment() - def __ge_print(msg, required, *, to_file: bool = False, result: dict = None): with MongoReader(required, user=user1) as reader: if result: @@ -3162,14 +3236,6 @@ data: def test_custom_schema_archive_and_definition(user1, custom_data): - def increment(): - n = 0 - while True: - n += 1 - yield n - - counter = increment() - def __entry_print(msg, required, *, to_file: bool = False, result: dict = None): with EntryReader(required, user=user1) as reader: response = reader.sync_read('id_example')