diff --git a/cap/config.py b/cap/config.py index 683dd5c3bd..aecb833753 100644 --- a/cap/config.py +++ b/cap/config.py @@ -517,6 +517,12 @@ def _(x): 'application/basic+json': ( 'cap.modules.records.serializers' ':basic_json_v1_search' ), + 'application/marcxml+xml': ( + 'cap.modules.records.serializers' ':record_xml_v1_search' + ), + 'application/csv': ( + 'cap.modules.records.serializers' ':record_csv_v1_search' + ), }, 'read_permission_factory_imp': check_oauth2_scope( lambda record: ReadRecordPermission(record).can(), write_scope.id @@ -761,10 +767,16 @@ def _(x): 'application/basic+json': ( 'cap.modules.records.serializers' ':basic_json_v1_search' ), + 'application/marcxml+xml': ( + 'cap.modules.records.serializers' ':record_xml_v1_search' + ), + 'application/csv': ( + 'cap.modules.records.serializers' ':record_csv_v1_search' + ), }, 'files_serializers': { 'application/json': ( - 'cap.modules.deposit.serializers:files_response' + 'invenio_deposit.serializers:json_file_response' ), }, 'search_class': 'cap.modules.deposit.search:CAPDepositSearch', @@ -956,3 +968,8 @@ def get_cms_stats_questionnaire_contacts(): #: Enable Prometheus flask exporter PROMETHEUS_ENABLE_EXPORTER_FLASK = False + +#: CERN E-groups +CERN_EGROUP_ACCOUNT_USERNAME = "CHANGE_ME" +CERN_EGROUP_ACCOUNT_PASSWORD = "CHANGE_ME" +CERN_EGROUP_ACCOUNT_DEFAULT_OWNER_ID = "CHANGE_ME_NUMBER" diff --git a/cap/modules/deposit/__init__.py b/cap/modules/deposit/__init__.py index 0223f9c704..81b6d9141f 100644 --- a/cap/modules/deposit/__init__.py +++ b/cap/modules/deposit/__init__.py @@ -25,5 +25,3 @@ """CAP Deposit module.""" from __future__ import absolute_import, print_function - -from .cli import create_deposit # noqa diff --git a/cap/modules/deposit/api.py b/cap/modules/deposit/api.py index 44d065d7f5..6876adf6e5 100644 --- a/cap/modules/deposit/api.py +++ b/cap/modules/deposit/api.py @@ -53,10 +53,33 @@ from sqlalchemy.orm.exc import NoResultFound from werkzeug.local import LocalProxy -from cap.modules.deposit.errors import DisconnectWebhookError, FileUploadError +from cap.modules.deposit.egroups import CERNEgroupMixin +from cap.modules.deposit.errors import ( + DepositValidationError, + DisconnectWebhookError, + FileUploadError, + ReviewError, + UniqueRequiredValidationError, + UpdateDepositPermissionsError, +) +from cap.modules.deposit.fetchers import cap_deposit_fetcher from cap.modules.deposit.loaders import get_val_from_path +from cap.modules.deposit.minters import cap_deposit_minter +from cap.modules.deposit.permissions import ( + AdminDepositPermission, + CloneDepositPermission, + DepositAdminActionNeed, + DepositReadActionNeed, + DepositUpdateActionNeed, + ReviewDepositPermission, + UpdateDepositPermission, +) +from cap.modules.deposit.review import Reviewable from cap.modules.deposit.utils import perform_copying_fields -from cap.modules.deposit.validators import NoRequiredValidator +from cap.modules.deposit.validators import ( + NoRequiredValidator, + get_custom_validator, +) from cap.modules.experiments.permissions import exp_need_factory from cap.modules.records.api import CAPRecord from cap.modules.records.errors import get_error_path @@ -77,26 +100,6 @@ get_existing_or_register_user, ) -from .errors import ( - DepositValidationError, - ReviewError, - UniqueRequiredValidationError, - UpdateDepositPermissionsError, -) -from .fetchers import cap_deposit_fetcher -from .minters import cap_deposit_minter -from .permissions import ( - AdminDepositPermission, - CloneDepositPermission, - DepositAdminActionNeed, - DepositReadActionNeed, - DepositUpdateActionNeed, - ReviewDepositPermission, - UpdateDepositPermission, -) -from .review import Reviewable -from .validators import get_custom_validator - _datastore = LocalProxy(lambda: current_app.extensions["security"].datastore) PRESERVE_FIELDS = ( @@ -104,6 +107,7 @@ "_buckets", "_files", "_review", + "_egroups", "_experiment", "_access", "_user_edited", @@ -134,7 +138,7 @@ def DEPOSIT_ACTIONS_NEEDS(id): } -class CAPDeposit(Deposit, Reviewable): +class CAPDeposit(Deposit, Reviewable, CERNEgroupMixin): """Define API for changing deposit state.""" deposit_fetcher = staticmethod(cap_deposit_fetcher) diff --git a/cap/modules/deposit/cli.py b/cap/modules/deposit/cli.py index b1c9eda543..6c88d60a25 100644 --- a/cap/modules/deposit/cli.py +++ b/cap/modules/deposit/cli.py @@ -25,9 +25,9 @@ from __future__ import absolute_import, print_function -import os import copy import json +import os import uuid from datetime import datetime @@ -42,13 +42,16 @@ from cap.modules.deposit.api import CAPDeposit from cap.modules.deposit.fetchers import cap_deposit_fetcher from cap.modules.deposit.minters import cap_deposit_minter +from cap.modules.deposit.utils import add_read_permission_for_egroup from cap.modules.fixtures.cli import fixtures -from cap.modules.user.utils import get_existing_or_register_user, \ - get_existing_or_register_role -from cap.modules.schemas.resolvers import resolve_schema_by_url, \ - schema_name_to_url - -from .utils import add_read_permission_for_egroup +from cap.modules.schemas.resolvers import ( + resolve_schema_by_url, + schema_name_to_url, +) +from cap.modules.user.utils import ( + get_existing_or_register_role, + get_existing_or_register_user, +) @fixtures.command('add') @@ -80,7 +83,8 @@ def add(file_path, schema, version, egroup, usermail, limit): click.secho( 'Draft with id {} already exist!'.format(pid_value), - fg='red') + fg='red', + ) except PIDDoesNotExistError: record_uuid = uuid.uuid4() @@ -91,29 +95,45 @@ def add(file_path, schema, version, egroup, usermail, limit): if egroup: add_read_permission_for_egroup(deposit, egroup) - click.secho('Draft {} added.'.format(pid.pid_value), - fg='green') + click.secho('Draft {} added.'.format(pid.pid_value), fg='green') db.session.commit() @fixtures.command('create-deposit') -@click.option('--file', '-f', - type=click.Path(exists=True), - required=True, - help='JSON data file') -@click.option('--ana', '-a', - help='Type of analysis',) -@click.option('--role', '-r', - 'roles', multiple=True, - help='Role with access to the record') -@click.option('--user', '-u', - 'users', multiple=True, - help='User with access to the record') -@click.option('--owner', '-o', - help='Owner of the record') -@click.option('--save-errors-to', '-e', 'save_errors', - help="Provide a filename, that wrong records will be saved to.") +@click.option( + '--file', + '-f', + type=click.Path(exists=True), + required=True, + help='JSON data file', +) +@click.option( + '--ana', + '-a', + help='Type of analysis', +) +@click.option( + '--role', + '-r', + 'roles', + multiple=True, + help='Role with access to the record', +) +@click.option( + '--user', + '-u', + 'users', + multiple=True, + help='User with access to the record', +) +@click.option('--owner', '-o', help='Owner of the record') +@click.option( + '--save-errors-to', + '-e', + 'save_errors', + help="Provide a filename, that wrong records will be saved to.", +) @with_appcontext def create_deposit(file, ana, roles, users, owner, save_errors): """Create a new deposit through the CLI. @@ -146,8 +166,7 @@ def save_errors_to_json(save_errors, errors): """Saves the wrong records to a specified file.""" timestamp = datetime.now().strftime("%d-%b-%Y-%H:%M:%S") wrong_records_path = os.path.join( - os.getcwd(), - f'{save_errors}_errors_{timestamp}.json' + os.getcwd(), f'{save_errors}_errors_{timestamp}.json' ) with open(wrong_records_path, 'w') as _json: @@ -165,13 +184,18 @@ def check_and_update_data_with_schema(data, ana): if not schema and not ana: click.secho( 'You need to provide the --ana/-a parameter OR ' - 'add the $schema field in your JSON', fg='red') + 'add the $schema field in your JSON', + fg='red', + ) return False try: if schema: if ana: - click.secho("Your data already provide a $schema, --ana will not be used.") # noqa + click.secho( + "Your data already provide a $schema," + " --ana will not be used." + ) resolve_schema_by_url(schema) elif ana: data['$schema'] = schema_name_to_url(ana) @@ -201,12 +225,14 @@ def create_deposit_with_permissions(data, roles, users, owner, ana, errors): for role in roles: _role = get_existing_or_register_role(role.strip()) deposit._add_egroup_permissions( - _role, ['deposit-read'], db.session) + _role, ['deposit-read'], db.session + ) if users: for user in users: _user = get_existing_or_register_user(user.strip()) deposit._add_user_permissions( - _user, ['deposit-read'], db.session) + _user, ['deposit-read'], db.session + ) deposit.commit() except ValidationError as err: @@ -215,4 +241,6 @@ def create_deposit_with_permissions(data, roles, users, owner, ana, errors): return db.session.commit() - click.secho(f"Created deposit with id: {deposit['_deposit']['id']}", fg='green') # noqa + click.secho( + f"Created deposit with id: {deposit['_deposit']['id']}", fg='green' + ) # noqa diff --git a/cap/modules/deposit/egroups.py b/cap/modules/deposit/egroups.py new file mode 100644 index 0000000000..0a93b1499d --- /dev/null +++ b/cap/modules/deposit/egroups.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2016 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. +"""Serializer for deposit reviews.""" + +from __future__ import absolute_import, print_function + +from flask import request +from invenio_deposit.api import index +from invenio_deposit.utils import mark_as_action +from invenio_records_rest.errors import InvalidDataRESTError +from marshmallow import Schema, fields, validate + +from cap.modules.deposit.errors import EgroupError as EgroupResponseError +from cap.modules.deposit.permissions import AdminDepositPermission +from cap.modules.services.utils.cern_egroups import ( + EgroupError, + addMembers, + createGroup, +) + + +class MemberPayload(Schema): + type = fields.Str( + validate=validate.OneOf(["add-egroup", "remove-egroup", "add-members"]), + required=True, + ) + value = fields.Str() + + +class EgroupPayload(Schema): + """Schema for deposit review.""" + + type = fields.Str( + validate=validate.OneOf(["add-egroup", "remove-egroup", "add-members"]), + required=True, + ) + name = fields.Str(required=True) + description = fields.Str() + members = fields.Nested(MemberPayload, many=True) + + +class CERNEgroupMixin(object): + """Methods for review schema.""" + + @index + @mark_as_action + def egroups(self, pid, *args, **kwargs): + """Egroups actions for a deposit. + + Adds egroups assosiated to the deposit. + """ + with AdminDepositPermission(self).require(403): + if self.schema_egroups_enabled(): + data = request.get_json() + if data is None: + raise InvalidDataRESTError() + + try: + if data.get("type") == "add-egroup": + self.create_egroup(data) + elif data.get("type") == "remove-egroup": + self.remove_egroup(data) + elif data.get("type") == "add-members": + self.add_group_members(data) + + self.commit() + except EgroupError as err: + desc = err.args[0] if len(err.args) else "" + raise EgroupResponseError(description=desc) + else: + raise EgroupResponseError(None) + + return self + + def schema_egroups_enabled(self): + config = self.schema.config + + if config: + return config.get('egroups', False) + return False + + def create_egroup(self, data): + name = data.get("name") + description = data.get("description", "-") + + _egroups = self.get("_egroups", []) + for grp in _egroups: + if grp.get("name") == name: + raise EgroupError(f"E-group '{name}' already linked") + + createGroup(name, description) + group = {"name": name, "description": description} + + _egroups.append(group) + self["_egroups"] = _egroups + + def remove_egroup(self, data): + name = data.get("name") + + _egroups = self.get("_egroups", []) + for grp in _egroups: + if grp.get("name") == name: + del grp + + self["_egroups"] = _egroups + + def add_group_members(self, data): + name = data.get("name") + members = data.get("members", []) + + _egroups = self.get("_egroups", []) + for grp in _egroups: + if grp.get("name") == name: + raise EgroupError() + + addMembers(name, members) diff --git a/cap/modules/deposit/errors.py b/cap/modules/deposit/errors.py index d587d6b10b..9ab7ce3948 100644 --- a/cap/modules/deposit/errors.py +++ b/cap/modules/deposit/errors.py @@ -121,6 +121,18 @@ def __init__(self, description, **kwargs): self.description = description or 'Review is not a possible action.' +class EgroupError(RESTException): + """Exception during review for analysis.""" + + code = 400 + + def __init__(self, description, **kwargs): + """Initialize exception.""" + super().__init__(**kwargs) + + self.description = description or 'Egroup action is not a possible.' + + class ReviewValidationError(RESTValidationError): """Review validation error exception.""" diff --git a/cap/modules/deposit/ext.py b/cap/modules/deposit/ext.py index de13c07024..17e9b13c89 100644 --- a/cap/modules/deposit/ext.py +++ b/cap/modules/deposit/ext.py @@ -1,10 +1,13 @@ """Initialize extension.""" from __future__ import absolute_import, print_function + import json from invenio_files_rest.views import blueprint as files_blueprint from invenio_indexer.signals import before_record_index + +from cap.modules.deposit.cli import add, create_deposit from cap.modules.deposit.utils import fix_bucket_links, prepare_record @@ -24,11 +27,13 @@ def update_file_links(response): try: if response.content_type == 'application/json': resp_json = json.loads(response.data) - response.data = json.dumps( - fix_bucket_links(resp_json)) + response.data = json.dumps(fix_bucket_links(resp_json)) finally: return response before_record_index.connect(prepare_record, sender=app) + app.cli.add_command(add) + app.cli.add_command(create_deposit) + app.extensions['cap_deposit'] = self diff --git a/cap/modules/deposit/links.py b/cap/modules/deposit/links.py index 00a9ca8455..b08c54007e 100644 --- a/cap/modules/deposit/links.py +++ b/cap/modules/deposit/links.py @@ -30,11 +30,10 @@ from flask import current_app, request from invenio_records_files.links import default_bucket_link_factory +from cap.modules.deposit.api import CAPDeposit +from cap.modules.deposit.utils import extract_actions_from_class from cap.modules.records.utils import api_url_for, url_to_api_url -from .api import CAPDeposit -from .utils import extract_actions_from_class - @cached(LRUCache(maxsize=1024), key=lambda pid, **kwargs: hashkey(str(pid))) def links_factory(pid, record=None, record_hit=None, **kwargs): @@ -46,7 +45,7 @@ def links_factory(pid, record=None, record_hit=None, **kwargs): host=request.host, scheme=request.scheme, pid_value=pid.pid_value, - ) + ), } try: @@ -55,10 +54,11 @@ def links_factory(pid, record=None, record_hit=None, **kwargs): links['bucket'] = url_to_api_url(bucket_link) except Exception: current_app.logger.info( - f'Bucket link generation error for deposit: {pid.pid_value}') + f'Bucket link generation error for deposit: {pid.pid_value}' + ) for action in extract_actions_from_class(CAPDeposit): - if action != "review": + if action not in ["review", "egroups"]: links[action] = api_url_for('depid_actions', pid, action=action) return links diff --git a/cap/modules/deposit/minters.py b/cap/modules/deposit/minters.py index 7d51233581..e2b13c210b 100644 --- a/cap/modules/deposit/minters.py +++ b/cap/modules/deposit/minters.py @@ -30,13 +30,15 @@ from invenio_pidstore.models import PersistentIdentifier, PIDStatus +from cap.modules.deposit.utils import ( + generate_auto_incremental_pid, + set_copy_to_attr, +) from cap.modules.schemas.resolvers import ( resolve_schema_by_name_and_version, resolve_schema_by_url, ) -from .utils import generate_auto_incremental_pid, set_copy_to_attr - def cap_deposit_minter(record_uuid, data, schema=None): """Mint deposit's identifier.""" diff --git a/cap/modules/deposit/permissions.py b/cap/modules/deposit/permissions.py index 1579866099..dd2ed60d9d 100644 --- a/cap/modules/deposit/permissions.py +++ b/cap/modules/deposit/permissions.py @@ -33,6 +33,7 @@ from invenio_records_files.models import RecordsBuckets from sqlalchemy.orm.exc import NoResultFound +from cap.modules.deposit.errors import WrongJSONSchemaError from cap.modules.experiments.permissions import cms_pag_convener_action from cap.modules.records.permissions import RecordFilesPermission from cap.modules.schemas.models import Schema @@ -48,8 +49,6 @@ ) from cap.modules.schemas.resolvers import resolve_schema_by_url -from .errors import WrongJSONSchemaError - DepositReadActionNeed = partial(ParameterizedActionNeed, 'deposit-read') """Action need for reading a record.""" diff --git a/cap/modules/deposit/resolvers.py b/cap/modules/deposit/resolvers.py new file mode 100644 index 0000000000..b93d456faf --- /dev/null +++ b/cap/modules/deposit/resolvers.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2016, 2017 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. +"""Resolver for JSON Schemas.""" + +from __future__ import absolute_import, print_function + +import jsonresolver +from invenio_pidstore.resolver import Resolver + +# from cap.modules.deposit.api import CAPDeposit +# from cap.config import _PID + + +# @jsonresolver.route(f'/api/deposits/<{_PID}:pid_value>', +@jsonresolver.route( + '/api/deposits/', host='analysispreservation.cern.ch' +) +def resolve_api(pid_value): + """Resolve CAP JSON schemas.""" + return resolve_by_deposit_id(pid_value) + + +def resolve_by_deposit_id(depid): + from cap.modules.deposit.api import CAPDeposit + from cap.modules.records.serializers import record_metadata_json_v1 + + resolver = Resolver(pid_type='depid', object_type='rec', getter=lambda x: x) + + dep, rec_uuid = resolver.resolve(depid) + rec = CAPDeposit.get_record(rec_uuid) + + data = record_metadata_json_v1.transform_record(dep, rec) + return data diff --git a/cap/modules/deposit/review.py b/cap/modules/deposit/review.py index e1dcf67695..a5e80acf90 100644 --- a/cap/modules/deposit/review.py +++ b/cap/modules/deposit/review.py @@ -25,19 +25,22 @@ from __future__ import absolute_import, print_function -from marshmallow import Schema, fields, validate - import uuid + from flask_login import current_user -from .errors import ReviewError, ReviewValidationError +from marshmallow import Schema, fields, validate + +from cap.modules.deposit.errors import ReviewError, ReviewValidationError from cap.modules.user.utils import get_user_email_by_id class ReviewSchema(Schema): """Schema for deposit review.""" - type = fields.Str(validate=validate.OneOf( - ["approved", "request_changes", "declined"]), required=True) + type = fields.Str( + validate=validate.OneOf(["approved", "request_changes", "declined"]), + required=True, + ) body = fields.Str() id = fields.Str(required=True) reviewer = fields.Method('get_reviewer', dump_only=True) @@ -51,8 +54,10 @@ def get_reviewer(self, obj): class ReviewCreatePayload(Schema): """Schema for deposit review.""" - type = fields.Str(validate=validate.OneOf( - ["approved", "request_changes", "declined"]), required=True) + type = fields.Str( + validate=validate.OneOf(["approved", "request_changes", "declined"]), + required=True, + ) body = fields.Str() @@ -60,8 +65,9 @@ class ReviewUpdatePayload(Schema): """Schema for deposit review.""" id = fields.Str(required=True) - action = fields.Str(validate=validate.OneOf( - ["comment", "delete", "resolve"]), required=True) + action = fields.Str( + validate=validate.OneOf(["comment", "delete", "resolve"]), required=True + ) comments = fields.Str() diff --git a/cap/modules/deposit/serializers/__init__.py b/cap/modules/deposit/serializers/__init__.py index 1723d9ea18..7675522379 100644 --- a/cap/modules/deposit/serializers/__init__.py +++ b/cap/modules/deposit/serializers/__init__.py @@ -25,12 +25,17 @@ from __future__ import absolute_import, print_function -from invenio_deposit.serializers import json_file_response -from invenio_records_rest.serializers.response import (record_responsify, - search_responsify) +from invenio_records_rest.serializers.response import ( + record_responsify, + search_responsify, +) -from .json import DepositSerializer -from .schemas.json import DepositSchema, DepositFormSchema, DepositSearchSchema +from cap.modules.deposit.serializers.json import DepositSerializer +from cap.modules.deposit.serializers.schemas.json import ( + DepositFormSchema, + DepositSchema, + DepositSearchSchema, +) # Serializers # =========== @@ -42,14 +47,12 @@ # Records-REST serializers # ======================== # JSON record serializer for individual records. -deposit_json_v1_response = record_responsify(deposit_json_v1, - 'application/json') -deposit_form_json_v1_response = record_responsify(deposit_form_json_v1, - 'application/json') -deposit_json_v1_search = search_responsify(deposit_search_json_v1, - 'application/json') - -# Files-REST serializers -# ======================== -# JSON Files serializers for deposit files -files_response = json_file_response +deposit_json_v1_response = record_responsify( + deposit_json_v1, 'application/json' +) +deposit_form_json_v1_response = record_responsify( + deposit_form_json_v1, 'application/json' +) +deposit_json_v1_search = search_responsify( + deposit_search_json_v1, 'application/json' +) diff --git a/cap/modules/deposit/serializers/schemas/json.py b/cap/modules/deposit/serializers/schemas/json.py index 7b882a16e2..83c07d4f03 100644 --- a/cap/modules/deposit/serializers/schemas/json.py +++ b/cap/modules/deposit/serializers/schemas/json.py @@ -100,7 +100,7 @@ def pre_process(self, data): @post_dump def remove_skip_values(self, data): # maybe we should add 'x_cap_permissions' and 'user_schema_permissions' - keys = ["can_review", "review", "x_cap_permission"] + keys = ["can_review", "review", "x_cap_permission", "egroups"] for key in keys: if data.get(key, "") is None: @@ -116,7 +116,7 @@ def remove_skip_values(self, data): can_admin = fields.Method("can_user_admin", dump_only=True) can_review = fields.Method("can_user_review", dump_only=True) review = fields.Method("get_review", dump_only=True) - links = fields.Method("get_links_with_review", dump_only=True) + links = fields.Method("get_links", dump_only=True) x_cap_permission = fields.Dict(attribute="x-cap-permission", dump_only=True) def get_webhooks(self, obj): @@ -177,12 +177,15 @@ def get_review(self, obj): else: return None - def get_links_with_review(self, obj): + def get_links(self, obj): links = obj["links"] if obj["deposit"].schema_is_reviewable(): links["review"] = links["publish"].replace("publish", "review") + if obj["deposit"].schema_egroups_enabled(): + links["egroups"] = links["publish"].replace("publish", "egroups") + return links def can_user_update(self, obj): diff --git a/cap/modules/deposit/validators/__init__.py b/cap/modules/deposit/validators/__init__.py index f97306e239..062d7704b8 100644 --- a/cap/modules/deposit/validators/__init__.py +++ b/cap/modules/deposit/validators/__init__.py @@ -31,6 +31,7 @@ from jsonschema._utils import load_schema from jsonschema.validators import Draft7Validator, create, extend +from cap.modules.deposit.validators.services import fetch_data_from_url from cap.modules.deposit.validators.users import ( find_field_copy, validate_field_schema_editing, @@ -49,6 +50,7 @@ deposit_validators['x-validate-unique-cadi'] = validate_unique_cadi # deposit_validators['x-validate-cadi-id'] = validate_cadi_id +deposit_validators['x-cap-fetch'] = fetch_data_from_url DepositValidator = extend(Draft7Validator, validators=deposit_validators) NoRequiredValidator = extend(DepositValidator, {'required': None}) diff --git a/cap/modules/deposit/validators/services.py b/cap/modules/deposit/validators/services.py new file mode 100644 index 0000000000..cdfde0210a --- /dev/null +++ b/cap/modules/deposit/validators/services.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2016 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. +"""Deposit validators.""" + +import re + +from flask import current_app +from jsonref import JsonRef +from jsonresolver import JSONResolver +from jsonresolver.contrib.jsonref import json_loader_factory +from jsonschema.exceptions import ValidationError + +# from cap.modules.deposit.validators import DepositValidator +from jsonschema.validators import Draft7Validator + +json_resolver = JSONResolver(plugins=['cap.modules.deposit.resolvers']) +loader_cls = json_loader_factory(json_resolver) +loader = loader_cls(cache_results=False) + + +SERVICE_URLS_MAPPING = {"cap_deposit": {"urlRegex": ""}} + + +def find_field_copy(validator, value, instance, schema, **kwargs): + yield ValidationError("x-cap-copy") + + +# Validate for schema field permissions +def fetch_data_from_url(validator, value, instance, schema): + # { + # "type": "object", + # "properties": { + # "url/$ref": { + # "type": "string", + # "pattern": "https://analysis.ce.ch/{/d}" + # }, + # "display_data": { "type": "string"}, + # "data": { + # "type": "object" + # } + # } + # } + + instance_url = instance.get("url") + instance_data = instance.get("data") + + if instance_url is None: + return ValidationError("Not a valid URL to fetch") + + # if "data" already part of the instance, DO NOT fetch + if instance_data: + # maybe validate this data against the schema + return + + # 1. Fetch data from URL + data = {'$ref': instance_url} + try: + fetched_data = JsonRef.replace_refs(data, loader=loader) + fetched_data = fetched_data.copy() + except Exception: + return ValidationError( + "Either response is not in JSON format OR it failed" + ) + + # 2. Serialize `fetched_data` to desired format + response_data_path = value.get('response_data_path') + if response_data_path: + fetched_data = parse_fetched_data(fetched_data) + + # 3. Validate serialized-fetched data. Pass to instance if valid + if value.get("schema_url"): + schema_to_validate = {"$ref": value.get("schema_url")} + else: + schema_to_validate = value.get('schema', {"type": "object"}) + + validate_errors = validate_data(schema_to_validate, fetched_data) + + if any(validate_errors): + yield ValidationError( + "Data are not valid against the required JSON schema" + ) + + # 4. If all correct assign final fetched data into instance "data" + instance["data"] = fetched_data + + +def is_valid_url(url_pattern, url): + return re.match(url_pattern, url) is not None + + +def parse_fetched_data(data): + return data + + +def validate_data(schema, data): + if not isinstance(schema, dict): + schema = {"$ref": schema} + + resolver = current_app.extensions[ + "invenio-records" + ].ref_resolver_cls.from_schema(schema) + validator = Draft7Validator(schema, resolver=resolver) + + errors = validator.iter_errors(data) + + return errors diff --git a/cap/modules/records/serializers/__init__.py b/cap/modules/records/serializers/__init__.py index fc411c29ae..f641b1be7a 100644 --- a/cap/modules/records/serializers/__init__.py +++ b/cap/modules/records/serializers/__init__.py @@ -25,18 +25,50 @@ from __future__ import absolute_import, print_function -from invenio_records_rest.serializers.response import (record_responsify, - search_responsify) +from invenio_records_rest.serializers.response import ( + record_responsify, + search_responsify, +) -from .json import RecordSerializer, CAPJSONSerializer -from .schemas.json import (BasicDepositSchema, PermissionsDepositSchema, - RecordFormSchema, RecordSchema, - RepositoriesDepositSchema) +from cap.modules.records.serializers.author_xml import AuthorXMLSerializer +from cap.modules.records.serializers.csv import CSVSerializer +from cap.modules.records.serializers.json import ( + CAPJSONSerializer, + RecordSerializer, +) +from cap.modules.records.serializers.schemas.common import ( + CommonRecordMetadataSchema, +) +from cap.modules.records.serializers.schemas.json import ( + BasicDepositSchema, + PermissionsDepositSchema, + RecordFormSchema, + RecordSchema, + RepositoriesDepositSchema, +) # Serializers # =========== # CAP JSON serializer version 1.0.0 +record_metadata_json_v1 = RecordSerializer(CommonRecordMetadataSchema) record_json_v1 = RecordSerializer(RecordSchema) +record_csv_v1 = CSVSerializer( + RecordSchema, + csv_included_fields=[ + "metadata_name", + "metadata_surname", + "metadata_institution", + ], +) +record_xml_v1 = AuthorXMLSerializer( + RecordSchema, + csv_included_fields=[ + "metadata_name", + "metadata_surname", + "metadata_institution", + ], +) + record_form_json_v1 = RecordSerializer(RecordFormSchema) basic_json_v1 = CAPJSONSerializer(BasicDepositSchema) @@ -47,16 +79,27 @@ # ======================== # JSON record serializer for individual records. record_json_v1_response = record_responsify(record_json_v1, 'application/json') -record_form_json_v1_response = record_responsify(record_form_json_v1, - 'application/json') +record_form_json_v1_response = record_responsify( + record_form_json_v1, 'application/json' +) record_json_v1_search = search_responsify(record_json_v1, 'application/json') -basic_json_v1_response = record_responsify(basic_json_v1, - 'application/basic+json') +basic_json_v1_response = record_responsify( + basic_json_v1, 'application/basic+json' +) permissions_json_v1_response = record_responsify( - permissions_json_v1, 'application/permissions+json') + permissions_json_v1, 'application/permissions+json' +) + +# JSON record serializer for search results. +basic_json_v1_search = search_responsify( + basic_json_v1, 'application/basic+json' +) # JSON record serializer for search results. -basic_json_v1_search = search_responsify(basic_json_v1, - 'application/basic+json') +record_xml_v1_search = search_responsify( + record_xml_v1, 'application/marcxml+xml' +) +record_csv_v1_search = search_responsify(record_csv_v1, 'application/csv') repositories_json_v1_response = record_responsify( - repositories_json_v1, 'application/repositories+json') + repositories_json_v1, 'application/repositories+json' +) diff --git a/cap/modules/records/serializers/author_xml.py b/cap/modules/records/serializers/author_xml.py new file mode 100644 index 0000000000..92d6b0e752 --- /dev/null +++ b/cap/modules/records/serializers/author_xml.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2016-2019 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""Marshmallow based DublinCore serializer for records.""" + +from invenio_records_rest.serializers.base import ( + PreprocessorMixin, + SerializerMixinInterface, +) +from invenio_records_rest.serializers.marshmallow import MarshmallowMixin +from lxml import etree + + +class Line(object): + """Object that implements an interface the csv writer accepts.""" + + def __init__(self): + """Initialize.""" + self._line = None + + def write(self, line): + """Write a line.""" + self._line = line + + def read(self): + """Read a line.""" + return self._line + + +class AuthorXMLSerializer( + SerializerMixinInterface, MarshmallowMixin, PreprocessorMixin +): + """CSV serializer for records. + + Note: This serializer is not suitable for serializing large number of + records. + """ + + def __init__(self, *args, **kwargs): + """Initialize CSVSerializer. + + :param csv_excluded_fields: list of paths of the fields that + should be excluded from the final output + :param csv_included_fields: list of paths of the only fields that + should be included in the final output + :param header_separator: separator that should be used when flattening + nested dictionary keys + """ + self.csv_excluded_fields = kwargs.pop("csv_excluded_fields", []) + self.csv_included_fields = kwargs.pop("csv_included_fields", []) + + if self.csv_excluded_fields and self.csv_included_fields: + raise ValueError( + "Please provide only fields to either include or exclude" + ) + + self.header_separator = kwargs.pop("header_separator", "_") + super().__init__(*args, **kwargs) + + def serialize(self, pid, record, links_factory=None): + """Serialize a single record and persistent identifier. + + :param pid: Persistent identifier instance. + :param record: Record instance. + :param links_factory: Factory function for record links. + """ + record = self.process_dict( + self.transform_record(pid, record, links_factory) + ) + + return self._format_author_xml([record]) + + def serialize_search( + self, pid_fetcher, search_result, links=None, item_links_factory=None + ): + """Serialize a search result. + + :param pid_fetcher: Persistent identifier fetcher. + :param search_result: The search engine result. + :param links: Dictionary of links to add to response. + :param item_links_factory: Factory function for record links. + """ + records = [] + for hit in search_result["hits"]["hits"]: + processed_hit = self.transform_search_hit( + pid_fetcher(hit["_id"], hit["_source"]), + hit, + links_factory=item_links_factory, + ) + records.append(self.process_dict(processed_hit)) + + return self._format_author_xml(records) + + def process_dict(self, dictionary): + """Transform record dict with nested keys to a flat dict.""" + return self._flatten(dictionary) + + def _format_author_xml(self, records): + """Return the list of records as a CSV string.""" + root = etree.Element("authors") + + for record in records: + author = etree.SubElement(root, "Person") + + name = etree.SubElement(author, 'name') + surname = etree.SubElement(author, 'surname') + name.text = record.get('metadata_name') + surname.text = record.get('metadata_surname') + return etree.tostring(root) + + def _flatten(self, value, parent_key=""): + """Flattens nested dict recursively, skipping excluded fields.""" + items = [] + sep = self.header_separator if parent_key else "" + + if isinstance(value, dict): + for k, v in value.items(): + # for dict, build a key field_subfield, e.g. title_subtitle + new_key = parent_key + sep + k + # skip excluded keys + if new_key in self.csv_excluded_fields: + continue + if self.csv_included_fields and not self.key_in_field( + new_key, self.csv_included_fields + ): + continue + items.extend(self._flatten(v, new_key).items()) + elif isinstance(value, list): + for index, item in enumerate(value): + # for lists, build a key with an index, e.g. title_0_subtitle + new_key = parent_key + sep + str(index) + # skip excluded keys + if new_key in self.csv_excluded_fields: + continue + if self.csv_included_fields and not self.key_in_field( + parent_key, self.csv_included_fields + ): + continue + items.extend(self._flatten(item, new_key).items()) + else: + items.append((parent_key, value)) + + return dict(items) + + def key_in_field(self, key, fields): + """Checks if the given key is contained within any of the fields.""" + for field in fields: + if key in field: + return True + return False diff --git a/cap/modules/records/serializers/csv.py b/cap/modules/records/serializers/csv.py new file mode 100644 index 0000000000..b7c37ed6c0 --- /dev/null +++ b/cap/modules/records/serializers/csv.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Invenio. +# Copyright (C) 2016-2019 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""Marshmallow based DublinCore serializer for records.""" + +import csv + +from invenio_records_rest.serializers.base import ( + PreprocessorMixin, + SerializerMixinInterface, +) +from invenio_records_rest.serializers.marshmallow import MarshmallowMixin + + +class Line(object): + """Object that implements an interface the csv writer accepts.""" + + def __init__(self): + """Initialize.""" + self._line = None + + def write(self, line): + """Write a line.""" + self._line = line + + def read(self): + """Read a line.""" + return self._line + + +class CSVSerializer( + SerializerMixinInterface, MarshmallowMixin, PreprocessorMixin +): + """CSV serializer for records. + + Note: This serializer is not suitable for serializing large number of + records. + """ + + def __init__(self, *args, **kwargs): + """Initialize CSVSerializer. + + :param csv_excluded_fields: list of paths of the fields that + should be excluded from the final output + :param csv_included_fields: list of paths of the only fields that + should be included in the final output + :param header_separator: separator that should be used when flattening + nested dictionary keys + """ + self.csv_excluded_fields = kwargs.pop("csv_excluded_fields", []) + self.csv_included_fields = kwargs.pop("csv_included_fields", []) + + if self.csv_excluded_fields and self.csv_included_fields: + raise ValueError( + "Please provide only fields to either include or exclude" + ) + + self.header_separator = kwargs.pop("header_separator", "_") + super().__init__(*args, **kwargs) + + def serialize(self, pid, record, links_factory=None): + """Serialize a single record and persistent identifier. + + :param pid: Persistent identifier instance. + :param record: Record instance. + :param links_factory: Factory function for record links. + """ + record = self.process_dict( + self.transform_record(pid, record, links_factory) + ) + + return self._format_csv([record]) + + def serialize_search( + self, pid_fetcher, search_result, links=None, item_links_factory=None + ): + """Serialize a search result. + + :param pid_fetcher: Persistent identifier fetcher. + :param search_result: The search engine result. + :param links: Dictionary of links to add to response. + :param item_links_factory: Factory function for record links. + """ + records = [] + for hit in search_result["hits"]["hits"]: + processed_hit = self.transform_search_hit( + pid_fetcher(hit["_id"], hit["_source"]), + hit, + links_factory=item_links_factory, + ) + records.append(self.process_dict(processed_hit)) + + return self._format_csv(records) + + def process_dict(self, dictionary): + """Transform record dict with nested keys to a flat dict.""" + return self._flatten(dictionary) + + def _format_csv(self, records): + """Return the list of records as a CSV string.""" + # build a unique list of all records keys as CSV headers + headers = set() + for rec in records: + headers.update(rec.keys()) + + # write the CSV output in memory + line = Line() + writer = csv.DictWriter(line, fieldnames=sorted(headers)) + writer.writeheader() + yield line.read() + + for record in records: + writer.writerow(record) + yield line.read() + + def _flatten(self, value, parent_key=""): + """Flattens nested dict recursively, skipping excluded fields.""" + items = [] + sep = self.header_separator if parent_key else "" + + if isinstance(value, dict): + for k, v in value.items(): + # for dict, build a key field_subfield, e.g. title_subtitle + new_key = parent_key + sep + k + # skip excluded keys + if new_key in self.csv_excluded_fields: + continue + if self.csv_included_fields and not self.key_in_field( + new_key, self.csv_included_fields + ): + continue + items.extend(self._flatten(v, new_key).items()) + elif isinstance(value, list): + for index, item in enumerate(value): + # for lists, build a key with an index, e.g. title_0_subtitle + new_key = parent_key + sep + str(index) + # skip excluded keys + if new_key in self.csv_excluded_fields: + continue + if self.csv_included_fields and not self.key_in_field( + parent_key, self.csv_included_fields + ): + continue + items.extend(self._flatten(item, new_key).items()) + else: + items.append((parent_key, value)) + + return dict(items) + + def key_in_field(self, key, fields): + """Checks if the given key is contained within any of the fields.""" + for field in fields: + if key in field: + return True + return False diff --git a/cap/modules/records/serializers/schemas/common.py b/cap/modules/records/serializers/schemas/common.py index 20f89faa5b..f16326309c 100644 --- a/cap/modules/records/serializers/schemas/common.py +++ b/cap/modules/records/serializers/schemas/common.py @@ -27,7 +27,13 @@ from flask_login import current_user from invenio_files_rest.models import ObjectVersion from invenio_files_rest.serializer import ObjectVersionSchema -from marshmallow import Schema, ValidationError, fields, validates_schema +from marshmallow import ( + Schema, + ValidationError, + fields, + post_dump, + validates_schema, +) from cap.modules.records.utils import url_to_api_url from cap.modules.schemas.resolvers import resolve_schema_by_url @@ -49,6 +55,20 @@ 'condition': lambda obj: obj.get('metadata', {}).get('_experiment') == 'CMS', }, + 'institute': { + 'path': 'metadata.institute', + 'condition': lambda obj: obj.get('metadata') + .get('_collection', {}) + .get('name') + == 'na62authors', + }, + 'orcidid': { + 'path': 'metadata.orcidid', + 'condition': lambda obj: obj.get('metadata') + .get('_collection', {}) + .get('name') + == 'na62authors', + } # 'cms_keywords': { # 'path': 'metadata.additional_resources.keywords', # 'condition': @@ -130,20 +150,55 @@ def dump_links(self, o): return data -class CommonRecordSchema(Schema, StrictKeysMixin): +class CommonRecordMetadataSchema(Schema): + metadata = fields.Method('get_metadata', dump_only=True) + + def get_metadata(self, obj): + result = { + k: v + for k, v in obj.get('metadata', {}).items() + if k + not in [ + 'control_number', + '$schema', + '_deposit', + '_experiment', + '_access', + '_files', + '_review', + '_fetched_from', + '_user_edited', + '_egroups', + '_collection', + ] + } + + return result + + +class CommonRecordSchema(CommonRecordMetadataSchema, StrictKeysMixin): """Base record schema.""" + @post_dump + def remove_skip_values(self, data): + keys = ["egroups"] + + for key in keys: + if data.get(key, '') is None: + del data[key] + + return data + id = fields.Str(attribute='pid.pid_value', dump_only=True) schema = fields.Method('get_schema', dump_only=True) experiment = fields.Str(attribute='metadata._experiment', dump_only=True) status = fields.Str(attribute='metadata._deposit.status', dump_only=True) + egroups = fields.Method("get_egroups", dump_only=True) created_by = fields.Method('get_created_by', dump_only=True) is_owner = fields.Method('is_current_user_owner', dump_only=True) - metadata = fields.Method('get_metadata', dump_only=True) - links = fields.Raw(dump_only=True) files = fields.Method('get_files', dump_only=True) @@ -189,25 +244,12 @@ def get_schema(self, obj): } return result - def get_metadata(self, obj): - result = { - k: v - for k, v in obj.get('metadata', {}).items() - if k - not in [ - 'control_number', - '$schema', - '_deposit', - '_experiment', - '_access', - '_files', - '_review', - '_fetched_from', - '_user_edited', - '_collection', - ] - } - return result + def get_egroups(self, obj): + _egroups = obj.get("metadata", {}).get("_egroups", []) + if "deposit" in obj and obj["deposit"].schema_egroups_enabled(): + return _egroups + else: + return None def get_created_by(self, obj): user_id = obj.get('metadata', {})['_deposit'].get('created_by') diff --git a/cap/modules/records/serializers/schemas/json.py b/cap/modules/records/serializers/schemas/json.py index 40bd8d864d..10dfc549d1 100644 --- a/cap/modules/records/serializers/schemas/json.py +++ b/cap/modules/records/serializers/schemas/json.py @@ -24,22 +24,25 @@ """CAP Basic Schemas.""" from __future__ import absolute_import, print_function + import copy -from marshmallow import Schema, fields, post_dump from invenio_jsonschemas import current_jsonschemas from invenio_pidstore.resolver import Resolver +from marshmallow import Schema, fields, post_dump from cap.modules.deposit.api import CAPDeposit -from cap.modules.records.permissions import UpdateRecordPermission from cap.modules.deposit.permissions import ReviewDepositPermission from cap.modules.deposit.review import ReviewSchema - +from cap.modules.records.permissions import UpdateRecordPermission from cap.modules.records.serializers.schemas import common from cap.modules.records.utils import clean_api_url_for from cap.modules.repos.serializers import GitWebhookSubscriberSchema -from cap.modules.user.utils import get_role_name_by_id, get_user_email_by_id, \ - get_remote_account_by_id +from cap.modules.user.utils import ( + get_remote_account_by_id, + get_role_name_by_id, + get_user_email_by_id, +) class RecordSchema(common.CommonRecordSchema): @@ -58,7 +61,9 @@ def get_access(self, obj): for permission in access.values(): if permission['users']: for index, user_id in enumerate(permission['users']): - permission['users'][index] = get_remote_account_by_id(user_id) # noqa + permission['users'][index] = get_remote_account_by_id( + user_id + ) # noqa if permission['roles']: for index, role_id in enumerate(permission['roles']): permission['roles'][index] = get_role_name_by_id(role_id) @@ -71,7 +76,7 @@ class RecordFormSchema(RecordSchema): @post_dump def remove_skip_values(self, data): - keys = ["can_review", "review"] + keys = ["can_review", "review", "egroups"] for key in keys: if data.get(key, '') is None: @@ -88,21 +93,24 @@ def remove_skip_values(self, data): def get_record_schemas(self, obj): deposit = CAPDeposit.get_record(obj['pid'].object_uuid) - schema = current_jsonschemas.get_schema(deposit.schema.record_path, - with_refs=True, - resolved=True) + schema = current_jsonschemas.get_schema( + deposit.schema.record_path, with_refs=True, resolved=True + ) uiSchema = deposit.schema.record_options config_reviewable = deposit.schema_is_reviewable() - return dict(schema=copy.deepcopy(schema), uiSchema=uiSchema, - config_reviewable=config_reviewable) + return dict( + schema=copy.deepcopy(schema), + uiSchema=uiSchema, + config_reviewable=config_reviewable, + ) def get_review(self, obj): depid = obj.get("metadata", {}).get("_deposit", {}).get("id") - resolver = Resolver(pid_type='depid', - object_type='rec', - getter=lambda x: x) + resolver = Resolver( + pid_type='depid', object_type='rec', getter=lambda x: x + ) _, rec_uuid = resolver.resolve(depid) deposit = CAPDeposit.get_record(rec_uuid) @@ -120,33 +128,40 @@ def can_user_update(self, obj): def can_user_review(self, obj): deposit_pid = obj.get("metadata", {}).get("_deposit", {}).get("id") - resolver = Resolver(pid_type='depid', - object_type='rec', - getter=lambda x: x) + resolver = Resolver( + pid_type='depid', object_type='rec', getter=lambda x: x + ) _, rec_uuid = resolver.resolve(deposit_pid) deposit = CAPDeposit.get_record(rec_uuid) - return (deposit.schema_is_reviewable() and - ReviewDepositPermission(deposit).can()) + return ( + deposit.schema_is_reviewable() + and ReviewDepositPermission(deposit).can() + ) def get_links_with_review(self, obj): deposit_pid = obj.get("metadata", {}).get("_deposit", {}).get("id") - resolver = Resolver(pid_type='depid', - object_type='rec', - getter=lambda x: x) + resolver = Resolver( + pid_type='depid', object_type='rec', getter=lambda x: x + ) _, rec_uuid = resolver.resolve(deposit_pid) deposit = CAPDeposit.get_record(rec_uuid) links = obj['links'] - if (deposit.schema_is_reviewable() and - ReviewDepositPermission(deposit).can()): + if ( + deposit.schema_is_reviewable() + and ReviewDepositPermission(deposit).can() + ): links['review'] = clean_api_url_for( 'invenio_deposit_rest.depid_actions', deposit.pid, - action="review") + action="review", + ) + + links.pop('egroups', None) return links @@ -164,7 +179,9 @@ class BasicDepositSchema(Schema): def get_metadata(self, obj): result = { k: v - for k, v in obj.get('metadata', {}).items() if k not in [ + for k, v in obj.get('metadata', {}).items() + if k + not in [ 'control_number', '$schema', '_deposit', @@ -172,6 +189,7 @@ def get_metadata(self, obj): '_access', '_files', '_review', + '_egroups', '_user_edited', '_fetched_from', '_collection', @@ -201,10 +219,12 @@ def get_access(self, obj): """Return access object.""" access = { k.replace('deposit-', ''): { - 'users': - [get_user_email_by_id(user_id) for user_id in v['users']], - 'roles': - [get_role_name_by_id(role_id) for role_id in v['roles']] + 'users': [ + get_user_email_by_id(user_id) for user_id in v['users'] + ], + 'roles': [ + get_role_name_by_id(role_id) for role_id in v['roles'] + ], } for k, v in obj['metadata']['_access'].items() } diff --git a/cap/modules/schemas/access.py b/cap/modules/schemas/access.py index fa9aa249bc..870db711fc 100644 --- a/cap/modules/schemas/access.py +++ b/cap/modules/schemas/access.py @@ -30,11 +30,13 @@ def _allow_role(action, arg, id): """Allow action for schema processor.""" db.session.add(ActionRoles.allow(action, argument=arg, role_id=id)) + delete_access_cache() def _deny_role(action, arg, id): """Deny action for schema processor.""" db.session.add(ActionRoles.deny(action, argument=arg, role_id=id)) + delete_access_cache() def _remove_role(action, arg, id): @@ -42,16 +44,19 @@ def _remove_role(action, arg, id): ActionRoles.query_by_action(action, argument=arg).filter( ActionRoles.role_id == id ).delete(synchronize_session=False) + delete_access_cache() def _allow_user(action, arg, id): """Allow action for schema processor.""" db.session.add(ActionUsers.allow(action, argument=arg, user_id=id)) + delete_access_cache() def _deny_user(action, arg, id): """Deny action for schema processor.""" db.session.add(ActionUsers.deny(action, argument=arg, user_id=id)) + delete_access_cache() def _remove_user(action, arg, id): @@ -59,3 +64,10 @@ def _remove_user(action, arg, id): ActionUsers.query_by_action(action, argument=arg).filter( ActionUsers.user_id == id ).delete(synchronize_session=False) + delete_access_cache() + + +def delete_access_cache(): + from cap.modules.schemas.imp import delete_schema_access_cache + + delete_schema_access_cache() diff --git a/cap/modules/schemas/cli.py b/cap/modules/schemas/cli.py index a722c4a31a..3faabe519f 100644 --- a/cap/modules/schemas/cli.py +++ b/cap/modules/schemas/cli.py @@ -167,13 +167,21 @@ def permissions( # create all combinations of actions and roles try: actions_roles = list(itertools.product(requested_actions, roles)) - schema.process_action_roles(schema_action, actions_roles) + roles_logs = schema.process_action_roles(schema_action, actions_roles) # create all combinations of actions and users actions_users = list(itertools.product(requested_actions, users)) - schema.process_action_users(schema_action, actions_users) + users_logs = schema.process_action_users(schema_action, actions_users) except IntegrityError: return click.secho("Action user/role already exists.", fg="red") click.secho("Process finished.", fg="green") + errors = [log for log in roles_logs if log.get('status') == 'error'] + errors += [log for log in users_logs if log.get('status') == 'error'] + + for e in errors: + click.secho( + f"User/Role \"{e.get('role')}\" - \"{e.get('message')}\"", + fg="yellow", + ) @fixtures.command() diff --git a/cap/modules/schemas/imp.py b/cap/modules/schemas/imp.py index 11ae3513a0..54686ca892 100644 --- a/cap/modules/schemas/imp.py +++ b/cap/modules/schemas/imp.py @@ -29,8 +29,8 @@ from invenio_cache import current_cache from sqlalchemy.event import listen -from .models import Schema -from .permissions import ( +from cap.modules.schemas.models import Schema +from cap.modules.schemas.permissions import ( AdminSchemaPermission, ReadSchemaPermission, deposit_schema_create_action, @@ -209,11 +209,15 @@ def clear_schema_access_cache(mapper, connection, target): if target.action.startswith("deposit-schema-") or target.action.startswith( "record-schema-" ): - get_cached_indexed_schemas_for_user_create.delete_memoized() - get_cached_indexed_schemas_for_user_read.delete_memoized() - get_cached_indexed_schemas_for_user_admin.delete_memoized() - get_cached_indexed_record_schemas_for_user_create.delete_memoized() - get_cached_indexed_record_schemas_for_user_read.delete_memoized() + delete_schema_access_cache() + + +def delete_schema_access_cache(): + get_cached_indexed_schemas_for_user_create.delete_memoized() + get_cached_indexed_schemas_for_user_read.delete_memoized() + get_cached_indexed_schemas_for_user_admin.delete_memoized() + get_cached_indexed_record_schemas_for_user_create.delete_memoized() + get_cached_indexed_record_schemas_for_user_read.delete_memoized() listen(ActionUsers, "after_insert", clear_schema_access_cache) diff --git a/cap/modules/schemas/jsonschemas/schema_config.py b/cap/modules/schemas/jsonschemas/schema_config.py index 99105efdea..8669d25a06 100644 --- a/cap/modules/schemas/jsonschemas/schema_config.py +++ b/cap/modules/schemas/jsonschemas/schema_config.py @@ -25,6 +25,7 @@ "x-cap-permission": {"type": "boolean"}, "notifications": notifications_schema, "reviewable": {"type": "boolean"}, + "ergoups": {"type": "boolean"}, "repositories": repositories_schema, "readme": {"type": "string"}, }, diff --git a/cap/modules/schemas/models.py b/cap/modules/schemas/models.py index 6d3c7e95a2..5cb179ac57 100644 --- a/cap/modules/schemas/models.py +++ b/cap/modules/schemas/models.py @@ -45,6 +45,7 @@ from werkzeug.utils import import_string from cap.modules.records.errors import get_error_path +from cap.modules.user.errors import DoesNotExistInLDAP from cap.modules.user.utils import ( get_existing_or_register_role, get_existing_or_register_user, @@ -287,8 +288,14 @@ def modify_record_permissions( for role in permissions[permission_type].get("roles", []): actions_roles.append([action_name, role]) - self.process_action_roles(schema_action, actions_roles) - self.process_action_users(schema_action, actions_users) + permissions_roles_log = self.process_action_roles( + schema_action, actions_roles + ) + permissions_users_log = self.process_action_users( + schema_action, actions_users + ) + + return permissions_roles_log + permissions_users_log def process_action_roles(self, schema_action, actions_roles): """Permission process action. @@ -305,13 +312,42 @@ def process_action_roles(self, schema_action, actions_roles): processor = schema_actions[schema_action] # check for kind of action, in order to use the correct argument # schema actions need id, deposit/record actions need name + permission_logs = [] for _action, _role in actions_roles: - with db.session.begin_nested(): - role = get_existing_or_register_role(_role) - role_id = role.id - schema_argument = str(self.id) - processor(allowed_actions[_action], schema_argument, role_id) - db.session.commit() + try: + with db.session.begin_nested(): + role = get_existing_or_register_role(_role) + db.session.flush() + role_id = role.id + schema_argument = str(self.id) + processor( + allowed_actions[_action], schema_argument, role_id + ) + db.session.commit() + permission_logs.append( + { + "action": _action, + "role": role.name, + "status": schema_action, + } + ) + except (DoesNotExistInLDAP, IntegrityError) as err: + message = "" + if isinstance(err, DoesNotExistInLDAP): + message = "Doesn't exist in CERN database" + elif isinstance(err, IntegrityError): + message = "Already exists" + permission_logs.append( + { + "action": _action, + "role": _role, + "status": "error", + "message": message, + } + ) + continue + + return permission_logs def process_action_users(self, schema_action, actions_users): """ @@ -329,13 +365,41 @@ def process_action_users(self, schema_action, actions_users): processor = schema_actions[schema_action] # check for kind of action, in order to use the correct argument # schema actions need id, deposit/record actions need name + permission_logs = [] for _action, _user in actions_users: - with db.session.begin_nested(): - user = get_existing_or_register_user(_user) - user_id = user.id - schema_argument = str(self.id) - processor(allowed_actions[_action], schema_argument, user_id) - db.session.commit() + try: + with db.session.begin_nested(): + user = get_existing_or_register_user(_user) + db.session.flush() + user_id = user.id + schema_argument = str(self.id) + processor( + allowed_actions[_action], schema_argument, user_id + ) + db.session.commit() + permission_logs.append( + { + "action": _action, + "user": user.email, + "status": schema_action, + } + ) + except (DoesNotExistInLDAP, IntegrityError) as err: + message = "" + if isinstance(err, DoesNotExistInLDAP): + message = "Doesn't exist in CERN database" + elif isinstance(err, IntegrityError): + message = "Already exists" + permission_logs.append( + { + "action": _action, + "role": _user, + "status": "error", + "message": message, + } + ) + continue + return permission_logs def give_admin_access_for_user(self, user): """Give admin access for users.""" diff --git a/cap/modules/schemas/views.py b/cap/modules/schemas/views.py index 9c83ce5000..acb447f403 100644 --- a/cap/modules/schemas/views.py +++ b/cap/modules/schemas/views.py @@ -149,15 +149,31 @@ def get_all_versions(name=None, schemas=None, *args, **kwargs): @super_admin_permission.require(http_exception=403) def permissions(name=None, version=None, schema=None, *args, **kwargs): """Get all versions of a schema that user has access to.""" + permission_logs = [] if request.method == "GET": schema_permissions = schema.get_schema_permissions() return jsonify(schema_permissions) elif request.method == "POST": data = request.json - schema.modify_record_permissions(data) - return jsonify({}), 201 + if data.get("deposit", None): + permission_logs += schema.modify_record_permissions(data["deposit"]) + if data.get("record", None): + permission_logs += schema.modify_record_permissions( + data["record"], record_type="record" + ) + return jsonify(permission_logs), 201 elif request.method == "DELETE": - return jsonify({}), 204 + data = request.json + if data.get("deposit", None): + permission_logs += schema.modify_record_permissions( + data["deposit"], schema_action="remove" + ) + if data.get("record", None): + permission_logs += schema.modify_record_permissions( + data["record"], record_type="record", schema_action="remove" + ) + + return jsonify(permission_logs), 202 @blueprint.route( diff --git a/cap/modules/services/utils/cern_egroups.py b/cap/modules/services/utils/cern_egroups.py new file mode 100644 index 0000000000..23ffe5292a --- /dev/null +++ b/cap/modules/services/utils/cern_egroups.py @@ -0,0 +1,82 @@ +from flask import current_app +from suds.client import Client + +from .helpers import checkOK, generateGroup, getMemberObject + +# Implementation idea from: https://gitlab.cern.ch/-/snippets/85 + +WSDL_URI = ( + "https://foundservices.cern.ch/ws/" + "egroups/v1/EgroupsWebService/EgroupsWebService.wsdl" +) + + +class EgroupError(Exception): + """Account not registered in LDAP exception.""" + + pass + + +def getClient(): + try: + client = Client( + WSDL_URI, + username=current_app.config.get("CERN_EGROUP_ACCOUNT_USERNAME"), + password=current_app.config.get("CERN_EGROUP_ACCOUNT_PASSWORD"), + ) + return client + except Exception: + raise EgroupError() + + +def findGroup(group_name=None): + client = getClient() + group = None + try: + group = client.service.FindEgroupByName(group_name).result + except Exception: + pass + + return group + + +def createGroup(group_name=None, description="-", owner=None): + if not group_name: + return + + client = getClient() + + if owner is None: + owner = current_app.config.get("CERN_EGROUP_ACCOUNT_DEFAULT_OWNER_ID") + + group_name = group_name.lower() + group = generateGroup(group_name, description, owner) + + try: + result = client.service.SynchronizeEgroup(group) + if not checkOK(result): + errors = [warn["Message"] for warn in result.warnings] + print(f"ERROR could not create group, {group_name}") + print(f"reason given by server: {errors}") + raise EgroupError({"errors": errors}) + except Exception as ex: + raise EgroupError(ex) + + return result + + +def addMembers(group_name, members=[]): + client = getClient() + + group_name = group_name.lower() + members = [getMemberObject(member) for member in members] + + try: + result = client.service.AddEgroupMembers(group_name, False, members) + if not checkOK(result): + errors = [warn["Message"] for warn in result.warnings] + print(f"ERROR could not add group members in {group_name}") + print(f"reason given by server: {errors}") + raise EgroupError({"errors": errors}) + except Exception as ex: + raise EgroupError(ex) diff --git a/cap/modules/services/utils/helpers.py b/cap/modules/services/utils/helpers.py new file mode 100644 index 0000000000..a16deb7780 --- /dev/null +++ b/cap/modules/services/utils/helpers.py @@ -0,0 +1,140 @@ +import copy + +# Implementation idea from: https://gitlab.cern.ch/-/snippets/85 + +EGROUP_OBJECT_TEMPLATE = { + "Name": "", + "Description": "", + "Type": "StaticEgroup", + "Usage": "SecurityMailing", + "Owner": {}, + "Selfsubscription": "Closed", + # "EgroupWithPrivileges": [ + # { + # "Name": "test-group", + # "Privilege": "SeeMembers" + # }, + # { + # "Name": "test-group-admin", + # "Privilege": "SeeMembers | Admin" + # } + # ], + "Privacy": "", + "Members": [], + "Comments": "This e-group was created dynamically/on-demand" + " by CERN Analysis Preservation service", +} + + +def generate_members(members): + # MEMBER_TYPES = [ + # "Account", + # "DynamicEgroup", + # "StaticEgroup", + # "Person", + # "External", + # "ServiceProvider" + + # ] + # for member in members: + # if members["Type"] in MEMBER_TYPES: + return members + + +def generate_privileged_egroup(privileged_egroups): + return privileged_egroups + + +def generate_type(type): + TYPE_TYPES = ["StaticEgroup", "DynamicEgroup"] + if type in TYPE_TYPES: + return type + + +def generate_usage(usage): + USAGE_TYPES = ["EgroupsOnly", "SecurityMailing"] + if usage in USAGE_TYPES: + return usage + + +def generate_owner(owner): + return {"PersonId": owner} + + +def generate_selfsubscription(selfsubscription): + SELF_SUBSCRIPTION_TYPES = [ + "Open", + "Members", + "Closed", + "Users", + "OpenWithAdminsAproval", + "UsersWithAdminsAproval", + ] + if selfsubscription in SELF_SUBSCRIPTION_TYPES: + return selfsubscription + + +def generate_privacy(privacy): + PRIVACY_TYPES = [ + "Open", + "Members", + "Administrators", + "Users", + ] + if privacy in PRIVACY_TYPES: + return privacy + + +def generateGroup( + name, + description, + owner, + members=[], + privileged_egroups=[], + selfsubscription="Closed", + privacy="Members", + adminGroup=None, + type="StaticEgroup", + usage="SecurityMailing", + status="Active", +): + + group = copy.copy(EGROUP_OBJECT_TEMPLATE) + + group["Name"] = name + group["Description"] = description + group["Privacy"] = generate_privacy(privacy) + group["Members"] = generate_members(members) + if privileged_egroups: + group["EgroupWithPrivileges"] = generate_privileged_egroup( + privileged_egroups + ) + group["Type"] = generate_type(type) + group["Usage"] = generate_usage(usage) + group["Owner"] = generate_owner(owner) + group["Selfsubscription"] = generate_selfsubscription(selfsubscription) + + return group + + +def checkOK(replyIn): + + reply = str(replyIn) + if "ErrorType" in reply: + return False + + return True + + +def getMemberObject(member): + type = member.get("type") + value = member.get("value") + + if type == "external": + return {"Type": "External", "Email": value} + elif type == "username": + return {"Type": "Account", "Name": value} + elif type == "personID": + return {"Type": "Person", "ID": value} + elif type == "egroup": + return {"Type": "StaticEgroup", "Name": value} diff --git a/setup.py b/setup.py index 47fc7cdde5..423dc7834b 100644 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ # reana_client => bravado core dependency pin due to py3.6 drop 'swagger-spec-validator==2.7.6', 'prometheus-flask-exporter==0.20.3', + 'suds==1.1.2', ] packages = find_packages() diff --git a/tests/integration/cli/test_schema_permissions.py b/tests/integration/cli/test_schema_permissions.py index 7e3ff83479..0263f8d7ca 100644 --- a/tests/integration/cli/test_schema_permissions.py +++ b/tests/integration/cli/test_schema_permissions.py @@ -205,4 +205,4 @@ def test_action_allow_read_schema_fails_if_already_in_db(app, db, users, create_ res = cli_runner('fixtures permissions -p read -r test-users@cern.ch --schema --allow test-schema') assert res.exit_code == 0 - assert 'Action user/role already exists.' in res.output + assert 'Already exists' in res.output diff --git a/tests/integration/deposits/test_deposit_validation.py b/tests/integration/deposits/test_deposit_validation.py index 4734c93955..5abd3d6a3c 100644 --- a/tests/integration/deposits/test_deposit_validation.py +++ b/tests/integration/deposits/test_deposit_validation.py @@ -847,3 +847,108 @@ def test_deposit_validation_on_schema_field_user_cannot_edit_with_custom_message assert resp.status_code == 422 assert resp.json['errors'][0]['message'] == 'Test error message from schema.' + + + +def test_deposit_validation_x_cap_fetch(client, users, + auth_headers_for_user, + json_headers, create_schema, + create_deposit): + owner = users['cms_user'] + headers = auth_headers_for_user(owner) + + create_schema('validation-schema', + experiment='CMS', + deposit_schema={ + 'type': 'object', + 'required': ['title'], + 'properties': { + 'title': { + 'type': 'string' + }, + 'obj': { + 'type': 'object', + "properties": { + "field1": {"type": "string"}, + "field2": { + "type": "object", + "properties": { + "field2_1": {"type": "string"}, + "field2_2": {"type": "string"}, + }, + "required": ["field2_2"] + } + }, + "additionalProperties": False + } + }, + }) + + deposit = create_deposit(owner, + 'test-analysis-2', + experiment='CMS', + metadata={"$ana_type": "test-analysis-2", "random_prop": "boom"}) + deposit_id = deposit.pid.pid_value + + resp = client.get('/deposits/'.format(deposit['_deposit']['id']), + headers=headers + json_headers) + + create_schema('test-analysis', + experiment='CMS', + deposit_schema={ + 'type': 'object', + 'required': ['title'], + 'properties': { + 'title': { + 'type': 'string' + }, + 'obj': { + 'type': 'object', + 'x-cap-fetch': { + "name": "service", + "description": "", + # "url": "/api/deposits", + # "request": { + # "searchParam": "q", + # "params": { + # } + # }, + "schema": {"type": "object"}, + "schemaURL": "https://analysispreservation.cern.ch/api/schemas/depostis/records/validation-schema-v0.0.1.json", + }, + "properties": { + "url": {"type": "string"}, + "data": {"$ref": "https://analysispreservation.cern.ch/api/schemas/depostis/records/validation-schema-v0.0.1.json"} + }, + "required": ["url"] + } + }, + }) + deposit = create_deposit(owner, 'test-analysis') + pid = deposit['_deposit']['id'] + + resp = client.put('/deposits/{}'.format(pid), + headers=headers + json_headers, + data=json.dumps({ + "obj": { + "url": f"http://analysispreservation.cern.ch/api/deposits/{deposit_id}" + } + } + )) + + deposit2 = create_deposit(owner, 'validation-schema') + pid2 = deposit2['_deposit']['id'] + + resp = client.put('/deposits/{}'.format(pid2), + headers=headers + json_headers, + data=json.dumps({ + "obj": { + "url": f"http://analysispreservation.cern.ch/api/deposits/{deposit_id}" + } + } + )) + + assert resp.status_code == 422 + + assert "('url' was unexpected)" in resp.json['errors'][0]['message'] + diff --git a/tests/integration/deposits/test_records_schema_permissions.py b/tests/integration/deposits/test_records_schema_permissions.py new file mode 100644 index 0000000000..590a105206 --- /dev/null +++ b/tests/integration/deposits/test_records_schema_permissions.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2018 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. +# or submit itself to any jurisdiction. +"""Unit tests for schemas views.""" +import json + +from six import PY3 +from pytest import mark +from conftest import add_role_to_user, get_default_mapping, _datastore + +from cap.modules.schemas.models import Schema + +from time import sleep +from mock import patch +from mock.mock import MagicMock +######################## +# api/jsonschemas/{schema_id}/permissions [GET] +# api/jsonschemas/{schema_id}/{version}/permissions [GET] +######################## + +@patch('cap.modules.user.utils.does_user_exist_in_ldap', + MagicMock(return_value=False)) +def test_schema_permission( + client, + es, + db, + users, + location, + create_schema, + auth_headers_for_user, + json_headers, + auth_headers_for_superuser,clean_schema_acceess_cache + +): + schema_name = "example-schema" + deposit_mapping = get_default_mapping(schema_name, "1.0.0") + create_schema( + schema_name, + # experiment="CMS", + + deposit_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + deposit_mapping=deposit_mapping, + ) + other_user = users["lhcb_user"] + random_user = users["random"] + test_role = "test-egroup@cern.ch" + add_role_to_user(random_user, test_role) + metadata = {"$ana_type": schema_name} + + # 'other_user' HAS NO permission to create + resp = client.post( + "/deposits/", + data=json.dumps(metadata), + headers=auth_headers_for_user(other_user) + json_headers, + ) + # TODO: update POST /deposits to return JSON response on wrong schema + assert resp.status_code == 403 + + # 'other_user' HAS NO permission to update schema permissions + permission_metadata = {"deposit": { + "create": { "users": [other_user.email], "roles": [test_role]} + }} + resp = client.post( + f"/jsonschemas/{schema_name}/permissions", + data=json.dumps(permission_metadata), + headers=auth_headers_for_user(other_user) + json_headers, + ) + assert resp.status_code == 403 + + # 'superuser' HAS permission to update schema permissions + resp = client.post( + f"/jsonschemas/{schema_name}/permissions", + data=json.dumps(permission_metadata), + headers=auth_headers_for_superuser+ json_headers, + ) + assert resp.status_code == 201 + + # 'other_user' can now create + metadata = {"$ana_type": schema_name} + resp = client.post( + "/deposits/", + data=json.dumps(metadata), + headers=auth_headers_for_user(other_user) + json_headers, + ) + assert resp.status_code == 201 + dep_id = resp.json.get("id") + + # 'other_user' can GET created deposit + resp = client.get( + f"/deposits/{dep_id}", + headers=auth_headers_for_user(other_user) + json_headers, + ) + assert resp.status_code == 200 + + # 'random_user' can NOT GET created deposit + resp = client.get( + f"/deposits/{dep_id}", + headers=auth_headers_for_user(random_user) + json_headers, + ) + assert resp.status_code == 403 + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits", + headers=auth_headers_for_user(random_user) + json_headers, + ) + assert resp.status_code == 200 + assert resp.json.get("hits", {}).get("total") == 0 + + # 'superuser' gives 'read' permissions to 'random_user' role + metadata = {"deposit": { + "read": { "roles": [test_role]} + }} + resp = client.post( + f"/jsonschemas/{schema_name}/permissions", + data=json.dumps(metadata), + headers=auth_headers_for_superuser+ json_headers, + ) + assert resp.status_code == 201 + + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits/{dep_id}", + headers=auth_headers_for_user(random_user) + json_headers, + ) + assert resp.status_code == 200 + + sleep(2) + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits", + headers=auth_headers_for_user(random_user), + ) + assert resp.status_code == 200 + assert resp.json.get("hits", {}).get("total") == 1 + + # 'random_user' can now GET created deposit + metadata = {"$ana_type": schema_name} + resp = client.post( + "/deposits/", + data=json.dumps(metadata), + headers=auth_headers_for_user(random_user) + json_headers, + ) + + assert resp.status_code == 201 + + # 'superuser' HAS permission to update schema permissions + permission_metadata = {"deposit": { + "create": { "users": ["wrong.user@mmail.com"], "roles": [test_role]} + }} + resp = client.post( + f"/jsonschemas/{schema_name}/permissions", + data=json.dumps(permission_metadata), + headers=auth_headers_for_superuser+ json_headers, + ) + assert resp.status_code == 201 + assert resp.json[0]["status"] == "error" + assert resp.json[1]["status"] == "error" + + sleep(1) + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits", + headers=auth_headers_for_user(other_user) + json_headers, + ) + assert resp.status_code == 200 + assert resp.json.get("hits", {}).get("total") == 1 + + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits", + headers=auth_headers_for_user(random_user) + json_headers, + ) + assert resp.status_code == 200 + assert resp.json.get("hits", {}).get("total") == 2 + + # 'superuser' HAS permission to update schema permissions + permission_metadata = {"deposit": { + "read": { "roles": [test_role]}, + "create": { "roles": [test_role]} + }} + resp = client.delete( + f"/jsonschemas/{schema_name}/permissions", + data=json.dumps(permission_metadata), + headers=auth_headers_for_superuser+ json_headers, + ) + assert resp.status_code == 202 + resp = client.get( + f"/jsonschemas/{schema_name}/permissions", + headers=auth_headers_for_superuser+ json_headers, + ) + + sleep(2) + + # 'random_user' can read other users deposit of this schema + resp = client.get( + f"/deposits", + headers=auth_headers_for_user(random_user), + ) + assert resp.status_code == 200 + assert resp.json.get("hits", {}).get("total") == 1 diff --git a/ui/cap-react/package.json b/ui/cap-react/package.json index e117147c1a..d89471106f 100644 --- a/ui/cap-react/package.json +++ b/ui/cap-react/package.json @@ -64,6 +64,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-formule": "file:.yalc/react-formule", + "react-infinite-scroll-component": "6.1.0", "react-input-mask": "3.0.0-alpha.2", "react-joyride": "^2.5.4", "react-markdown-editor-lite": "1.2.4", diff --git a/ui/cap-react/src/actions/builder.js b/ui/cap-react/src/actions/builder.js index 7426743f1c..6a893a685b 100644 --- a/ui/cap-react/src/actions/builder.js +++ b/ui/cap-react/src/actions/builder.js @@ -18,6 +18,8 @@ export const UPDATE_NOTIFICATIONS = "UPDATE_NOTIFICATIONS"; export const REMOVE_NOTIFICATION = "REMOVE_NOTIFICATION"; export const CREATE_NOTIFICATION_GROUP = "CREATE_NOTIFICATION_GROUP"; +export const SET_SCHEMA_PERMISSIONS = "SET_SCHEMA_PERMISSIONS"; + export const synchronizeFormuleState = value => ({ type: SYNCHRONIZE_FORMULE_STATE, value, @@ -215,3 +217,71 @@ export const saveSchemaChanges = () => (dispatch, getState) => { }) ); }; + +export const setSchemaPermissions = permissions => ({ + type: SET_SCHEMA_PERMISSIONS, + permissions, +}); + +export const getSchemaPermissions = (name, version = null) => { + let schemaPermissionLink; + + if (version) + schemaPermissionLink = `/api/jsonschemas/${name}/${version}/permissions`; + else schemaPermissionLink = `/api/jsonschemas/${name}/permissions`; + return function (dispatch) { + axios + .get(schemaPermissionLink) + .then(resp => { + dispatch(setSchemaPermissions(resp.data)); + }) + .catch(() => { + notification.error({ + message: "Fetching permissions failed", + description: "There was an error fetching the schema permissions", + }); + }); + }; +}; + +export const postSchemaPermissions = (name, version = null, permissions) => { + let schemaPermissionLink; + + if (version) + schemaPermissionLink = `/api/jsonschemas/${name}/${version}/permissions`; + else schemaPermissionLink = `/api/jsonschemas/${name}/permissions`; + return function (dispatch) { + axios + .post(schemaPermissionLink, permissions) + .then(() => { + dispatch(getSchemaPermissions(name, version)); + }) + .catch(() => { + notification.error({ + message: "Updating schema permissions failed", + description: "There was an error updating the schema permissions", + }); + }); + }; +}; + +export const deleteSchemaPermissions = (name, version = null, permissions) => { + let schemaPermissionLink; + + if (version) + schemaPermissionLink = `/api/jsonschemas/${name}/${version}/permissions`; + else schemaPermissionLink = `/api/jsonschemas/${name}/permissions`; + return function (dispatch) { + axios + .delete(schemaPermissionLink, { data: permissions }) + .then(() => { + dispatch(getSchemaPermissions(name, version)); + }) + .catch(() => { + notification.error({ + message: "Deleting schema permissions failed", + description: "There was an error deleting the schema permissions", + }); + }); + }; +}; diff --git a/ui/cap-react/src/actions/draftItem.js b/ui/cap-react/src/actions/draftItem.js index 9f603e15e4..2e35910487 100644 --- a/ui/cap-react/src/actions/draftItem.js +++ b/ui/cap-react/src/actions/draftItem.js @@ -792,3 +792,31 @@ export function getDraftById(draft_id, fetchSchemaFlag = false) { }); }; } + +export function addEgroupToDraft(draft_id, group) { + return () => { + let uri = `/api/deposits/${draft_id}/actions/egroups`; + let data = { + "type": "add-egroup", + "name": group.name, + "desciption": group.description + } + return axios + .post(uri, data) + .then(() => { + notification.success({ + message: "e-Group creation", + description: "The e-group was created and attahced to the entry succesfully", + }); + }) + .catch(error => { + let {response: {data}} = error; + notification.error({ + message: "Failed while creating e-group", + description: data.message, + }); + + throw error; + }); + }; +} diff --git a/ui/cap-react/src/antd/admin/components/AdminPanel.js b/ui/cap-react/src/antd/admin/components/AdminPanel.js index 6cba3af62f..473342b623 100644 --- a/ui/cap-react/src/antd/admin/components/AdminPanel.js +++ b/ui/cap-react/src/antd/admin/components/AdminPanel.js @@ -13,6 +13,7 @@ import useStickyState from "../../hooks/useStickyState"; import { PRIMARY_COLOR } from "../../utils/theme"; import { isEmpty } from "lodash-es"; import { initFormuleSchemaWithNotifications } from "../utils"; +import Permissions from "../permissions/Permissions"; const AdminPanel = ({ location, match, getSchema, loading, formuleState }) => { useEffect(() => { @@ -36,8 +37,25 @@ const AdminPanel = ({ location, match, getSchema, loading, formuleState }) => { const getPageTitle = () => location.pathname.includes("notifications") ? "Notifications" + : location.pathname.includes("permissions") + ? "Permissions" : "Form Builder"; + const getDisplay = () => { + switch (display) { + case "notifications": + return ; + case "permissions": + return ; + default: + return ( + + ); + } + }; + return ( @@ -65,13 +83,7 @@ const AdminPanel = ({ location, match, getSchema, loading, formuleState }) => { spotlight: { borderRadius: 0 }, }} /> - {display === "notifications" ? ( - - ) : ( - - )} + {getDisplay()} } type="primary" diff --git a/ui/cap-react/src/antd/admin/components/Header.js b/ui/cap-react/src/antd/admin/components/Header.js index 79d6b29873..fdbfd15c87 100644 --- a/ui/cap-react/src/antd/admin/components/Header.js +++ b/ui/cap-react/src/antd/admin/components/Header.js @@ -65,7 +65,9 @@ const Header = ({ const a = document.createElement("a"); const file = new Blob([fileData], { type: "text/json" }); a.href = URL.createObjectURL(file); - a.download = `${config.toJS().name || "cap-schema"}-export-v${config.toJS().version}-${Date.now()}.json`; + a.download = `${config.toJS().name || "cap-schema"}-export-v${ + config.toJS().version + }-${Date.now()}.json`; a.click(); }; const _renderSchemaPreview = schemaPreviewDisplay => { @@ -220,6 +222,12 @@ const Header = ({ icon: , className: "tour-notifications-tab", }, + { + key: "permissions", + label: "Settings", + icon: , + className: "tour-settings-tab", + }, ]} /> @@ -242,13 +250,6 @@ const Header = ({ () => setDiffModal(true), "tour-diff" ), - getMenuItem( - "settings", - "Settings", - , - () => setSettingsModal(true), - "tour-schema-settings" - ), getMenuItem("save", "Save updates", , () => saveSchemaChanges() ), diff --git a/ui/cap-react/src/antd/admin/permissions/AddPermissions.js b/ui/cap-react/src/antd/admin/permissions/AddPermissions.js new file mode 100644 index 0000000000..1e84eef0ed --- /dev/null +++ b/ui/cap-react/src/antd/admin/permissions/AddPermissions.js @@ -0,0 +1,90 @@ +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Form, Input, Space, Radio, Typography } from "antd"; +import axios from "../../../axios"; +import { debounce } from "lodash-es"; +import CollectionPermissions from "../../collection/CollectionPermissions"; + +const Permissions = ({ handlePermissions, permissions }) => { + const [ldapData, setLdapData] = useState([]); + const [tableLoading, setTableLoading] = useState(false); + const [searchFor, setSearchFor] = useState("user"); + const [form] = Form.useForm(); + + const fetchLDAPdata = debounce(async ({ searchInput }) => { + setTableLoading(true); + const response = await axios.get( + `/api/services/ldap/${searchFor}/mail?query=${searchInput}` + ); + + setLdapData( + response.data.map(item => ({ + target: searchFor, + key: item.email ? item.email : item, + email: item.email ? item.email : item, + department: item.email ? item.profile.department : "egroup", + name: item.email + ? item.profile.display_name + : item.split("@cern.ch")[0], + permission: [], + })) + ); + setTableLoading(false); + }, 500); + + return ( + <> +
+ values.searchInput && values.searchInput.length > 0 + ? fetchLDAPdata(values) + : setLdapData([]) && setTableLoading(false) + } + > + + + Search for: + setSearchFor(e.target.value)} + > + Users + E groups + + + } + /> + +
+ { + i["permissions"] = []; + return i; + })} + editable + dont + /> + + ); +}; + +Permissions.propTypes = { + draft_id: PropTypes.string, + handlePermissions: PropTypes.func, + permissions: PropTypes.object, + canAdmin: PropTypes.bool, + created_by: PropTypes.object, +}; + +export default Permissions; diff --git a/ui/cap-react/src/antd/admin/permissions/PermissionDropdown.js b/ui/cap-react/src/antd/admin/permissions/PermissionDropdown.js new file mode 100644 index 0000000000..f57f7e784e --- /dev/null +++ b/ui/cap-react/src/antd/admin/permissions/PermissionDropdown.js @@ -0,0 +1,173 @@ +import React, { useState, useEffect, useRef } from "react"; +import PropTypes from "prop-types"; +import { Button, Dropdown, Typography, Checkbox, Card } from "antd"; +import { DownOutlined } from "@ant-design/icons"; + +const DropDown = ({ + permissions = ["deposit-read"], + updatePermissions, +}) => { + const [visible, setVisible] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = event => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setVisible(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const getTextFromPermission = () => { + let selectedPerms = []; + + options.map(opt => { + if (opt.type == "group") { + opt.children.map(opch => { + if (permissions.includes(opch.key)) + selectedPerms.push(`${opch.label} (${opt.key})`); + }); + } else { + if (permissions.includes(opt.key)) selectedPerms.push(opt.label); + } + }); + + return selectedPerms.length > 0 + ? selectedPerms.join(", ") + : "Select from list"; + }; + + const options = [ + { + key: "D", + type: "group", + label: "Deposits/Drafts", + children: [ + { + key: "deposit-schema-admin", + label: "Admin", + title: "Schema Admin", + description: + "Can read, create and update any draft entry of this collection. Can also publish and upload files", + }, + { + key: "deposit-schema-read", + label: "Read", + title: "Schema Read", + description: "Can read and search draft entries of this collection.", + }, + { + key: "deposit-schema-update", + label: "Update", + title: "Schema Update", + description: + "Can read, create and update any draft entry of this collection. Can also publish and upload files", + }, + { + key: "deposit-schema-create", + label: "Create", + title: "Schema Create", + description: "Can create draft entries for this collection", + }, + { + key: "deposit-schema-review", + label: "Review", + title: "Schema Review", + description: + "Can search, read and review any draft entry of this collection", + }, + ], + }, + { + key: "R", + type: "group", + label: "Records/Published", + children: [ + { + key: "record-schema-read", + label: "Read", + title: "Schema Read", + description: + "Can read and search published entries of this collection.", + }, + { + key: "record-schema-review", + label: "Review", + title: "Schema Review", + description: + "Can search, read and review any published entry of this collection", + }, + ], + }, + ]; + + const generateMenuItems = _options => { + return _options.map(option => { + if (option.type == "group") { + return { + ...option, + children: option.children ? generateMenuItems(option.children) : [], + }; + } else { + return { + key: option.key, + label: ( + updatePermissions(option.key)} + > + {option.title} + {option.description} + + ), + }; + } + }); + }; + + return ( +
+ } + menu={{ + items: generateMenuItems(options), + }} + open={visible} + onOpenChange={flag => { + if (flag) { + setVisible(true); + } + }} + dropdownRender={menu => { + return ( + setVisible(false)}>Close, + ]} + > + {menu} + + ); + }} + > + {getTextFromPermission()} + +
+ ); +}; + +DropDown.propTypes = { + isOwner: PropTypes.bool, + shouldDisableOptions: PropTypes.bool, + updatePermissions: PropTypes.func, + permission: PropTypes.array, +}; + +export default DropDown; diff --git a/ui/cap-react/src/antd/admin/permissions/Permissions.js b/ui/cap-react/src/antd/admin/permissions/Permissions.js new file mode 100644 index 0000000000..0f98b91d10 --- /dev/null +++ b/ui/cap-react/src/antd/admin/permissions/Permissions.js @@ -0,0 +1,225 @@ +import { useEffect, useState } from "react"; +import { connect } from "react-redux"; +import PropTypes from "prop-types"; +import { + Alert, + Layout, + Space, + Typography, + Tabs, + Col, + Card, + Button, +} from "antd"; +import CollectionPermissions from "../../collection/CollectionPermissions"; +import { + getSchemaPermissions, + postSchemaPermissions, + deleteSchemaPermissions, + updateSchemaConfig, +} from "../../../actions/builder"; +import AddPermissions from "./AddPermissions"; +import { Map } from "immutable"; +import { + CloseSquareFilled, + EditFilled, + PlusCircleOutlined, +} from "@ant-design/icons"; + +import { configSchema } from "../utils/schemaSettings"; +import { FormuleForm } from "react-formule"; + +const Permissions = ({ + schemaName, + schemaVersion, + permissions = null, + getSchemaPermissions, + postSchemaPermissions, + deleteSchemaPermissions, + config, + updateSchemaConfig, +}) => { + const [editable, setEditable] = useState(false); + const [addEnabled, setAddEnabled] = useState(false); + useEffect(() => { + getSchemaPermissions(schemaName, schemaVersion); + }, []); + + const addSchemaPermissionsToEmail = ( + email, + permissions, + type = "user", + action = "add" + ) => { + let permission_data = {}; + const _type = type == "user" ? "users" : "roles"; + permissions.map(p => { + if (p.startsWith("record-schema-")) { + let action = p.replace("record-schema-", ""); + if (!permission_data["record"]) permission_data["record"] = {}; + if (!permission_data["record"][action]) + permission_data["record"][action] = {}; + + permission_data["record"][action][_type] = [email]; + } else if (p.startsWith("deposit-schema-")) { + let action = p.replace("deposit-schema-", ""); + if (!permission_data["deposit"]) permission_data["deposit"] = {}; + if (!permission_data["deposit"][action]) + permission_data["deposit"][action] = {}; + permission_data["deposit"][action][_type] = [email]; + } + }); + + if (action == "delete") + deleteSchemaPermissions(schemaName, schemaVersion, permission_data); + else postSchemaPermissions(schemaName, schemaVersion, permission_data); + }; + + return ( + + + + updateSchemaConfig(data.formData)} + /> + + ), + }, + { + label: "Permissions", + key: "permissions", + children: ( + + {!addEnabled && ( + + )} + {!editable && ( + + )} + + } + > + {addEnabled && ( + + )} + {!addEnabled ? ( + <> + + + Here you can manage access to your{" "} + + {schemaName} ({schemaVersion}) + {" "} + collection. You can determine who can perform specific + action for both states of your document + (draft/published) + + + + + Actions: + read, + create, + update, + admin, + review + + + + {editable && ( + + )} + + + ) : ( + + )} + + ), + }, + ]} + /> + + + ); +}; + +Permissions.propTypes = { + schemaConfig: PropTypes.object, + createNotificationCategory: PropTypes.func, +}; + +const mapStateToProps = state => { + return { + schemaName: state.builder.get("formuleState").config.name, + schemaVersion: state.builder.get("formuleState").config.version, + permissions: state.builder.get("permissions"), + config: state.builder.get("config"), + }; +}; + +const mapDispatchToProps = dispatch => ({ + getSchemaPermissions: (schema, version) => + dispatch(getSchemaPermissions(schema, version)), + postSchemaPermissions: (schema, version, permissions) => + dispatch(postSchemaPermissions(schema, version, permissions)), + deleteSchemaPermissions: (schema, version, permissions) => + dispatch(deleteSchemaPermissions(schema, version, permissions)), + updateSchemaConfig: config => dispatch(updateSchemaConfig(config)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Permissions); diff --git a/ui/cap-react/src/antd/admin/utils/schemaSettings.js b/ui/cap-react/src/antd/admin/utils/schemaSettings.js index b7e095cef2..0feab584fb 100644 --- a/ui/cap-react/src/antd/admin/utils/schemaSettings.js +++ b/ui/cap-react/src/antd/admin/utils/schemaSettings.js @@ -67,6 +67,7 @@ export const configSchema = { type: "string", title: "Schema ID", description: "Unique ID of the schema", + readOnly: true }, version: { type: "string", diff --git a/ui/cap-react/src/antd/admin/utils/tour/admin.js b/ui/cap-react/src/antd/admin/utils/tour/admin.js index fe1b9cee33..72a320a386 100644 --- a/ui/cap-react/src/antd/admin/utils/tour/admin.js +++ b/ui/cap-react/src/antd/admin/utils/tour/admin.js @@ -63,14 +63,14 @@ export const steps = [ "You can see the JSON schema representation of your form and a diff view of all the current unsaved changes here.", }, { - target: ".tour-schema-settings", + target: ".tour-notifications-tab", content: - "On a new schema, you will have to provide some settings like id, version, name or experiment before being able to save your changes.", + "The notifications tab allows you create notification templates and define patterns to send them to the appropriate users when an event takes place.", }, { - target: ".tour-notifications-tab", + target: ".tour-settings-tab", content: - "The notifications tab allows you create notification templates and define patterns to send them to the appropriate users when an event takes place.", + "On a new schema, you will have to provide some settings like id, version, name or experiment before being able to save your changes. Here you can also define permissions for the schema.", }, { target: "body", diff --git a/ui/cap-react/src/antd/collection/CollectionPermissions.js b/ui/cap-react/src/antd/collection/CollectionPermissions.js index d128a1719b..96fb965d91 100644 --- a/ui/cap-react/src/antd/collection/CollectionPermissions.js +++ b/ui/cap-react/src/antd/collection/CollectionPermissions.js @@ -1,61 +1,50 @@ import { permissionsPerUser } from "../utils"; -import { CloseOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons"; -import { Empty, Space, Table, Tag, Tooltip } from "antd"; +import { TeamOutlined, UserOutlined } from "@ant-design/icons"; +import { Empty, Table, Tooltip, Typography } from "antd"; import PropTypes from "prop-types"; import { useEffect, useState } from "react"; +import CollectionPermissionsColumn from "./CollectionPermissionsColumn"; -const CollectionPermissions = ({ permissions }) => { - const [permissionsArray, setPermissionsArray] = useState(); - +const CollectionPermissions = ({ dont, permissions, editable, handlePermissions, defaultPermissions }) => { + const [permissionsArray, setPermissionsArray] = useState([]); useEffect(() => { - if (permissions) { + if (dont) { + setPermissionsArray(permissions) + } + else if (permissions) { const { permissionsArray } = permissionsPerUser(permissions.toJS()); setPermissionsArray(permissionsArray); } - }, [permissions]); - const renderPermissionType = (e, type) => { - let tags = []; - if (e.includes(`deposit-schema-${type}`)) { - tags.push( - - - D - - - ); - } - if (e.includes(`record-schema-${type}`)) { - tags.push( - - - P - - - ); + if (defaultPermissions) { + const permissionsMap = {}; + let { permissionsArray: perms} = permissionsPerUser(defaultPermissions) + for (const item of perms) { + permissionsMap[item.email] = item.permissions; + } + + const mergedArray = permissions.map((item) => ({ + ...item, + permission: permissionsMap[item.email] || [], + permissions: permissionsMap[item.email] || [], + })); + + setPermissionsArray(mergedArray) } - return tags.length ? ( - {tags} - ) : ( - - ); - }; + + }, [permissions, defaultPermissions]); return permissionsArray && permissionsArray.length > 0 ? ( - e === "egroup" ? ( + render: (e, item) => + {item.type === "egroup" ? ( @@ -63,43 +52,44 @@ const CollectionPermissions = ({ permissions }) => { - ), + )} {e} + }, { title: "Read", dataIndex: "permissions", key: "read", align: "center", - render: e => renderPermissionType(e, "read"), + render: (e, item) => , }, { title: "Create", dataIndex: "permissions", key: "create", align: "center", - render: e => renderPermissionType(e, "create"), + render: (e, item) => , }, { title: "Review", dataIndex: "permissions", key: "review", align: "center", - render: e => renderPermissionType(e, "review"), + render: (e, item) => , }, { title: "Update", dataIndex: "permissions", key: "update", align: "center", - render: e => renderPermissionType(e, "update"), + render: (e, item) => , }, { title: "Admin", dataIndex: "permissions", key: "admin", align: "center", - render: e => renderPermissionType(e, "admin"), - }, + render: (e, item) => , + } ]} dataSource={permissionsArray} fixedHeader diff --git a/ui/cap-react/src/antd/collection/CollectionPermissionsColumn.js b/ui/cap-react/src/antd/collection/CollectionPermissionsColumn.js new file mode 100644 index 0000000000..f98d5a27f7 --- /dev/null +++ b/ui/cap-react/src/antd/collection/CollectionPermissionsColumn.js @@ -0,0 +1,88 @@ +import { CloseOutlined } from "@ant-design/icons"; +import { Space, Tag, Tooltip } from "antd"; +import PropTypes from "prop-types"; +import { useState } from "react"; + +const { CheckableTag } = Tag; + +const CollectionPermissionsColumn = ({ + e, + item, + type, + handlePermissions, + editable = false, +}) => { + const [draftIncluded, setDraftIncluded] = useState( + e.includes(`deposit-schema-${type}`) + ); + const [pubIncluded, setPubIncluded] = useState( + e.includes(`record-schema-${type}`) + ); + + const requestPermissionUpdate = (rec_type, checked) => { + handlePermissions( + item.email, + [`${rec_type}-schema-${type}`], + item.type, + checked ? "add" : "delete" + ); + rec_type == "record" ? setPubIncluded(checked) : setDraftIncluded(checked); + }; + + let tags = []; + + if (editable) { + tags = [ + + requestPermissionUpdate("deposit", checked)} + color="geekblue" + style={{ border: "1px solid #ccc", marginRight: 0 }} + > + D + + , + + requestPermissionUpdate("record", checked)} + color="purple" + style={{ border: "1px solid #ccc", marginRight: 0 }} + > + P + + , + ]; + } else { + if (draftIncluded || editable) { + tags.push( + + + D + + + ); + } + if (pubIncluded || editable) + tags.push( + + + P + + + ); + } + + return tags.length ? ( + {tags} + ) : ( + + ); +}; + +CollectionPermissionsColumn.propTypes = { + permissions: PropTypes.object, +}; + +export default CollectionPermissionsColumn; diff --git a/ui/cap-react/src/antd/drafts/components/Permissions/Permissions.js b/ui/cap-react/src/antd/drafts/components/Permissions/Permissions.js index 9df0726248..284beac601 100644 --- a/ui/cap-react/src/antd/drafts/components/Permissions/Permissions.js +++ b/ui/cap-react/src/antd/drafts/components/Permissions/Permissions.js @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import PropTypes from "prop-types"; import { permissionsPerUser } from "../../../utils"; import DropDown from "./DropDown"; -import { DeleteOutlined } from "@ant-design/icons"; +import { DeleteOutlined, PlusCircleFilled } from "@ant-design/icons"; import { Button, Card, @@ -250,18 +250,22 @@ const Permissions = ({ /> } + size="small" + type="default" onClick={() => setDisplayModal(true)} > - Add + Add Permissions } >
{ const publishMyDraft = () => { @@ -39,8 +44,13 @@ const Settings = ({ setConfirmPublish(false); }; const [confirmPublish, setConfirmPublish] = useState(false); + const [createModalEnabled, setCreateModalEnabled] = useState(false); + + const _addEgroup = group => { + addEgroupToDraft(draft_id, group); + }; return ( - + + + {egroups && ( + } + onClick={() => setCreateModalEnabled(true)} + type="default" + > + Create e-group + + } + > + + Here you can find and link{" "} + CERN E-groups{" "} + associated with this entry + + setCreateModalEnabled(false)} + > +
+ {/* */} + + + + + + + + + + {/* */} + +
+
{i}, + }, + { + key: "description", + dataIndex: "description", + title: "Description", + width: "65%", + }, + ]} + /> + + )} ({ recid: state.draftItem.get("recid"), status: state.draftItem.get("status"), + egroups: state.draftItem.get("egroups"), canUpdate: state.draftItem.get("can_update"), formData: state.draftItem.get("formData"), canAdmin: state.draftItem.get("can_admin"), @@ -18,6 +20,7 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ publishDraft: draft_id => dispatch(publishDraft(draft_id)), + addEgroupToDraft: (draft_id, group) => dispatch(addEgroupToDraft(draft_id, group)), updateDraft: (data, draft_id) => dispatch(updateDraft(data, draft_id)), deleteDraft: draft_id => dispatch(deleteDraft(draft_id)) }); diff --git a/ui/cap-react/src/antd/drafts/containers/SideBar.js b/ui/cap-react/src/antd/drafts/containers/SideBar.js index bab1310c20..4c0bfcea78 100644 --- a/ui/cap-react/src/antd/drafts/containers/SideBar.js +++ b/ui/cap-react/src/antd/drafts/containers/SideBar.js @@ -10,6 +10,7 @@ const mapStateToProps = state => ({ status: state.draftItem.get("status"), schema: state.draftItem.get("schema"), experiment: state.draftItem.get("experiment"), + egroups: state.draftItem.get("egroups"), revision: state.draftItem.get("revision"), created_by: state.draftItem.get("created_by"), created: state.draftItem.get("created"), diff --git a/ui/cap-react/src/antd/forms/customFields/IdFetcher.js b/ui/cap-react/src/antd/forms/customFields/IdFetcher.js new file mode 100644 index 0000000000..d6a4e9d5fb --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/IdFetcher.js @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { Button, Col, Input, Row, Select, Space, Typography } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; +import Icon from "@ant-design/icons"; +import CAPDeposit from "./services/CAPDeposit"; +import CapSvg from "./services/svg/CapSvg"; +import axios from "../../../axios"; +import OrcidSvg from "./services/svg/OrcidSvg"; +import RorSvg from "./services/svg/RorSvg"; +import ZenodoSvg from "./services/svg/ZenodoSvg"; +import Ror from "./services/Ror"; +import Zenodo from "./services/Zenodo"; +import Orcid from "./services/Orcid"; + +// TODO: This component needs to be rethought: we either keep it like this or we separate +// the cap records and cap deposits fetchers into a different field + +const SERVICES = { + orcid: { + name: "ORCiD", + url: "/api/services/orcid/", + svg: OrcidSvg, + }, + ror: { + name: "ROR", + url: "/api/services/ror/", + svg: RorSvg, + }, + zenodo: { + name: "Zenodo", + url: "/api/services/zenodo/record/", + svg: ZenodoSvg, + }, + capRecords: { + name: "CAP Records", + url: "/api/records/", + svg: CapSvg, + }, + capDeposits: { + name: "CAP Deposits", + url: "/api/deposits/", + svg: CapSvg, + }, +}; + +const IdFetcher = ({ formData = {}, uiSchema, onChange }) => { + const [service, setService] = useState(); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(undefined); + + useEffect(() => { + if (uiSchema?.["ui:servicesList"]?.length === 1) { + setService(uiSchema["ui:servicesList"]); + } + }, [uiSchema]); + + const getContentByName = name => { + const choices = { + ror: , + zenodo: , + orcid: , + capDeposits: , + }; + return choices[name] || {JSON.stringify(formData.fetched)}; + }; + + const getId = (service, id) => { + switch (service) { + case "zenodo": + if (id.match(/\W+(zenodo.org)/)) { + return id.match(/(record\/)(.*)(#*)/)[2]; + } + break; + case "orcid": + if (id.match(/\W+(orcid.org)/)) { + return id.match(/(orcid.org\/)(.*)/)[2]; + } + break; + case "ror": + if (id.match(/\W+(ror.org)/)) { + return id.match(/(ror.org\/)(.*)/)[2]; + } + break; + + default: + return id; + } + + return id; + }; + const onSearch = async val => { + setErrorMessage(undefined); + const currentServiceApi = SERVICES[service].url || null; + const resourceID = getId(service, val); + if (currentServiceApi && !resourceID == "") { + setLoading(true); + try { + const results = await axios.get(currentServiceApi + resourceID); + let { data } = results; + const resource_data = { + source: { + service: service, + externalID: resourceID, + }, + fetched: data, + }; + onChange(resource_data); + } catch (e) { + setErrorMessage("Resource not found or inaccessible"); + } + setLoading(false); + } else { + setErrorMessage("Please make sure you filled in all the inputs"); + } + }; + + if (formData.fetched) { + return ( +
+ +
{getContentByName(formData.source.service)} + + + + + + + + {data.person.name["given-names"].value[0]} + {data.person.name["family-name"].value[0]} + + + + + {data.person.name["given-names"].value}{" "} + {data.person.name["family-name"].value} + + + + {data["orcid-identifier"].uri} + + + + + ); +}; + +Orcid.propTypes = { + data: PropTypes.object, +}; + +export default Orcid; diff --git a/ui/cap-react/src/antd/forms/customFields/services/Ror.js b/ui/cap-react/src/antd/forms/customFields/services/Ror.js new file mode 100644 index 0000000000..0ced286114 --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/Ror.js @@ -0,0 +1,34 @@ +import PropTypes from "prop-types"; +import { Descriptions, Space, Tag, Typography } from "antd"; + +const Ror = ({ data }) => { + return ( + + + + + {data.name} + + {data.acronyms && data.acronyms.length > 0 && ( + {data.acronyms} + )} + + + + {data.country.country_name} ({data.country.country_code}) + + + {data.types && data.types.map(type => {type})} + + + {data.links} + + + ); +}; + +Ror.propTypes = { + data: PropTypes.object, +}; + +export default Ror; diff --git a/ui/cap-react/src/antd/forms/customFields/services/Zenodo.js b/ui/cap-react/src/antd/forms/customFields/services/Zenodo.js new file mode 100644 index 0000000000..1a6a96543d --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/Zenodo.js @@ -0,0 +1,21 @@ +import PropTypes from "prop-types"; +import { Descriptions } from "antd"; + +const Zenodo = ({ data }) => { + return ( + + {data.id} + {data.metadata.title} + {data.metadata.doi} + + {data.links.self} + + + ); +}; + +Zenodo.propTypes = { + data: PropTypes.object, +}; + +export default Zenodo; diff --git a/ui/cap-react/src/antd/forms/customFields/services/svg/CapSvg.js b/ui/cap-react/src/antd/forms/customFields/services/svg/CapSvg.js new file mode 100644 index 0000000000..53f9045098 --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/svg/CapSvg.js @@ -0,0 +1,86 @@ +const CapSvg = () => ( + + + + + + + + + + + + + + + + + + + +); + +export default CapSvg; diff --git a/ui/cap-react/src/antd/forms/customFields/services/svg/OrcidSvg.js b/ui/cap-react/src/antd/forms/customFields/services/svg/OrcidSvg.js new file mode 100644 index 0000000000..8e05511bd5 --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/svg/OrcidSvg.js @@ -0,0 +1,30 @@ +const OrcidSvg = () => ( + + + + + + + + +); + +export default OrcidSvg; diff --git a/ui/cap-react/src/antd/forms/customFields/services/svg/RorSvg.js b/ui/cap-react/src/antd/forms/customFields/services/svg/RorSvg.js new file mode 100644 index 0000000000..c3d9c57572 --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/svg/RorSvg.js @@ -0,0 +1,29 @@ +const RorSvg = () => ( + + + + + + + + + +); + +export default RorSvg; diff --git a/ui/cap-react/src/antd/forms/customFields/services/svg/ZenodoSvg.js b/ui/cap-react/src/antd/forms/customFields/services/svg/ZenodoSvg.js new file mode 100644 index 0000000000..ed6f4f017c --- /dev/null +++ b/ui/cap-react/src/antd/forms/customFields/services/svg/ZenodoSvg.js @@ -0,0 +1,87 @@ +const ZenodoSvg = () => ( + + + + + + + + + + +); + +export default ZenodoSvg; diff --git a/ui/cap-react/src/antd/forms/formuleConfig.js b/ui/cap-react/src/antd/forms/formuleConfig.js index 8b23306363..93034fa8d4 100644 --- a/ui/cap-react/src/antd/forms/formuleConfig.js +++ b/ui/cap-react/src/antd/forms/formuleConfig.js @@ -1,9 +1,11 @@ import { commonFields, extraFields } from "react-formule"; -import { FileOutlined } from "@ant-design/icons"; +import { CloudDownloadOutlined, FileOutlined } from "@ant-design/icons"; import CernUsers from "./customFields/CernUsers"; import CapFiles from "./customFields/CapFiles"; import SchemaPathSuggester from "./customFields/SchemaPathSuggester"; +import IdFetcher from "./customFields/IdFetcher"; +import ImportDataField from "./customFields/ImportDataField"; export const customFieldTypes = { advanced: { @@ -40,6 +42,159 @@ export const customFieldTypes = { }, }, }, + idFetcher: { + title: "ID fetcher", + icon: , + description: "Fetch data from ZENODO, ORCiD or ROR", + child: {}, + optionsSchema: { + type: "object", + title: "ID Fetcher Field Schema", + properties: { + ...commonFields.optionsSchema, + readOnly: extraFields.optionsSchema.readOnly, + isRequired: extraFields.optionsSchema.isRequired, + }, + }, + optionsSchemaUiSchema: { + readOnly: extraFields.optionsSchemaUiSchema.readOnly, + isRequired: extraFields.optionsSchemaUiSchema.isRequired, + }, + optionsUiSchema: { + type: "object", + title: "UI Schema", + properties: { + ...commonFields.optionsUiSchema.properties, + "ui:servicesList": { + title: "Select the services you want to allow", + type: "array", + items: { + type: "string", + oneOf: [ + { const: "orcid", title: "ORCiD" }, + { const: "ror", title: "ROR" }, + { const: "zenodo", title: "Zenodo" }, + { const: "capRecords", title: "CAP Records" }, + { const: "capDeposits", title: "CAP Deposits" }, + ], + }, + uniqueItems: "true", + }, + }, + }, + optionsUiSchemaUiSchema: { + ...commonFields.optionsUiSchemaUiSchema, + "ui:servicesList": { + "ui:widget": "checkbox", + }, + }, + default: { + schema: { + type: "object", + properties: {}, + }, + uiSchema: { + "ui:serfvicesList": ["orcid", "ror", "zenodo"], + "ui:servicesList": ["capDeposits"], + "ui:field": "idFetcher", + }, + }, + }, + }, + simple: { + importData: { + title: "Import Data", + icon: , + description: "Provided a URL or query", + child: {}, + optionsSchema: { + type: "object", + title: "File upload widget", + properties: { + ...commonFields.optionsSchema, + readOnly: extraFields.optionsSchema.readOnly, + isRequired: extraFields.optionsSchema.isRequired, + }, + }, + optionsSchemaUiSchema: { + readOnly: extraFields.optionsSchemaUiSchema.readOnly, + isRequired: extraFields.optionsSchemaUiSchema.isRequired, + }, + optionsUiSchema: { + ...commonFields.optionsUiSchema, + properties: { + ...commonFields.optionsUiSchema.properties, + "x-cap-import-data": { + type: "object", + properties: { + queryUrl: { + title: "Query URL", + description: "URL to query for and wait for response data", + type: "string", + placeholder: "/api/deposits", + }, + queryParam: { + type: "string", + title: "Query Param", + description: "URL to query for and wait for response data", + }, + hitTitle: { + type: "string", + title: "Item Title", + description: + "What to display as result item title? Should be path of the response data, e.g 'metadata.general_title'", + }, + hitDescription: { + type: "string", + title: "Item Description", + description: + "What to display as result item description? Should be path of the response data, e.g 'metadata.general_title'", + }, + resultsPath: { + type: "string", + title: "Results path", + description: + "If response data is not an array, provide the path in the data where the results array exists. E.g 'hits.hits'", + }, + resultsTotalPath: { + type: "string", + title: "Results Total path", + description: + "If results total is returned, specify the path in the response data. E.g 'hits.hits'", + }, + }, + }, + }, + }, + optionsUiSchemaUiSchema: { + ...commonFields.optionsUiSchemaUiSchema, + "x-cap-import-data": { + queryUrl: { + "ui:placeholder": "/api/deposits", + }, + queryParam: { + "ui:placeholder": "q", + }, + hitTitle: { + "ui:placeholder": "metadata.general_title", + }, + hitDescription: { + "ui:placeholder": "created_by.email", + }, + resultsPath: { + "ui:placeholder": "hits.hits", + }, + }, + }, + default: { + schema: { + type: "object", + }, + uiSchema: { + "ui:field": "importData", + }, + }, + }, }, }; @@ -47,4 +202,6 @@ export const customFields = { cernUsers: CernUsers, CapFiles: CapFiles, schemaPathSuggester: SchemaPathSuggester, + idFetcher: IdFetcher, + importData: ImportDataField, }; diff --git a/ui/cap-react/src/antd/partials/Reviews/ReviewList.js b/ui/cap-react/src/antd/partials/Reviews/ReviewList.js index fa370c35df..f304fe6491 100644 --- a/ui/cap-react/src/antd/partials/Reviews/ReviewList.js +++ b/ui/cap-react/src/antd/partials/Reviews/ReviewList.js @@ -14,6 +14,7 @@ const ReviewList = ({ }) => { return ( ( diff --git a/ui/cap-react/src/antd/partials/Reviews/Reviews.js b/ui/cap-react/src/antd/partials/Reviews/Reviews.js index 337b867343..5363f6470d 100644 --- a/ui/cap-react/src/antd/partials/Reviews/Reviews.js +++ b/ui/cap-react/src/antd/partials/Reviews/Reviews.js @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import { Button, Card, Form, Input, Modal, Radio, Typography } from "antd"; import ReviewList from "./ReviewList"; +import { PlusCircleFilled } from "@ant-design/icons"; const Reviews = ({ review, @@ -145,12 +146,15 @@ const Reviews = ({ ) ) : ( } + size="small" + type="default" onClick={() => setShowModal(true)} > Add Review diff --git a/ui/cap-react/src/antd/partials/Utils/schema.js b/ui/cap-react/src/antd/partials/Utils/schema.js index e3272eb8fc..9192de587c 100644 --- a/ui/cap-react/src/antd/partials/Utils/schema.js +++ b/ui/cap-react/src/antd/partials/Utils/schema.js @@ -15,6 +15,7 @@ export const transformSchema = schema => { "_user_edited", "control_number", "_review", + "_egroups", ]; schema = { diff --git a/ui/cap-react/src/antd/search/Loaders/Results.js b/ui/cap-react/src/antd/search/Loaders/Results.js index 2a75703d39..e7a5538fef 100644 --- a/ui/cap-react/src/antd/search/Loaders/Results.js +++ b/ui/cap-react/src/antd/search/Loaders/Results.js @@ -1,9 +1,9 @@ import { Skeleton } from "antd"; const Results = () => { - return [...Array(5)].map(item => ( + return [...Array(5)].map((_, index) => (
diff --git a/ui/cap-react/src/antd/search/components/Header.js b/ui/cap-react/src/antd/search/components/Header.js index 88200cc1e2..9e962718fd 100644 --- a/ui/cap-react/src/antd/search/components/Header.js +++ b/ui/cap-react/src/antd/search/components/Header.js @@ -1,7 +1,8 @@ import PropTypes from "prop-types"; import queryString from "query-string"; -import { Button, Row, Select, Space, Typography } from "antd"; -import { FilterOutlined } from "@ant-design/icons"; +import { Button, Dropdown, Row, Select, Space, Typography } from "antd"; +import { EllipsisOutlined, FilterOutlined } from "@ant-design/icons"; +import { DRAFTS, PUBLISHED } from "../../routes"; const SORT_OPTIONS = [ { value: "mostrecent", label: "Newest First" }, @@ -9,6 +10,17 @@ const SORT_OPTIONS = [ { value: "bestmatch", label: "Best Match" }, ]; +const EXPORT_OPTIONS = [ + { + key: "csv", + label: "Export CSV", + }, + { + key: "xml", + label: "Export XML", + }, +]; + const getValueFromLocation = (value = "mostrecent") => { const choices = { mostrecent: "Newest First", @@ -30,6 +42,45 @@ const Header = ({ let searchParams = queryString.parse(location.search); let sortParam = searchParams.sort || "mostrecent"; + + const onExport = async (mimetype) => { + let acceptHeader = null; + if (mimetype == "csv") { acceptHeader = "application/csv"} + else if (mimetype == "xml") { acceptHeader = "application/marcxml+xml"} + else return; + + const timestamp = new Date().getTime(); + const filename = `export-${timestamp}.${mimetype}` + + let record_type = null; + if (location.pathname == DRAFTS) { record_type = "deposits"} + else if (location.pathname == PUBLISHED) { record_type = "records"} + else return; + + try { + const apiUrl = `/api/${record_type}/${location.search}`; + const headers = { 'Accept': acceptHeader }; + + const response = await fetch(apiUrl, { headers }); + + if (!response.ok) { + throw new Error(`Error: ${response.statusText}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + } catch (error) { + console.error('Download failed', error); + } + }; + // let isDescending = sortParam && sortParam[0] == "-"; // TODO: For asc/desc sorting // let sortValue = isDescending ? sortParam.substring(1) : sortParam; @@ -60,7 +111,7 @@ const Header = ({ )} {shouldDisplayFacetButton && ( - )} @@ -82,6 +133,16 @@ const Header = ({ ))} + onExport(e.key) + }} + > +