From 831e1c1500bb74fd251b0431e11d5de85f603dd8 Mon Sep 17 00:00:00 2001 From: pamfilos Date: Thu, 26 Oct 2023 17:10:55 +0200 Subject: [PATCH 1/9] global: support to create,link of CERN groups Signed-off-by: pamfilos --- cap/config.py | 5 + cap/modules/deposit/api.py | 4 +- cap/modules/deposit/egroups.py | 137 +++++++++++++++++ cap/modules/deposit/errors.py | 12 ++ cap/modules/deposit/permissions.py | 3 +- .../deposit/serializers/schemas/json.py | 2 +- .../records/serializers/schemas/common.py | 9 ++ .../records/serializers/schemas/json.py | 80 ++++++---- .../schemas/jsonschemas/schema_config.py | 1 + cap/modules/services/utils/cern_egroups.py | 82 ++++++++++ cap/modules/services/utils/helpers.py | 140 ++++++++++++++++++ setup.py | 1 + ui/cap-react/src/actions/draftItem.js | 28 ++++ .../components/Permissions/Permissions.js | 10 +- .../drafts/components/Settings/Settings.js | 93 +++++++++++- .../src/antd/drafts/containers/Settings.js | 5 +- .../src/antd/drafts/containers/SideBar.js | 1 + .../src/antd/partials/Reviews/ReviewList.js | 1 + .../src/antd/partials/Reviews/Reviews.js | 6 +- .../src/antd/partials/Utils/schema.js | 1 + 20 files changed, 579 insertions(+), 42 deletions(-) create mode 100644 cap/modules/deposit/egroups.py create mode 100644 cap/modules/services/utils/cern_egroups.py create mode 100644 cap/modules/services/utils/helpers.py diff --git a/cap/config.py b/cap/config.py index 683dd5c3bd..be4e41e82d 100644 --- a/cap/config.py +++ b/cap/config.py @@ -956,3 +956,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/api.py b/cap/modules/deposit/api.py index 44d065d7f5..c23719fd2a 100644 --- a/cap/modules/deposit/api.py +++ b/cap/modules/deposit/api.py @@ -77,6 +77,7 @@ get_existing_or_register_user, ) +from .egroups import CERNEgroupMixin from .errors import ( DepositValidationError, ReviewError, @@ -104,6 +105,7 @@ "_buckets", "_files", "_review", + "_egroups", "_experiment", "_access", "_user_edited", @@ -134,7 +136,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/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/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/serializers/schemas/json.py b/cap/modules/deposit/serializers/schemas/json.py index 7b882a16e2..2dd21c0cb3 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: diff --git a/cap/modules/records/serializers/schemas/common.py b/cap/modules/records/serializers/schemas/common.py index 20f89faa5b..2bfeeb9810 100644 --- a/cap/modules/records/serializers/schemas/common.py +++ b/cap/modules/records/serializers/schemas/common.py @@ -139,6 +139,7 @@ class CommonRecordSchema(Schema, StrictKeysMixin): 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) @@ -202,6 +203,7 @@ def get_metadata(self, obj): '_access', '_files', '_review', + '_egroups', '_fetched_from', '_user_edited', '_collection', @@ -209,6 +211,13 @@ def get_metadata(self, obj): } return result + def get_egroups(self, obj): + _egroups = obj.get("metadata", {}).get("_egroups", []) + if "deposit" not in obj or 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..3b931b10a9 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,38 @@ 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", + ) return links @@ -164,7 +177,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 +187,7 @@ def get_metadata(self, obj): '_access', '_files', '_review', + '_egroups', '_user_edited', '_fetched_from', '_collection', @@ -201,10 +217,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/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/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/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/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/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 a7bcd2b951..5db095630e 100644 --- a/ui/cap-react/src/antd/partials/Utils/schema.js +++ b/ui/cap-react/src/antd/partials/Utils/schema.js @@ -14,6 +14,7 @@ export const transformSchema = schema => { "_user_edited", "control_number", "_review", + "_egroups", ]; schema.properties = omit(schema.properties, schemaFieldsToRemove); From 1428b01611f8f29ebc39825906545191e45f4650 Mon Sep 17 00:00:00 2001 From: pamfilos Date: Tue, 24 Oct 2023 11:50:07 +0200 Subject: [PATCH 2/9] ui: forms - update serviceGetter for CAP records Signed-off-by: pamfilos --- .../src/antd/admin/utils/fieldTypes.js | 49 ++++- .../src/antd/forms/fields/ImportDataField.js | 179 ++++++++++++++++++ .../src/antd/forms/fields/ServiceGetter.js | 28 ++- ui/cap-react/src/antd/forms/fields/index.js | 2 + .../antd/forms/fields/services/CAPDeposit.js | 49 +++++ .../antd/forms/fields/services/svg/capLogo.js | 89 +++++++++ 6 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 ui/cap-react/src/antd/forms/fields/ImportDataField.js create mode 100644 ui/cap-react/src/antd/forms/fields/services/CAPDeposit.js create mode 100644 ui/cap-react/src/antd/forms/fields/services/svg/capLogo.js diff --git a/ui/cap-react/src/antd/admin/utils/fieldTypes.js b/ui/cap-react/src/antd/admin/utils/fieldTypes.js index bf4e090c77..a416a30af0 100644 --- a/ui/cap-react/src/antd/admin/utils/fieldTypes.js +++ b/ui/cap-react/src/antd/admin/utils/fieldTypes.js @@ -247,6 +247,50 @@ const collections = { }; const simple = { + importData: { + title: "Import Data", + icon: , + description: "Provided a URL or query", + child: {}, + optionsSchema: { + type: "object", + title: "File upload widget", + properties: { + ...common.optionsSchema, + readOnly: extra.optionsSchema.readOnly, + isRequired: extra.optionsSchema.isRequired, + "x-cap-import-data": { + type: "object", + properties: { + queryUrl: { + type: "string" + }, + resultsPath: { + type: "string" + }, + } + } + }, + }, + optionsSchemaUiSchema: { + readOnly: extra.optionsSchemaUiSchema.readOnly, + isRequired: extra.optionsSchemaUiSchema.isRequired, + }, + optionsUiSchema: { + ...common.optionsUiSchema, + }, + optionsUiSchemaUiSchema: { + ...common.optionsUiSchemaUiSchema, + }, + default: { + schema: { + type: "object", + }, + uiSchema: { + "ui:field": "importData", + }, + }, + }, text: { title: "Text", icon: , @@ -916,6 +960,8 @@ const advanced = { { const: "orcid", title: "ORCiD" }, { const: "ror", title: "ROR" }, { const: "zenodo", title: "Zenodo" }, + { const: "capRecords", title: "CAP Records" }, + { const: "capDeposits", title: "CAP Deposits" }, ], }, uniqueItems: "true", @@ -934,7 +980,8 @@ const advanced = { properties: {}, }, uiSchema: { - "ui:servicesList": ["orcid", "ror", "zenodo"], + "ui:serfvicesList": ["orcid", "ror", "zenodo"], + "ui:servicesList": ["capDeposits"], "ui:field": "idFetcher", }, }, diff --git a/ui/cap-react/src/antd/forms/fields/ImportDataField.js b/ui/cap-react/src/antd/forms/fields/ImportDataField.js new file mode 100644 index 0000000000..44d3d92b27 --- /dev/null +++ b/ui/cap-react/src/antd/forms/fields/ImportDataField.js @@ -0,0 +1,179 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; + +import axios from "axios"; + +// import CustomOption from "./ImportDataFieldSelectComponent"; +import { Avatar, Checkbox, Input, List, Select, Space, Typography } from "antd"; +import ServiceGetter from "./ServiceGetter"; + +const EMPTY_VALUE = "---- No Selection ---- "; + +const ImportDataField = ({ schema, uiSchema, formData, onChange }) => { + // const [data, setData] = useState([]); + const [selected, setSelected] = useState(); + + const query = uiSchema?.["ui:options"]?.query || undefined; + const importData = uiSchema["importData"] || {}; + + const [fetchedResults, setFetchedResults] = useState(null); + const [currentIndex, setCurrentIndex] = useState(null); + const [data, setData] = useState(null); + + const { + "x-cap-import-data": { + queryUrl = "/api/deposits", + // queryUrl = "", + queryParam = "q", + resultsPath = "hits.hits", + hitTitle = "metadata.general_title", + hitDescription = "created_by.email", + // resultsPath = null + } = {}, + } = schema; + // } = uiSchema; + + const getResultFromData = data => { + let results = data; + const resultPathArray = resultsPath.split("."); + resultPathArray.map(p => (results = results[p])); + return results; + }; + + const serializeResults = data => { + return data.map(entry => { + let title = entry; + let description = entry; + + hitTitle.split(".").map(i => (title = title[i])); + hitDescription.split(".").map(i => (description = description[i])); + return { + label: title, + value: description, + data: entry, + }; + }); + // let results = data; + // const resultPathArray = resultsPath.split("."); + // resultPathArray.map(p => (results = results[p])); + // return results; + }; + const fetchSuggestions = async (val = "") => { + try { + const { data } = await axios.get(queryUrl, { + params: { + [queryParam]: val, + }, + }); + console.log(data); + + let results = getResultFromData(data); + console.log("rrrrrrrrrrrrrrrr", results); + results = serializeResults(results); + console.log("rrrrrrrrrrrr3333rrrr", results); + setFetchedResults(results); + // updateAll(data, true); + } catch (err) { + // + } + }; + + return ( + + + { + // e.target.value == "" && + // fetchedResults && + // setFetchedResults(null); + // updateAll(fetchedResults || [], false); + // }} + onSearch={fetchSuggestions} + /> + {fetchedResults && ( + ( + { + console.log(item) + setData(item.data)} + + } extfra={[
Content
]}> + } + description={item.value} + /> +
+ )} + /> + )} + + + { + data && JSON.stringify(data) + } +
+ ); +}; + +ImportDataField.propTypes = { + onChange: PropTypes.func, + uiSchema: PropTypes.object, + formData: PropTypes.object, +}; + +export default ImportDataField; diff --git a/ui/cap-react/src/antd/forms/fields/ServiceGetter.js b/ui/cap-react/src/antd/forms/fields/ServiceGetter.js index 10700213f6..5c48be561f 100644 --- a/ui/cap-react/src/antd/forms/fields/ServiceGetter.js +++ b/ui/cap-react/src/antd/forms/fields/ServiceGetter.js @@ -9,6 +9,8 @@ import OrcidSvg from "./services/svg/OrcidSvg"; import ZenodoSvg from "./services/svg/ZenodoSvg"; import RorSvg from "./services/svg/RorSvg"; import Icon from "@ant-design/icons"; +import CAPDeposit from "./services/CAPDeposit"; +import CAPLogo from "./services/svg/capLogo"; const SERVICES = { orcid: { @@ -26,15 +28,25 @@ const SERVICES = { url: "/api/services/zenodo/record/", svg: ZenodoSvg, }, + capRecords: { + name: "CAP Records", + url: "/api/records/", + svg: ZenodoSvg, + }, + capDeposits: { + name: "CAP Deposits", + url: "/api/deposits/", + svg: CAPLogo, + }, }; -const ServiceGetter = ({ formData = {}, uiSchema, onChange }) => { +const ServiceGetter = ({ formData = {}, uiSchema ={}, onChange }) => { const [service, setService] = useState(); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(undefined); useEffect(() => { - if (uiSchema["ui:servicesList"].length === 1) { + if (uiSchema?.["ui:servicesList"]?.length === 1) { setService(uiSchema["ui:servicesList"]); } }, [uiSchema]); @@ -44,8 +56,9 @@ const ServiceGetter = ({ formData = {}, uiSchema, onChange }) => { ror: , zenodo: , orcid: , + capDeposits: , }; - return choices[name]; + return choices[name] || {JSON.stringify(formData.fetched)}; }; const getId = (service, id) => { @@ -81,7 +94,7 @@ const ServiceGetter = ({ formData = {}, uiSchema, onChange }) => { try { const results = await axios.get(currentServiceApi + resourceID); let { data } = results; - if (!data.status) { + if (!data.statuss) { const resource_data = { source: { service: service, @@ -103,10 +116,11 @@ const ServiceGetter = ({ formData = {}, uiSchema, onChange }) => { return (
{formData.fetched ? ( - +
{getContentByName(formData.source.service)}{getContentByName(formData.source.service)}@@ -127,41 +125,44 @@ const ServiceGetter = ({ formData = {}, uiSchema ={}, onChange }) => { /> - ) : ( - - {uiSchema["ui:servicesList"]?.length > 1 && ( - - )} - {service && ( - - - - - - {errorMessage} + + ); + } + return ( +
+ + {uiSchema["ui:servicesList"]?.length > 1 && ( + + )} + {service && ( + + + + - )} - - )} + {errorMessage} + + )} +
); }; diff --git a/ui/cap-react/src/antd/forms/fields/services/CAPDeposit.js b/ui/cap-react/src/antd/forms/fields/services/CAPDeposit.js index 4bd4f79203..6c077e1959 100644 --- a/ui/cap-react/src/antd/forms/fields/services/CAPDeposit.js +++ b/ui/cap-react/src/antd/forms/fields/services/CAPDeposit.js @@ -1,5 +1,15 @@ import PropTypes from "prop-types"; -import { Avatar, Card, Col, Divider, Modal, Row, Space, Tag, Typography } from "antd"; +import { + Avatar, + Card, + Col, + Divider, + Modal, + Row, + Space, + Tag, + Typography, +} from "antd"; import { stringToHslColor } from "../../../utils"; import { EyeFilled, EyeInvisibleFilled, LinkOutlined } from "@ant-design/icons"; import { useState } from "react"; @@ -23,20 +33,25 @@ const CAPDeposit = ({ data }) => { /> - {data.schema.fullname}} - title={data?.metadata?.general_title || "No title"} - description={ - }> - - - - setShowModal(true)} /> - + + + {data?.schema?.fullname}} + title={data?.metadata?.general_title || "No title"} + description={"No description"} + /> + - } - /> + }> + + + + setShowModal(true)} /> + + ); From f468cb0b5b1b162ee932eca618c371d0327a06be Mon Sep 17 00:00:00 2001 From: pamfilos Date: Mon, 4 Sep 2023 16:31:48 +0200 Subject: [PATCH 5/9] ui: admin - adds permissions editor Signed-off-by: pamfilos --- cap/modules/schemas/models.py | 92 +++++-- cap/modules/schemas/views.py | 22 +- .../test_records_schema_permissions.py | 224 +++++++++++++++++ ui/cap-react/src/actions/schemaWizard.js | 75 ++++++ .../src/antd/admin/components/AdminPanel.js | 18 +- .../src/antd/admin/components/Header.js | 30 +-- .../antd/admin/permissions/AddPermissions.js | 107 ++++++++ .../admin/permissions/PermissionDropdown.js | 173 +++++++++++++ .../src/antd/admin/permissions/Permissions.js | 234 ++++++++++++++++++ .../src/antd/admin/utils/schemaSettings.js | 1 + .../antd/collection/CollectionPermissions.js | 84 +++---- .../collection/CollectionPermissionsColumn.js | 79 ++++++ ui/cap-react/src/antd/utils/index.js | 2 + ui/cap-react/src/reducers/schemaWizard.js | 4 + 14 files changed, 1054 insertions(+), 91 deletions(-) create mode 100644 tests/integration/deposits/test_records_schema_permissions.py create mode 100644 ui/cap-react/src/antd/admin/permissions/AddPermissions.js create mode 100644 ui/cap-react/src/antd/admin/permissions/PermissionDropdown.js create mode 100644 ui/cap-react/src/antd/admin/permissions/Permissions.js create mode 100644 ui/cap-react/src/antd/collection/CollectionPermissionsColumn.js 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/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/src/actions/schemaWizard.js b/ui/cap-react/src/actions/schemaWizard.js index d140bc299b..a8309c380a 100644 --- a/ui/cap-react/src/actions/schemaWizard.js +++ b/ui/cap-react/src/actions/schemaWizard.js @@ -28,6 +28,8 @@ export const ADD_NEW_NOTIFICATION = "ADD_NEW_NOTIFICATION"; export const REMOVE_NOTIFICATION = "REMOVE_NOTIFICATION"; export const CREATE_NOTIFICATION_GROUP = "CREATE_NOTIFICATION_GROUP"; +export const SET_SCHEMA_PERMISSIONS = "SET_SCHEMA_PERMISSIONS"; + const NOTIFICATIONS = { notifications: { actions: { @@ -58,6 +60,13 @@ export function deleteNotification(notification) { }; } +export function setSchemaPermissions(permissions) { + return { + type: SET_SCHEMA_PERMISSIONS, + permissions, + }; +} + export function schemaError(error) { return { type: SCHEMA_ERROR, @@ -196,6 +205,72 @@ export function getSchema(name, version = null) { }; } +export function getSchemaPermissions(name, version = null) { + let schemaPermissionLink; + + if (version) + schemaPermissionLink = `/api/jsonschemas/${name}/${version}/permissions`; + else schemaPermissionLink = `/api/jsonschemas/${name}/permissions`; + return function (dispatch) { + // dispatch(schemaInitRequest()); + 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 function postSchemaPermissions(name, version = null, permissions) { + let schemaPermissionLink; + + if (version) + schemaPermissionLink = `/api/jsonschemas/${name}/${version}/permissions`; + else schemaPermissionLink = `/api/jsonschemas/${name}/permissions`; + return function (dispatch) { + // dispatch(schemaInitRequest()); + 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 function 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", + }); + }); + }; +} + + export function getSchemasLocalStorage() { return function () { //let availableSchemas = localStorage.getItem("availableSchemas"); diff --git a/ui/cap-react/src/antd/admin/components/AdminPanel.js b/ui/cap-react/src/antd/admin/components/AdminPanel.js index 4c2f3ff497..0bade3179b 100644 --- a/ui/cap-react/src/antd/admin/components/AdminPanel.js +++ b/ui/cap-react/src/antd/admin/components/AdminPanel.js @@ -12,6 +12,7 @@ import TourTooltip from "../utils/tour/TourTooltip"; import { CarOutlined } from "@ant-design/icons"; import useStickyState from "../../hooks/useStickyState"; import { PRIMARY_COLOR } from "../../utils/theme"; +import Permissions from "../permissions/Permissions"; const AdminPanel = ({ location, match, schema, schemaInit, getSchema }) => { useEffect(() => { @@ -34,8 +35,19 @@ const AdminPanel = ({ location, match, schema, schemaInit, getSchema }) => { const getPageTitle = () => location.pathname.includes("notifications") - ? "Notifications" - : "Form Builder"; + ? "Notifications" : location.pathname.includes("permissions") ? + "Permissions" : "Form Builder"; + + const getDisplay = () => { + switch (display) { + case "notifications": + return ; + case "permissions": + return ; + default: + return ; + } + }; return ( @@ -64,7 +76,7 @@ const AdminPanel = ({ location, match, schema, schemaInit, getSchema }) => { 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 559d72e186..bc9e4e2d59 100644 --- a/ui/cap-react/src/antd/admin/components/Header.js +++ b/ui/cap-react/src/antd/admin/components/Header.js @@ -1,6 +1,5 @@ import { useState } from "react"; import PropTypes from "prop-types"; -import Form from "../../forms/Form"; import { Col, Menu, @@ -23,7 +22,6 @@ import { SettingOutlined, } from "@ant-design/icons"; import { CMS } from "../../routes"; -import { configSchema } from "../utils/schemaSettings"; import CodeViewer from "../../utils/CodeViewer"; import { json } from "@codemirror/lang-json"; import CodeDiffViewer from "../../utils/CodeDiffViewer"; @@ -37,12 +35,10 @@ const Header = ({ uiSchema, initialUiSchema, initialSchema, - updateSchemaConfig, display, setDisplay, }) => { const [diffModal, setDiffModal] = useState(false); - const [settingsModal, setSettingsModal] = useState(false); const screens = useBreakpoint(); const diffModalTabStyle = { @@ -160,19 +156,6 @@ const Header = ({ ]} /> - setSettingsModal(false)} - okButtonProps={{ - onClick: () => setSettingsModal(false), - }} - > -
updateSchemaConfig(data.formData)} - /> -
, className: "tour-notifications-tab", }, + { + key: "permissions", + label: "Settings", + icon: , + className: "tour-settings-tab", + }, ]} /> @@ -242,13 +231,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..669ab287d7 --- /dev/null +++ b/ui/cap-react/src/antd/admin/permissions/AddPermissions.js @@ -0,0 +1,107 @@ +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); + + // const [displayModal, setDisplayModal] = useState(false); + + // const closeModal = () => { + // setDisplayModal(false); + // form.resetFields(); + // setLdapData([]); + // }; + + 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..f74959a381 --- /dev/null +++ b/ui/cap-react/src/antd/admin/permissions/Permissions.js @@ -0,0 +1,234 @@ +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/schemaWizard"; +import AddPermissions from "./AddPermissions"; +import { Map } from "immutable"; +import { + CloseSquareFilled, + EditFilled, + PlusCircleOutlined, +} from "@ant-design/icons"; + +import { configSchema } from "../utils/schemaSettings"; +import Form from "../../forms/Form"; + +// const initialStatePermissions = Map({ +// deposit: { +// read: { users: [], roles: [] }, +// update: { users: [], roles: [] }, +// admin: { users: [], roles: [] }, +// review: { users: [], roles: [] }, +// create: { users: [], roles: [] }, +// }, +// records: { +// read: { users: [], roles: [] }, +// review: { users: [], roles: [] }, +// }, +// }); + +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 => ({ + schemaName: state.schemaWizard.getIn(["config", "name"]), + schemaVersion: state.schemaWizard.getIn(["config", "version"]), + permissions: state.schemaWizard.getIn(["permissions"]), + config: state.schemaWizard.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/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..e8a6849d37 --- /dev/null +++ b/ui/cap-react/src/antd/collection/CollectionPermissionsColumn.js @@ -0,0 +1,79 @@ +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/utils/index.js b/ui/cap-react/src/antd/utils/index.js index 4ee9b6eac4..313ecdda7b 100644 --- a/ui/cap-react/src/antd/utils/index.js +++ b/ui/cap-react/src/antd/utils/index.js @@ -21,6 +21,7 @@ export const permissionsPerUser = permissions => { email: user.email, permissions: [], type: "user", + key: `user-${user.email}` }; access[user.email].permissions.push(action); @@ -37,6 +38,7 @@ export const permissionsPerUser = permissions => { email: role, permissions: [], type: "egroup", + key: `egroup-${role}` }; access[role].permissions.push(action); if (access[role]) { diff --git a/ui/cap-react/src/reducers/schemaWizard.js b/ui/cap-react/src/reducers/schemaWizard.js index c64a8881c9..527a126aec 100644 --- a/ui/cap-react/src/reducers/schemaWizard.js +++ b/ui/cap-react/src/reducers/schemaWizard.js @@ -16,6 +16,7 @@ import { ADD_NEW_NOTIFICATION, REMOVE_NOTIFICATION, CREATE_NOTIFICATION_GROUP, + SET_SCHEMA_PERMISSIONS } from "../actions/schemaWizard"; const initialState = Map({ @@ -34,6 +35,7 @@ const initialState = Map({ error: null, loader: false, version: null, + permissions: null, }); export default function schemaReducer(state = initialState, action) { @@ -102,6 +104,8 @@ export default function schemaReducer(state = initialState, action) { return state.setIn(action.payload.path, action.payload.item); case REMOVE_NOTIFICATION: return state.setIn(action.payload.path, action.payload.notification); + case SET_SCHEMA_PERMISSIONS: + return state.set("permissions", action.permissions); case CREATE_NOTIFICATION_GROUP: return state.setIn(action.path, []); default: From 7d047941de10dd88c366ebaf2bf08dba8f935c69 Mon Sep 17 00:00:00 2001 From: pamfilos Date: Thu, 7 Dec 2023 17:30:46 +0100 Subject: [PATCH 6/9] records: inits xml, csv serializers Signed-off-by: pamfilos --- cap/config.py | 12 ++ cap/modules/records/serializers/__init__.py | 25 +++ cap/modules/records/serializers/author_xml.py | 155 +++++++++++++++++ cap/modules/records/serializers/csv.py | 160 ++++++++++++++++++ .../records/serializers/schemas/common.py | 14 ++ 5 files changed, 366 insertions(+) create mode 100644 cap/modules/records/serializers/author_xml.py create mode 100644 cap/modules/records/serializers/csv.py diff --git a/cap/config.py b/cap/config.py index 697d5daf2b..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,6 +767,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' + ), }, 'files_serializers': { 'application/json': ( diff --git a/cap/modules/records/serializers/__init__.py b/cap/modules/records/serializers/__init__.py index a5aea85dbd..f8dbe839a3 100644 --- a/cap/modules/records/serializers/__init__.py +++ b/cap/modules/records/serializers/__init__.py @@ -37,6 +37,8 @@ from cap.modules.records.serializers.schemas.common import ( CommonRecordMetadataSchema, ) +from cap.modules.records.serializers.author_xml import AuthorXMLSerializer +from cap.modules.records.serializers.csv import CSVSerializer from cap.modules.records.serializers.schemas.json import ( BasicDepositSchema, PermissionsDepositSchema, @@ -50,6 +52,23 @@ # 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) @@ -75,6 +94,12 @@ basic_json_v1_search = search_responsify( basic_json_v1, 'application/basic+json' ) + +# JSON record serializer for search results. +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' ) 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 f553bfef32..dbe80bf5ad 100644 --- a/cap/modules/records/serializers/schemas/common.py +++ b/cap/modules/records/serializers/schemas/common.py @@ -49,6 +49,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': From b041eb63f7291b89fa580a98084c3226226bcccd Mon Sep 17 00:00:00 2001 From: pamfilos Date: Mon, 18 Dec 2023 13:59:00 +0100 Subject: [PATCH 7/9] global: fixes for tests, formatting Signed-off-by: pamfilos --- cap/modules/deposit/api.py | 3 +- cap/modules/deposit/links.py | 2 +- .../deposit/serializers/schemas/json.py | 9 +++-- cap/modules/records/serializers/__init__.py | 4 +-- .../records/serializers/schemas/common.py | 18 +++++++++- .../records/serializers/schemas/json.py | 4 ++- cap/modules/schemas/cli.py | 12 +++++-- .../cli/test_schema_permissions.py | 2 +- tests/integration/test_get_records.py | 2 +- .../src/antd/forms/fields/ImportDataField.js | 35 +++++++------------ .../antd/forms/fields/services/CAPDeposit.js | 8 +---- .../antd/forms/fields/services/svg/capLogo.js | 22 ++++++------ 12 files changed, 66 insertions(+), 55 deletions(-) diff --git a/cap/modules/deposit/api.py b/cap/modules/deposit/api.py index 38f1d4a781..6876adf6e5 100644 --- a/cap/modules/deposit/api.py +++ b/cap/modules/deposit/api.py @@ -53,6 +53,7 @@ from sqlalchemy.orm.exc import NoResultFound from werkzeug.local import LocalProxy +from cap.modules.deposit.egroups import CERNEgroupMixin from cap.modules.deposit.errors import ( DepositValidationError, DisconnectWebhookError, @@ -99,8 +100,6 @@ get_existing_or_register_user, ) -from cap.modules.deposit.egroups import CERNEgroupMixin - _datastore = LocalProxy(lambda: current_app.extensions["security"].datastore) PRESERVE_FIELDS = ( diff --git a/cap/modules/deposit/links.py b/cap/modules/deposit/links.py index e48b2147af..b08c54007e 100644 --- a/cap/modules/deposit/links.py +++ b/cap/modules/deposit/links.py @@ -58,7 +58,7 @@ def links_factory(pid, record=None, record_hit=None, **kwargs): ) 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/serializers/schemas/json.py b/cap/modules/deposit/serializers/schemas/json.py index 2dd21c0cb3..ebb57e05f4 100644 --- a/cap/modules/deposit/serializers/schemas/json.py +++ b/cap/modules/deposit/serializers/schemas/json.py @@ -103,7 +103,7 @@ def remove_skip_values(self, data): keys = ["can_review", "review", "x_cap_permission", "egroups"] for key in keys: - if data.get(key, "") is None: + if not data.get(key, ""): del data[key] return data @@ -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/records/serializers/__init__.py b/cap/modules/records/serializers/__init__.py index f8dbe839a3..f641b1be7a 100644 --- a/cap/modules/records/serializers/__init__.py +++ b/cap/modules/records/serializers/__init__.py @@ -30,6 +30,8 @@ search_responsify, ) +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, @@ -37,8 +39,6 @@ from cap.modules.records.serializers.schemas.common import ( CommonRecordMetadataSchema, ) -from cap.modules.records.serializers.author_xml import AuthorXMLSerializer -from cap.modules.records.serializers.csv import CSVSerializer from cap.modules.records.serializers.schemas.json import ( BasicDepositSchema, PermissionsDepositSchema, diff --git a/cap/modules/records/serializers/schemas/common.py b/cap/modules/records/serializers/schemas/common.py index dbe80bf5ad..13a33fb4ed 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 @@ -173,6 +179,16 @@ def get_metadata(self, obj): class CommonRecordSchema(CommonRecordMetadataSchema, StrictKeysMixin): """Base record schema.""" + @post_dump + def remove_skip_values(self, data): + keys = ["egroups"] + + for key in keys: + if not data.get(key, ''): + del data[key] + + return data + id = fields.Str(attribute='pid.pid_value', dump_only=True) schema = fields.Method('get_schema', dump_only=True) diff --git a/cap/modules/records/serializers/schemas/json.py b/cap/modules/records/serializers/schemas/json.py index 3b931b10a9..16fc1e0676 100644 --- a/cap/modules/records/serializers/schemas/json.py +++ b/cap/modules/records/serializers/schemas/json.py @@ -79,7 +79,7 @@ def remove_skip_values(self, data): keys = ["can_review", "review", "egroups"] for key in keys: - if data.get(key, '') is None: + if not data.get(key, ''): del data[key] return data @@ -161,6 +161,8 @@ def get_links_with_review(self, obj): action="review", ) + links.pop('egroups', None) + return links 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/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/test_get_records.py b/tests/integration/test_get_records.py index 9339449df0..ad4b33065b 100644 --- a/tests/integration/test_get_records.py +++ b/tests/integration/test_get_records.py @@ -442,7 +442,7 @@ def test_get_record_with_form_json_serializer( }, 'can_update': True, 'is_owner': True, - 'can_review': False, + # 'can_review': False, 'created': rec.created.strftime('%Y-%m-%dT%H:%M:%S.%f+00:00'), 'updated': rec.updated.strftime('%Y-%m-%dT%H:%M:%S.%f+00:00'), 'created_by': {'email': example_user.email, 'profile': {}}, diff --git a/ui/cap-react/src/antd/forms/fields/ImportDataField.js b/ui/cap-react/src/antd/forms/fields/ImportDataField.js index 22613e4f62..41e3c22be9 100644 --- a/ui/cap-react/src/antd/forms/fields/ImportDataField.js +++ b/ui/cap-react/src/antd/forms/fields/ImportDataField.js @@ -5,38 +5,27 @@ import axios from "axios"; import { isEmpty } from "lodash-es"; import { - Avatar, Button, - Checkbox, Col, Divider, Input, List, Row, - Select, Skeleton, Space, - Typography, } from "antd"; import CAPDeposit from "./services/CAPDeposit"; import { DeleteOutlined } from "@ant-design/icons"; import InfiniteScroll from "react-infinite-scroll-component"; -const EMPTY_VALUE = "---- No Selection ---- "; - - // This component should always return an object with properties: url, data // url: (required) URL pointing to the resource to fetch // data: fetched data from the provided URL -const ImportDataField = ({ schema, uiSchema, formData, onChange }) => { - const [selected, setSelected] = useState(); - - const query = uiSchema?.["ui:options"]?.query || undefined; +const ImportDataField = ({ uiSchema, formData, onChange }) => { const [fetchedResults, setFetchedResults] = useState([]); const [fetchedResultsTotal, setFetchedResultsTotal] = useState(0); - const [currentIndex, setCurrentIndex] = useState(null); const [data, setData] = useState(formData?.data); const [searchValue, setSearchValue] = useState(""); @@ -52,8 +41,8 @@ const ImportDataField = ({ schema, uiSchema, formData, onChange }) => { // resultsTotalPath = "hits.total", hitTitle = "metadata.general_title", hitDescription = "created_by.email", - hitLink = "links.self", - hitDataPath = "" + // hitLink = "links.self", + // hitDataPath = "" } = {}, } = uiSchema || {}; @@ -71,12 +60,12 @@ const ImportDataField = ({ schema, uiSchema, formData, onChange }) => { return resultsTotal; }; - const getHitFromData = data => { - let hit = data; - const hitPathArray = hitDataPath.split("."); - hitPathArray.map(p => (hit = hit[p])); - return hit; - }; + // const getHitFromData = data => { + // let hit = data; + // const hitPathArray = hitDataPath.split("."); + // hitPathArray.map(p => (hit = hit[p])); + // return hit; + // }; const serializeResults = data => { return data.map(entry => { @@ -152,7 +141,7 @@ const ImportDataField = ({ schema, uiSchema, formData, onChange }) => {