From 0d45c8119af2fc63127c515a975b292d474e1c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20De=CC=81le=CC=80ze?= Date: Wed, 16 Jun 2021 14:51:51 +0200 Subject: [PATCH] subdivisions: create resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Creates a new `subdivisions` resource. * Links subdivisions to documents. * Links subdivision to users. * Links subdivision to deposits in diffusion step. * Creates a subdivision when a section is found in RERO DOC publication. * Configures a facet to filter deposits by subdivisions. * Configures a facet to filter users by subdivision. * Configures a facet to filter documents by subdivision. * Updates permissions for collections by adding rules for subdivisions. * Updates permissions for deposits by adding rules for subdivisions. * Updates permissions for documents by adding rules for subdivisions. * Adds organisations and subdivisions info in document detail view below the title. * Translates subdivisions and collections facets. * Makes the name of a subdivision or collection unique per language. * Closes #145. Co-Authored-by: Sébastien Délèze --- .gitignore | 1 + MANIFEST.in | 1 + babel.ini | 1 + scripts/bootstrap | 1 + setup.py | 15 +- sonar/config.py | 27 +- sonar/json_schemas/deposits_json_schema.py | 41 +++ sonar/json_schemas/factory.py | 6 +- sonar/modules/api.py | 43 ++- .../collections/collection-v1.0.0_src.json | 15 +- sonar/modules/collections/permissions.py | 15 +- sonar/modules/deposits/api.py | 11 +- .../deposits/deposit-v1.0.0_src.json | 33 ++ .../mappings/v7/deposits/deposit-v1.0.0.json | 19 + sonar/modules/deposits/permissions.py | 24 +- sonar/modules/deposits/query.py | 24 +- sonar/modules/deposits/rest.py | 15 +- .../modules/deposits/serializers/__init__.py | 9 + .../modules/documents/dojson/rerodoc/model.py | 39 +- .../documents/document-v1.0.0_src.json | 43 ++- .../documents/loaders/schemas/rerodoc.py | 2 +- .../v7/documents/document-v1.0.0.json | 28 +- sonar/modules/documents/marshmallow/json.py | 2 +- sonar/modules/documents/permissions.py | 30 +- .../modules/documents/serializers/__init__.py | 8 +- .../documents/templates/documents/record.html | 12 + sonar/modules/serializers.py | 11 + sonar/modules/subdivisions/__init__.py | 18 + sonar/modules/subdivisions/api.py | 74 ++++ sonar/modules/subdivisions/config.py | 107 ++++++ sonar/modules/subdivisions/jsonresolvers.py | 44 +++ .../subdivisions/jsonschemas/__init__.py | 18 + .../subdivisions/subdivision-v1.0.0_src.json | 95 +++++ .../modules/subdivisions/loaders/__init__.py | 27 ++ .../modules/subdivisions/mappings/__init__.py | 18 + .../subdivisions/mappings/v7/__init__.py | 18 + .../v7/subdivisions/subdivision-v1.0.0.json | 54 +++ sonar/modules/subdivisions/minters.py | 38 ++ sonar/modules/subdivisions/permissions.py | 111 ++++++ sonar/modules/subdivisions/query.py | 47 +++ sonar/modules/subdivisions/schemas.py | 112 ++++++ .../subdivisions/serializers/__init__.py | 43 +++ sonar/modules/users/api.py | 54 ++- .../users/jsonschemas/users/user-v1.0.0.json | 22 ++ .../users/mappings/v7/users/user-v1.0.0.json | 19 + sonar/modules/users/marshmallow/json.py | 1 + sonar/modules/users/serializers/__init__.py | 20 +- sonar/theme/views.py | 6 + .../test_collections_permissions.py | 18 +- .../api/deposits/test_deposits_permissions.py | 18 +- tests/api/deposits/test_deposits_rest.py | 24 +- .../documents/test_documents_permissions.py | 60 ++- tests/api/monitoring/test_monitoring_views.py | 6 + .../test_subdivisions_deposits_facets.py | 37 ++ .../test_subdivisions_documents_facets.py | 51 +++ .../test_subdivisions_permissions.py | 348 ++++++++++++++++++ .../test_subdivisions_users_facets.py | 45 +++ tests/conftest.py | 72 +++- tests/ui/deposits/test_deposits_api.py | 30 +- .../dojson/rerodoc/test_rerodoc_model.py | 4 +- tests/ui/users/test_users_api.py | 73 +++- 61 files changed, 2059 insertions(+), 149 deletions(-) create mode 100644 sonar/json_schemas/deposits_json_schema.py create mode 100644 sonar/modules/subdivisions/__init__.py create mode 100644 sonar/modules/subdivisions/api.py create mode 100644 sonar/modules/subdivisions/config.py create mode 100644 sonar/modules/subdivisions/jsonresolvers.py create mode 100644 sonar/modules/subdivisions/jsonschemas/__init__.py create mode 100644 sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0_src.json create mode 100644 sonar/modules/subdivisions/loaders/__init__.py create mode 100644 sonar/modules/subdivisions/mappings/__init__.py create mode 100644 sonar/modules/subdivisions/mappings/v7/__init__.py create mode 100644 sonar/modules/subdivisions/mappings/v7/subdivisions/subdivision-v1.0.0.json create mode 100644 sonar/modules/subdivisions/minters.py create mode 100644 sonar/modules/subdivisions/permissions.py create mode 100644 sonar/modules/subdivisions/query.py create mode 100644 sonar/modules/subdivisions/schemas.py create mode 100644 sonar/modules/subdivisions/serializers/__init__.py create mode 100644 tests/api/subdivisions/test_subdivisions_deposits_facets.py create mode 100644 tests/api/subdivisions/test_subdivisions_documents_facets.py create mode 100644 tests/api/subdivisions/test_subdivisions_permissions.py create mode 100644 tests/api/subdivisions/test_subdivisions_users_facets.py diff --git a/.gitignore b/.gitignore index b84df624..1c28cbfe 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ sonar/modules/organisations/jsonschemas/organisations/organisation-v1.0.0.json sonar/modules/documents/jsonschemas/documents/document-v1.0.0.json sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0.json sonar/modules/collections/jsonschemas/collections/collection-v1.0.0.json +sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0.json sonar/resources/projects/jsonschemas/projects/project-v1.0.0.json sonar/dedicated/*/*/jsonschemas/*/*/*-v1.0.0.json diff --git a/MANIFEST.in b/MANIFEST.in index d80ddf69..15e7f25e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -17,6 +17,7 @@ exclude sonar/modules/documents/jsonschemas/documents/document-v1.0.0.json exclude sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0.json exclude sonar/modules/projects/jsonschemas/projects/project-v1.0.0.json exclude sonar/modules/collections/jsonschemas/collections/collection-v1.0.0.json +exclude sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0.json include *.html include *.inv diff --git a/babel.ini b/babel.ini index ba5da037..96c711c2 100644 --- a/babel.ini +++ b/babel.ini @@ -37,5 +37,6 @@ extract_messages = $._, jQuery._ [ignore: **/**/deposit-v1.0.0.json] [ignore: **/**/project-v1.0.0.json] [ignore: **/**/collection-v1.0.0.json] +[ignore: **/**/subdivision-v1.0.0.json] [json: **.json] keys_to_translate = ['^title$', '^label$', '^description$', '^placeholder$', '^.*Message$'] diff --git a/scripts/bootstrap b/scripts/bootstrap index 57e7e805..5f5254b8 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -92,6 +92,7 @@ invenio utils compile-json ./sonar/modules/organisations/jsonschemas/organisatio invenio utils compile-json ./sonar/modules/documents/jsonschemas/documents/document-v1.0.0_src.json -o ./sonar/modules/documents/jsonschemas/documents/document-v1.0.0.json invenio utils compile-json ./sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0_src.json -o ./sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0.json invenio utils compile-json ./sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json -o ./sonar/modules/collections/jsonschemas/collections/collection-v1.0.0.json +invenio utils compile-json ./sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0_src.json -o ./sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0.json invenio utils compile-json ./sonar/resources/projects/jsonschemas/projects/project-v1.0.0_src.json -o ./sonar/resources/projects/jsonschemas/projects/project-v1.0.0.json invenio utils compile-json ./sonar/dedicated/hepvs/projects/jsonschemas/hepvs/projects/project-v1.0.0_src.json -o ./sonar/dedicated/hepvs/projects/jsonschemas/hepvs/projects/project-v1.0.0.json diff --git a/setup.py b/setup.py index fc14cc87..ef205833 100644 --- a/setup.py +++ b/setup.py @@ -115,6 +115,7 @@ 'projects = sonar.resources.projects.jsonschemas', 'projects_hepvs = sonar.dedicated.hepvs.projects.jsonschemas', 'collections = sonar.modules.collections.jsonschemas', + 'subdivisions = sonar.modules.subdivisions.jsonschemas', 'common = sonar.common.jsonschemas' ], 'invenio_search.mappings': [ @@ -123,7 +124,8 @@ 'users = sonar.modules.users.mappings', 'deposits = sonar.modules.deposits.mappings', 'projects = sonar.resources.projects.mappings', - 'collections = sonar.modules.collections.mappings' + 'collections = sonar.modules.collections.mappings', + 'subdivisions = sonar.modules.subdivisions.mappings' ], 'invenio_search.templates': [ 'base-record = sonar.es_templates:list_es_templates' @@ -138,7 +140,9 @@ 'deposit_id = \ sonar.modules.deposits.api:deposit_pid_minter', 'collections_id = \ - sonar.modules.collections.api:pid_minter' + sonar.modules.collections.api:pid_minter', + 'subdivisions_id = \ + sonar.modules.subdivisions.api:pid_minter' ], 'invenio_pidstore.fetchers': [ 'document_id = \ @@ -150,14 +154,17 @@ 'deposit_id = \ sonar.modules.deposits.api:deposit_pid_fetcher', 'collections_id = \ - sonar.modules.collections.api:pid_fetcher' + sonar.modules.collections.api:pid_fetcher', + 'subdivisions_id = \ + sonar.modules.subdivisions.api:pid_fetcher', ], "invenio_records.jsonresolver": [ "organisation = sonar.modules.organisations.jsonresolvers", "user = sonar.modules.users.jsonresolvers", "document = sonar.modules.documents.jsonresolvers", "project = sonar.resources.projects.jsonresolvers", - "collections = sonar.modules.collections.jsonresolvers" + "collections = sonar.modules.collections.jsonresolvers", + "subdivisions = sonar.modules.subdivisions.jsonresolvers" ], 'invenio_celery.tasks' : [ 'documents = sonar.modules.documents.tasks' diff --git a/sonar/config.py b/sonar/config.py index d6fdff60..40687af6 100644 --- a/sonar/config.py +++ b/sonar/config.py @@ -44,6 +44,8 @@ from sonar.modules.permissions import record_permission_factory, \ wiki_edit_permission from sonar.modules.query import and_term_filter, missing_field_filter +from sonar.modules.subdivisions.config import \ + Configuration as SubdivisionConfiguration from sonar.modules.users.api import UserRecord, UserSearch from sonar.modules.users.permissions import UserPermission from sonar.modules.utils import get_current_language @@ -496,6 +498,9 @@ def _(x): # Add endpoint for collections RECORDS_REST_ENDPOINTS['coll'] = CollectionConfiguration.rest_endpoint + +# Add endpoint for subdivisions +RECORDS_REST_ENDPOINTS['subd'] = SubdivisionConfiguration.rest_endpoint """REST endpoints.""" DEFAULT_AGGREGATION_SIZE = 50 @@ -504,8 +509,8 @@ def _(x): RECORDS_REST_FACETS = { 'documents': dict(aggs=dict( - sections=dict(terms=dict(field='sections', - size=DEFAULT_AGGREGATION_SIZE)), + subdivision=dict(terms=dict(field='subdivisions.pid', + size=DEFAULT_AGGREGATION_SIZE)), organisation=dict(terms=dict(field='organisation.pid', size=DEFAULT_AGGREGATION_SIZE)), language=dict( @@ -513,7 +518,7 @@ def _(x): subject=dict( terms=dict(field='facet_subjects', size=DEFAULT_AGGREGATION_SIZE)), collection=dict(terms=dict(field='collections.pid', - size=DEFAULT_AGGREGATION_SIZE)), + size=DEFAULT_AGGREGATION_SIZE)), document_type=dict( terms=dict(field='documentType', size=DEFAULT_AGGREGATION_SIZE)), controlled_affiliation=dict( @@ -533,8 +538,8 @@ def _(x): customField3=dict(terms=dict(field='customField3.raw', size=DEFAULT_AGGREGATION_SIZE))), filters={ - 'sections': - and_term_filter('sections'), + 'subdivision': + and_term_filter('subdivisions.pid'), 'organisation': and_term_filter('organisation.pid'), 'language': @@ -564,11 +569,14 @@ def _(x): }), 'deposits': dict(aggs=dict( + subdivision=dict(terms=dict(field='diffusion.subdivisions.pid', + size=DEFAULT_AGGREGATION_SIZE)), status=dict(terms=dict(field='status', size=DEFAULT_AGGREGATION_SIZE)), user=dict(terms=dict(field='user.pid', size=DEFAULT_AGGREGATION_SIZE)), contributor=dict(terms=dict(field='facet_contributors', size=DEFAULT_AGGREGATION_SIZE))), filters={ + 'subdivision': and_term_filter('diffusion.subdivisions.pid'), _('pid'): and_term_filter('pid'), _('status'): and_term_filter('status'), _('user'): and_term_filter('user.pid'), @@ -586,10 +594,17 @@ def _(x): } } } + }, + 'subdivision': { + 'terms': { + 'field': 'subdivision.pid', + 'size': DEFAULT_AGGREGATION_SIZE + } } }, 'filters': { - 'missing_organisation': missing_field_filter('organisation') + 'missing_organisation': missing_field_filter('organisation'), + 'subdivision': and_term_filter('subdivision.pid') } } } diff --git a/sonar/json_schemas/deposits_json_schema.py b/sonar/json_schemas/deposits_json_schema.py new file mode 100644 index 00000000..ae255101 --- /dev/null +++ b/sonar/json_schemas/deposits_json_schema.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Deposits JSON schema class.""" + +from sonar.modules.users.api import current_user_record + +from .json_schema_base import JSONSchemaBase + + +class DepositsJSONSchema(JSONSchemaBase): + """JSON schema for deposits.""" + + def process(self): + """Document JSON schema custom process. + + :returns: The processed schema. + """ + schema = super().process() + + if current_user_record.is_moderator: + return schema + + schema['properties']['diffusion']['properties'].pop( + 'subdivisions', None) + + return schema diff --git a/sonar/json_schemas/factory.py b/sonar/json_schemas/factory.py index 5aa8d78f..9eacb237 100644 --- a/sonar/json_schemas/factory.py +++ b/sonar/json_schemas/factory.py @@ -17,6 +17,7 @@ """Factory for JSON schema.""" +from .deposits_json_schema import DepositsJSONSchema from .documents_json_schema import DocumentsJSONSchema from .json_schema_base import JSONSchemaBase @@ -24,7 +25,10 @@ class JSONSchemaFactory(): """Factory for JSON schema.""" - SCHEMAS = {'documents': DocumentsJSONSchema} + SCHEMAS = { + 'documents': DocumentsJSONSchema, + 'deposits': DepositsJSONSchema + } @staticmethod def create(resource_type): diff --git a/sonar/modules/api.py b/sonar/modules/api.py index f495540f..b533e152 100644 --- a/sonar/modules/api.py +++ b/sonar/modules/api.py @@ -109,9 +109,8 @@ def get_record_class_by_pid_type(cls, pid_type): :param pid_type: PID type. :returns: Record class. """ - return current_app.config.get( - 'RECORDS_REST_ENDPOINTS', - {}).get(pid_type, {}).get('record_class') + return current_app.config.get('RECORDS_REST_ENDPOINTS', + {}).get(pid_type, {}).get('record_class') @classmethod def get_all_pids(cls, with_deleted=False): @@ -121,8 +120,7 @@ def get_all_pids(cls, with_deleted=False): :returns: A generator iterator. """ query = PersistentIdentifier.query.filter_by( - pid_type=cls.provider.pid_type - ) + pid_type=cls.provider.pid_type) if not with_deleted: query = query.filter_by(status=PIDStatus.REGISTERED) @@ -313,6 +311,41 @@ def delete(self, force=False, dbcommit=False, delindex=False): return self + def has_organisation(self, organisation_pid): + """Check if record belongs to the organisation. + + :param str organisation_pid: Organisation PID + :returns: True if record has organisation + :rtype: Bool + """ + for org in self.get('organisation', []): + if organisation_pid == org['pid']: + return True + + return False + + def has_subdivision(self, subdivision_pid): + """Check if record belongs to the subdivision. + + :param str subdivision_pid: Subdivision PID + :returns: True if record has subdivision + :rtype: Bool + """ + # No subdivision passed, means no check to do. + if not subdivision_pid: + return True + + # No subdivision in record, the document is accessible. + if not self.get('subdivisions'): + return False + + for subdivision in self['subdivisions']: + if subdivision_pid == subdivision['pid']: + return True + + # Subdivision not found, record is inaccessible + return False + class SonarSearch(RecordsSearch): """Search Class SONAR.""" diff --git a/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json b/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json index 69065501..fa8c3f0c 100644 --- a/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json +++ b/sonar/modules/collections/jsonschemas/collections/collection-v1.0.0_src.json @@ -45,8 +45,21 @@ "value", "language" ] + }, + "form": { + "validation": { + "validators": { + "uniqueValueKeysInObject": { + "keys": [ + "language" + ] + } + }, + "messages": { + "uniqueValueKeysInObjectMessage": "Only one value per language is allowed" + } + } } - }, "description": { "title": "Descriptions", diff --git a/sonar/modules/collections/permissions.py b/sonar/modules/collections/permissions.py index 79f205fa..c2a89fb1 100644 --- a/sonar/modules/collections/permissions.py +++ b/sonar/modules/collections/permissions.py @@ -36,7 +36,7 @@ def list(cls, user, record=None): :return: True is action can be done :rtype: bool """ - return user and user.is_moderator + return user and user.is_admin @classmethod def create(cls, user, record=None): @@ -47,7 +47,7 @@ def create(cls, user, record=None): :return: True is action can be done :rtype: bool """ - return user and user.is_moderator + return cls.list(user, record) @classmethod def read(cls, user, record): @@ -58,18 +58,15 @@ def read(cls, user, record): :return: True is action can be done :rtype: bool """ - # Only for moderator users - if not user or not user.is_moderator: - return False - - # Super user is allowed - if user.is_superuser: + if user and user.is_superuser: return True + if not cls.create(user, record): + return False + record = Record.get_record_by_pid(record['pid']) record = record.replace_refs() - # For moderator users, they can read only their own records. return current_organisation['pid'] == record['organisation']['pid'] @classmethod diff --git a/sonar/modules/deposits/api.py b/sonar/modules/deposits/api.py index 84d8ef37..96e4d812 100644 --- a/sonar/modules/deposits/api.py +++ b/sonar/modules/deposits/api.py @@ -26,7 +26,7 @@ from sonar.modules.api import SonarRecord from sonar.modules.collections.api import Record as CollectionRecord from sonar.modules.documents.api import DocumentRecord -from sonar.modules.users.api import current_user_record +from sonar.modules.users.api import UserRecord, current_user_record from sonar.proxies import sonar from ..api import SonarIndexer, SonarRecord, SonarSearch @@ -366,6 +366,15 @@ def create_document(self): if self['diffusion'].get('oa_status'): metadata['oa_status'] = self['diffusion']['oa_status'] + # Subdivisions + if self['diffusion'].get('subdivisions'): + metadata['subdivisions'] = self['diffusion']['subdivisions'] + else: + # Guess from submitter + user = UserRecord.get_record_by_ref_link(self['user']['$ref']) + if user and user.is_submitter and user.get('subdivision'): + metadata['subdivisions'] = [user['subdivision']] + document = DocumentRecord.create(metadata, dbcommit=True, with_bucket=True) diff --git a/sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0_src.json b/sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0_src.json index 803deea9..0d89ce69 100644 --- a/sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0_src.json +++ b/sonar/modules/deposits/jsonschemas/deposits/deposit-v1.0.0_src.json @@ -1122,6 +1122,39 @@ }, "oa_status": { "$ref": "oa-status-v1.0.0.json" + }, + "subdivisions": { + "title": "Subdivisions", + "type": "array", + "minItems": 0, + "items": { + "title": "Subdivision", + "type": "object", + "additionalProperties": false, + "properties": { + "$ref": { + "type": "string", + "pattern": "^https://sonar.ch/api/subdivisions/.*?$", + "form": { + "remoteTypeahead": { + "type": "subdivisions", + "field": "name.value.suggest", + "label": "label" + } + } + } + }, + "required": [ + "$ref" + ] + }, + "form": { + "templateOptions": { + "wrappers": [ + "card" + ] + } + } } }, "required": [ diff --git a/sonar/modules/deposits/mappings/v7/deposits/deposit-v1.0.0.json b/sonar/modules/deposits/mappings/v7/deposits/deposit-v1.0.0.json index da38d5ea..6bf10b92 100644 --- a/sonar/modules/deposits/mappings/v7/deposits/deposit-v1.0.0.json +++ b/sonar/modules/deposits/mappings/v7/deposits/deposit-v1.0.0.json @@ -263,6 +263,25 @@ "properties": { "license": { "type": "keyword" + }, + "subdivisions": { + "type": "object", + "properties": { + "pid": { + "type": "keyword" + }, + "name": { + "type": "object", + "properties": { + "value": { + "type": "text" + }, + "language": { + "type": "keyword" + } + } + } + } } } }, diff --git a/sonar/modules/deposits/permissions.py b/sonar/modules/deposits/permissions.py index 0566ef82..28ce3a2e 100644 --- a/sonar/modules/deposits/permissions.py +++ b/sonar/modules/deposits/permissions.py @@ -65,18 +65,36 @@ def read(cls, user, record): if not user or not user.is_submitter: return False - # Superuser is allowd if user.is_superuser: return True deposit = DepositRecord.get_record_by_pid(record['pid']) deposit = deposit.replace_refs() - # Moderators are allowed only for their organisation's deposits. - if user.is_moderator: + # Admin is allowed only for same organisation's records + if user.is_admin: return current_organisation['pid'] == deposit['user'][ 'organisation']['pid'] + # Special rules for moderators + if user.is_moderator: + user = user.replace_refs() + + # Deposit does not belong to user's organisation + if current_organisation['pid'] != deposit['user']['organisation'][ + 'pid']: + return False + + # Moderator has no subdivision, he can read the deposit + if not user.get('subdivision'): + return True + + # User has a subdivision, he can only read his own deposits and + # deposits from the same subdivision + return user['pid'] == deposit['user'][ + 'pid'] or deposit.has_subdivision( + user.get('subdivision', {}).get('pid')) + # Submitters have only access to their own deposits. return user['pid'] == deposit['user']['pid'] diff --git a/sonar/modules/deposits/query.py b/sonar/modules/deposits/query.py index ee180c5b..17b8ab46 100644 --- a/sonar/modules/deposits/query.py +++ b/sonar/modules/deposits/query.py @@ -17,6 +17,7 @@ """Query for deposits.""" +from elasticsearch_dsl import Q from flask import current_app from sonar.modules.organisations.api import current_organisation @@ -36,19 +37,32 @@ def search_factory(self, search, query_parser=None): if current_app.config.get('SONAR_APP_DISABLE_PERMISSION_CHECKS'): return (search, urlkwargs) + user = current_user_record + # For superusers, records are not filtered. - if current_user_record.is_superuser: + if user.is_superuser: return (search, urlkwargs) # For admin and moderator, only records that belongs to his organisation. - if current_user_record.is_admin or current_user_record.is_moderator: + if user.is_admin or user.is_moderator: search = search.filter( 'term', user__organisation__pid=current_organisation['pid']) + + # For moderators having a subdivision, records are filtered by + # subdivision or by owned deposits + if not user.is_admin and user.is_moderator and user.get('subdivision'): + user = user.replace_refs() + search = search.query( + 'bool', + should=[ + Q('term', subdivision__pid=user['subdivision']['pid']), + Q('term', user__pid=user['pid']) + ]) + return (search, urlkwargs) # For user, only records that belongs to him. - if current_user_record.is_submitter: - search = search.filter( - 'term', user__pid=current_user_record['pid']) + if user.is_submitter: + search = search.filter('term', user__pid=user['pid']) return (search, urlkwargs) diff --git a/sonar/modules/deposits/rest.py b/sonar/modules/deposits/rest.py index b0ceba67..875a913c 100644 --- a/sonar/modules/deposits/rest.py +++ b/sonar/modules/deposits/rest.py @@ -32,8 +32,9 @@ from sonar.modules.deposits.api import DepositRecord from sonar.modules.pdf_extractor.pdf_extractor import PDFExtractor from sonar.modules.pdf_extractor.utils import format_extracted_data +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from sonar.modules.users.api import UserRecord -from sonar.modules.utils import send_email +from sonar.modules.utils import get_language_value, send_email class FilesResource(ContentNegotiatedMethodView): @@ -140,12 +141,20 @@ def publish(pid=None): else: deposit['status'] = DepositRecord.STATUS_TO_VALIDATE - moderators_emails = user.get_moderators_emails() + subdivision = SubdivisionRecord.get_record_by_ref_link( + user['subdivision']['$ref']) if user.get('subdivision') else None + + moderators_emails = user.get_moderators_emails( + subdivision['pid'] if subdivision else None) + + email_subject = _('Deposit to validate') + if subdivision: + email_subject += f' ({get_language_value(subdivision["name"])})' if moderators_emails: # Send an email to validators send_email( - moderators_emails, _('Deposit to validate'), + moderators_emails, email_subject, 'deposits/email/validation', { 'deposit': deposit, 'user': user, diff --git a/sonar/modules/deposits/serializers/__init__.py b/sonar/modules/deposits/serializers/__init__.py index e11bdd70..8656fe6a 100644 --- a/sonar/modules/deposits/serializers/__init__.py +++ b/sonar/modules/deposits/serializers/__init__.py @@ -23,6 +23,7 @@ search_responsify from sonar.modules.serializers import JSONSerializer as _JSONSerializer +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from sonar.modules.users.api import UserRecord from ..marshmallow import DepositSchemaV1 @@ -41,6 +42,14 @@ def post_process_serialize_search(self, results, pid_fetcher): org_term['name'] = '{last_name}, {first_name}'.format( last_name=user['last_name'], first_name=user['first_name']) + # Add subdivision name + for org_term in results.get('aggregations', + {}).get('subdivision', + {}).get('buckets', []): + subdivision = SubdivisionRecord.get_record_by_pid(org_term['key']) + if subdivision: + org_term['name'] = subdivision['name'][0]['value'] + return super(JSONSerializer, self).post_process_serialize_search(results, pid_fetcher) diff --git a/sonar/modules/documents/dojson/rerodoc/model.py b/sonar/modules/documents/dojson/rerodoc/model.py index 48dab0ce..2801f24f 100644 --- a/sonar/modules/documents/dojson/rerodoc/model.py +++ b/sonar/modules/documents/dojson/rerodoc/model.py @@ -27,6 +27,7 @@ from sonar.modules.collections.api import Record as CollectionRecord from sonar.modules.documents.dojson.rerodoc.overdo import Overdo from sonar.modules.organisations.api import OrganisationRecord +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from sonar.modules.utils import remove_trailing_punctuation overdo = Overdo() @@ -69,12 +70,12 @@ def marc21_to_type_and_organisation(self, key, value): # Specific transformation for `bpuge` and `mhnge`, because the real # acronym is `vge`. + subdivision_name = None + if organisation in [ 'bpuge', 'mhnge', 'baage', 'bmuge', 'imvge', 'mhsge' ]: - # Store section - self['sections'] = [organisation - ] if organisation != 'bpuge' else ['bge'] + subdivision_name = 'bge' if organisation == 'bpuge' else organisation organisation = 'vge' if organisation not in overdo.registererd_organisations: @@ -86,6 +87,38 @@ def marc21_to_type_and_organisation(self, key, value): OrganisationRecord.get_ref_link('organisations', organisation) }] + if subdivision_name: + # Store subdivision + hash_key = hashlib.md5( + (subdivision_name + organisation).encode()).hexdigest() + + subdivision_pid = SubdivisionRecord.get_pid_by_hash_key(hash_key) + + # No subdivision found + if not subdivision_pid: + subdivision = SubdivisionRecord.create({ + 'name': [{ + 'language': 'eng', + 'value': subdivision_name + }], + 'organisation': { + '$ref': + OrganisationRecord.get_ref_link( + 'organisations', organisation) + }, + 'hashKey': + hash_key + }) + subdivision.commit() + subdivision.reindex() + db.session.commit() + subdivision_pid = subdivision['pid'] + + self['subdivisions'] = [{ + '$ref': + SubdivisionRecord.get_ref_link('subdivisions', subdivision_pid) + }] + # get doc type by mapping key = value.get('a', '') + '|' + value.get('f', '') if key not in TYPE_MAPPINGS: diff --git a/sonar/modules/documents/jsonschemas/documents/document-v1.0.0_src.json b/sonar/modules/documents/jsonschemas/documents/document-v1.0.0_src.json index 46088987..781e81c0 100644 --- a/sonar/modules/documents/jsonschemas/documents/document-v1.0.0_src.json +++ b/sonar/modules/documents/jsonschemas/documents/document-v1.0.0_src.json @@ -249,16 +249,6 @@ } } }, - "sections": { - "title": "Sections", - "type": "array", - "minItems": 1, - "items": { - "title": "Section", - "type": "string", - "minLength": 1 - } - }, "documentType": { "$ref": "type-v1.0.0.json" }, @@ -1853,6 +1843,38 @@ "templateOptions.required": "true" } } + }, + "subdivisions": { + "title": "Subdivisions", + "type": "array", + "minItems": 0, + "items": { + "title": "Subdivision", + "type": "object", + "additionalProperties": false, + "properties": { + "$ref": { + "type": "string", + "pattern": "^https://sonar.ch/api/subdivisions/.*?$", + "form": { + "remoteTypeahead": { + "type": "subdivisions", + "field": "name.value.suggest", + "label": "label" + } + } + } + }, + "required": [ + "$ref" + ] + }, + "form": { + "hide": true, + "navigation": { + "essential": true + } + } } }, "propertiesOrder": [ @@ -1872,6 +1894,7 @@ "provisionActivity", "partOf", "collections", + "subdivisions", "extent", "formats", "notes", diff --git a/sonar/modules/documents/loaders/schemas/rerodoc.py b/sonar/modules/documents/loaders/schemas/rerodoc.py index 92c3d6d3..0e48d02d 100644 --- a/sonar/modules/documents/loaders/schemas/rerodoc.py +++ b/sonar/modules/documents/loaders/schemas/rerodoc.py @@ -52,7 +52,7 @@ class RerodocSchema(Marc21Schema): otherEdition = fields.List(fields.Dict()) usageAndAccessPolicy = fields.Dict() files = fields.List(fields.Dict()) - sections = fields.List(fields.Str()) + subdivisions = fields.List(fields.Dict()) @pre_dump def process(self, obj, **kwargs): diff --git a/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json b/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json index c841e9a1..35dfb875 100644 --- a/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json +++ b/sonar/modules/documents/mappings/v7/documents/document-v1.0.0.json @@ -107,9 +107,6 @@ } } }, - "sections": { - "type": "keyword" - }, "documentType": { "type": "keyword" }, @@ -554,7 +551,7 @@ "type": "text", "fields": { "raw": { - "type": "keyword" + "type": "keyword" }, "suggest": { "type": "completion", @@ -566,7 +563,7 @@ "type": "text", "fields": { "raw": { - "type": "keyword" + "type": "keyword" }, "suggest": { "type": "completion", @@ -578,7 +575,7 @@ "type": "text", "fields": { "raw": { - "type": "keyword" + "type": "keyword" }, "suggest": { "type": "completion", @@ -589,6 +586,25 @@ "masked": { "type": "boolean" }, + "subdivisions": { + "type": "object", + "properties": { + "pid": { + "type": "keyword" + }, + "name": { + "type": "object", + "properties": { + "value": { + "type": "text" + }, + "language": { + "type": "keyword" + } + } + } + } + }, "_created": { "type": "date" }, diff --git a/sonar/modules/documents/marshmallow/json.py b/sonar/modules/documents/marshmallow/json.py index ce0a575a..96ce2605 100644 --- a/sonar/modules/documents/marshmallow/json.py +++ b/sonar/modules/documents/marshmallow/json.py @@ -100,7 +100,7 @@ class DocumentMetadataSchemaV1(StrictKeysMixin): usageAndAccessPolicy = fields.Dict() projects = fields.List(fields.Dict()) oa_status = SanitizedUnicode() - sections = fields.List(fields.Str()) + subdivisions = fields.List(fields.Dict()) harvested = fields.Boolean() customField1 = fields.List(fields.String(validate=validate.Length(min=1))) customField2 = fields.List(fields.String(validate=validate.Length(min=1))) diff --git a/sonar/modules/documents/permissions.py b/sonar/modules/documents/permissions.py index 0cbb81a8..6d2c6019 100644 --- a/sonar/modules/documents/permissions.py +++ b/sonar/modules/documents/permissions.py @@ -43,8 +43,7 @@ def list(cls, user, record=None): return True # Only for moderators users. - if (not user or not user.is_moderator or - not current_organisation): + if (not user or not user.is_moderator or not current_organisation): return False return True @@ -79,13 +78,7 @@ def read(cls, user, record): document = DocumentRecord.get_record_by_pid(record['pid']) document = document.replace_refs() - # For admin or moderators users, they can access only to their - # organisation's documents. - for organisation in document['organisation']: - if current_organisation['pid'] == organisation['pid']: - return True - - return False + return document.has_organisation(current_organisation['pid']) @classmethod def update(cls, user, record): @@ -96,7 +89,20 @@ def update(cls, user, record): :returns: True is action can be done. """ # Same rules as read - return cls.read(user, record) + can_read = cls.read(user, record) + + if not can_read: + return False + + if user.is_admin: + return True + + document = DocumentRecord.get_record_by_pid(record['pid']) + document = document.replace_refs() + + user = user.replace_refs() + + return document.has_subdivision(user.get('subdivision', {}).get('pid')) @classmethod def delete(cls, user, record): @@ -110,5 +116,5 @@ def delete(cls, user, record): if not user or not user.is_admin: return False - # Same rules as read - return cls.read(user, record) + # Same rules as update + return cls.update(user, record) diff --git a/sonar/modules/documents/serializers/__init__.py b/sonar/modules/documents/serializers/__init__.py index 347a74ce..7c53b019 100644 --- a/sonar/modules/documents/serializers/__init__.py +++ b/sonar/modules/documents/serializers/__init__.py @@ -38,6 +38,7 @@ from sonar.modules.organisations.api import OrganisationRecord, \ current_organisation from sonar.modules.serializers import JSONSerializer as _JSONSerializer +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from sonar.modules.users.api import current_user_record from sonar.modules.utils import get_language_value @@ -108,7 +109,12 @@ def post_process_serialize_search(self, results, pid_fetcher): {}).get('buckets', []): collection = CollectionRecord.get_record_by_pid(org_term['key']) if collection: - org_term['name'] = collection['name'][0]['value'] + org_term['name'] = get_language_value(collection['name']) + + # Don't display subdivision in global context + if view and view == current_app.config.get( + 'SONAR_APP_DEFAULT_ORGANISATION'): + results['aggregations'].pop('subdivision', {}) return super(JSONSerializer, self).post_process_serialize_search(results, pid_fetcher) diff --git a/sonar/modules/documents/templates/documents/record.html b/sonar/modules/documents/templates/documents/record.html index 77fda103..2de5483d 100644 --- a/sonar/modules/documents/templates/documents/record.html +++ b/sonar/modules/documents/templates/documents/record.html @@ -67,6 +67,18 @@
{{ _('document_type_' + record.documentType) }}

{{ title | nl2br | safe }}

+

+ {%- for organisation in record.get('organisation', []) -%} + + {{ organisation.name }} + + {%- endfor -%} + {%- for subdivision in record.get('subdivisions', []) -%} + + {{ subdivision.name | language_value }} + + {%- endfor -%} +

{% set contributors = record | contributors %} diff --git a/sonar/modules/serializers.py b/sonar/modules/serializers.py index 4ba5a5a5..c17a19ea 100644 --- a/sonar/modules/serializers.py +++ b/sonar/modules/serializers.py @@ -24,6 +24,9 @@ from invenio_records_rest.serializers.json import \ JSONSerializer as _JSONSerializer +from sonar.modules.subdivisions.api import Record as SubdivisionRecord +from sonar.modules.utils import get_language_value + class JSONSerializer(_JSONSerializer): """JSON serializer for SONAR.""" @@ -56,6 +59,14 @@ def preprocess_record(self, pid, record, links_factory=None, **kwargs): def post_process_serialize_search(self, results, pid_fetcher): """Post process the search results.""" + # Add subdivision name + for org_term in results.get('aggregations', + {}).get('subdivision', + {}).get('buckets', []): + subdivision = SubdivisionRecord.get_record_by_pid(org_term['key']) + if subdivision: + org_term['name'] = get_language_value(subdivision['name']) + return results def serialize_search(self, diff --git a/sonar/modules/subdivisions/__init__.py b/sonar/modules/subdivisions/__init__.py new file mode 100644 index 00000000..f711a845 --- /dev/null +++ b/sonar/modules/subdivisions/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Resource.""" diff --git a/sonar/modules/subdivisions/api.py b/sonar/modules/subdivisions/api.py new file mode 100644 index 00000000..1e9f31a2 --- /dev/null +++ b/sonar/modules/subdivisions/api.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Record API.""" + +from functools import partial + +from ..api import SonarIndexer, SonarRecord, SonarSearch +from ..fetchers import id_fetcher +from ..providers import Provider +from .config import Configuration +from .minters import id_minter + +# provider +RecordProvider = type('RecordProvider', (Provider, ), + dict(pid_type=Configuration.pid_type)) +# minter +pid_minter = partial(id_minter, provider=RecordProvider) +# fetcher +pid_fetcher = partial(id_fetcher, provider=RecordProvider) + + +class Record(SonarRecord): + """Record.""" + + minter = pid_minter + fetcher = pid_fetcher + provider = RecordProvider + schema = Configuration.schema + + @classmethod + def get_pid_by_hash_key(cls, hash_key): + """Get a record by a hash key. + + :param str hash_key: Hash key to find. + :returns: The record found. + :rtype: SonarRecord. + """ + result = RecordSearch().filter( + 'term', hashKey=hash_key).source(includes='pid').scan() + try: + return next(result).pid + except StopIteration: + return None + + +class RecordSearch(SonarSearch): + """Record search.""" + + class Meta: + """Search only on item index.""" + + index = Configuration.index + doc_types = [] + + +class RecordIndexer(SonarIndexer): + """Indexing documents in Elasticsearch.""" + + record_cls = Record diff --git a/sonar/modules/subdivisions/config.py b/sonar/modules/subdivisions/config.py new file mode 100644 index 00000000..857d0926 --- /dev/null +++ b/sonar/modules/subdivisions/config.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Configuration.""" + +from sonar.modules.permissions import record_permission_factory + +# Resource name +RESOURCE_NAME = 'subdivisions' + +# JSON schema name +JSON_SCHEMA_NAME = RESOURCE_NAME[:-1] + +# Module path +MODULE_PATH = f'sonar.modules.{RESOURCE_NAME}' + +# PID type +PID_TYPE = 'subd' + + +class Configuration: + """Resource configuration.""" + + index = f'{RESOURCE_NAME}' + schema = f'{RESOURCE_NAME}/{JSON_SCHEMA_NAME}-v1.0.0.json' + pid_type = PID_TYPE + resolver_url = f'/api/{RESOURCE_NAME}/' + rest_endpoint = { + 'pid_type': + PID_TYPE, + 'pid_minter': + f'{RESOURCE_NAME}_id', + 'pid_fetcher': + f'{RESOURCE_NAME}_id', + 'default_endpoint_prefix': + True, + 'record_class': + f'{MODULE_PATH}.api:Record', + 'search_class': + f'{MODULE_PATH}.api:RecordSearch', + 'indexer_class': + f'{MODULE_PATH}.api:RecordIndexer', + 'search_index': + RESOURCE_NAME, + 'search_type': + None, + 'record_serializers': { + 'application/json': (f'{MODULE_PATH}.serializers' + ':json_v1_response'), + }, + 'search_serializers': { + 'application/json': (f'{MODULE_PATH}.serializers' + ':json_v1_search'), + }, + 'record_loaders': { + 'application/json': (f'{MODULE_PATH}.loaders' + ':json_v1'), + }, + 'list_route': + f'/{RESOURCE_NAME}/', + 'item_route': + f'/{RESOURCE_NAME}/', + 'default_media_type': + 'application/json', + 'max_result_window': + 10000, + 'search_factory_imp': + f'{MODULE_PATH}.query:search_factory', + 'create_permission_factory_imp': + lambda record: record_permission_factory( + action='create', cls=f'{MODULE_PATH}.permissions:RecordPermission' + ), + 'read_permission_factory_imp': + lambda record: record_permission_factory( + action='read', + record=record, + cls=f'{MODULE_PATH}.permissions:RecordPermission'), + 'update_permission_factory_imp': + lambda record: record_permission_factory( + action='update', + record=record, + cls=f'{MODULE_PATH}.permissions:RecordPermission'), + 'delete_permission_factory_imp': + lambda record: record_permission_factory( + action='delete', + record=record, + cls=f'{MODULE_PATH}.permissions:RecordPermission'), + 'list_permission_factory_imp': + lambda record: record_permission_factory( + action='list', + record=record, + cls=f'{MODULE_PATH}.permissions:RecordPermission') + } diff --git a/sonar/modules/subdivisions/jsonresolvers.py b/sonar/modules/subdivisions/jsonresolvers.py new file mode 100644 index 00000000..238a4314 --- /dev/null +++ b/sonar/modules/subdivisions/jsonresolvers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""JSON resolvers.""" + +import jsonresolver +from invenio_pidstore.resolver import Resolver +from invenio_records.api import Record + +from ...config import JSONSCHEMAS_HOST +from .config import Configuration + + +@jsonresolver.route(Configuration.resolver_url, host=JSONSCHEMAS_HOST) +def json_resolver(pid): + """Resolve record. + + :param str pid: PID value. + :return: Record instance. + :rtype: Record + """ + resolver = Resolver(pid_type=Configuration.pid_type, + object_type='rec', + getter=Record.get_record) + _, record = resolver.resolve(pid) + + if record.get('$schema'): + del record['$schema'] + + return record diff --git a/sonar/modules/subdivisions/jsonschemas/__init__.py b/sonar/modules/subdivisions/jsonschemas/__init__.py new file mode 100644 index 00000000..6430fc89 --- /dev/null +++ b/sonar/modules/subdivisions/jsonschemas/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""JSON schemas.""" diff --git a/sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0_src.json b/sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0_src.json new file mode 100644 index 00000000..1eb3d8a3 --- /dev/null +++ b/sonar/modules/subdivisions/jsonschemas/subdivisions/subdivision-v1.0.0_src.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "https://sonar.ch/schemas/subdivisions/subdivision-v1.0.0.json", + "title": "Subdivisions", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "default": "https://sonar.ch/schemas/subdivisions/subdivision-v1.0.0.json" + }, + "pid": { + "title": "Identifier", + "type": "string", + "minLength": 1 + }, + "hashKey": { + "title": "Hash key", + "type": "string", + "minLength": 1 + }, + "name": { + "title": "Names", + "type": "array", + "minItems": 1, + "items": { + "title": "Name", + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "title": "Value", + "type": "string", + "minLength": 1 + }, + "language": { + "$ref": "language-v1.0.0.json" + } + }, + "propertiesOrder": [ + "language", + "value" + ], + "required": [ + "value", + "language" + ] + }, + "form": { + "validation": { + "validators": { + "uniqueValueKeysInObject": { + "keys": [ + "language" + ] + } + }, + "messages": { + "uniqueValueKeysInObjectMessage": "Only one value per language is allowed" + } + } + } + }, + "organisation": { + "title": "Organisation", + "type": "object", + "properties": { + "$ref": { + "title": "Organisation", + "type": "string", + "pattern": "^https://sonar.ch/api/organisations/.*?$", + "form": { + "remoteOptions": { + "type": "organisations" + } + } + } + }, + "required": [ + "$ref" + ], + "form": { + "expressionProperties": { + "templateOptions.required": "true" + } + } + } + }, + "propertiesOrder": [ + "name" + ], + "required": [ + "name" + ] +} diff --git a/sonar/modules/subdivisions/loaders/__init__.py b/sonar/modules/subdivisions/loaders/__init__.py new file mode 100644 index 00000000..66780316 --- /dev/null +++ b/sonar/modules/subdivisions/loaders/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Loaders.""" + +from invenio_records_rest.loaders.marshmallow import marshmallow_loader + +from ..schemas import RecordMetadataSchema + +#: JSON loader using Marshmallow for data validation. +json_v1 = marshmallow_loader(RecordMetadataSchema) + +__all__ = ('json_v1', ) diff --git a/sonar/modules/subdivisions/mappings/__init__.py b/sonar/modules/subdivisions/mappings/__init__.py new file mode 100644 index 00000000..1bd3117c --- /dev/null +++ b/sonar/modules/subdivisions/mappings/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Elasticsearch mappings.""" diff --git a/sonar/modules/subdivisions/mappings/v7/__init__.py b/sonar/modules/subdivisions/mappings/v7/__init__.py new file mode 100644 index 00000000..1bd3117c --- /dev/null +++ b/sonar/modules/subdivisions/mappings/v7/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Elasticsearch mappings.""" diff --git a/sonar/modules/subdivisions/mappings/v7/subdivisions/subdivision-v1.0.0.json b/sonar/modules/subdivisions/mappings/v7/subdivisions/subdivision-v1.0.0.json new file mode 100644 index 00000000..31f9aacd --- /dev/null +++ b/sonar/modules/subdivisions/mappings/v7/subdivisions/subdivision-v1.0.0.json @@ -0,0 +1,54 @@ +{ + "settings": { + "number_of_shards": 8, + "number_of_replicas": 2, + "max_result_window": 20000 + }, + "mappings": { + "date_detection": false, + "numeric_detection": false, + "properties": { + "$schema": { + "type": "keyword" + }, + "pid": { + "type": "keyword" + }, + "hashKey": { + "type": "keyword" + }, + "name": { + "type": "object", + "properties": { + "language": { + "type": "keyword" + }, + "value": { + "type": "text", + "fields": { + "suggest": { + "type": "text", + "analyzer": "autocomplete", + "search_analyzer": "standard" + } + } + } + } + }, + "organisation": { + "type": "object", + "properties": { + "pid": { + "type": "keyword" + } + } + }, + "_created": { + "type": "date" + }, + "_updated": { + "type": "date" + } + } + } +} diff --git a/sonar/modules/subdivisions/minters.py b/sonar/modules/subdivisions/minters.py new file mode 100644 index 00000000..0160b923 --- /dev/null +++ b/sonar/modules/subdivisions/minters.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Minters.""" + + +def id_minter(record_uuid, data, provider, pid_key='pid', object_type='rec'): + """PID minter. + + :param str record_uuid: UUID of the record + :param dict data: Data of the record + :param RecordProvider provider: PID provider + :param str pid_key: PIF key + :param str object_type: Object type + :return: PID value + :rtype: str + """ + # Create persistent identifier + provider = provider.create(object_type=object_type, + object_uuid=record_uuid, + pid_value=data.get(pid_key)) + pid = provider.pid + data[pid_key] = pid.pid_value + return pid diff --git a/sonar/modules/subdivisions/permissions.py b/sonar/modules/subdivisions/permissions.py new file mode 100644 index 00000000..fb92c3ff --- /dev/null +++ b/sonar/modules/subdivisions/permissions.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Record permissions.""" + +from sonar.modules.documents.api import DocumentSearch +from sonar.modules.organisations.api import current_organisation +from sonar.modules.permissions import RecordPermission as BaseRecordPermission + +from .api import Record + + +class RecordPermission(BaseRecordPermission): + """Record permissions.""" + + @classmethod + def list(cls, user, record=None): + """List permission check. + + :param UserRecord user: Current user record + :param Record record: Record to check + :return: True is action can be done + :rtype: bool + """ + return user and user.is_submitter + + @classmethod + def create(cls, user, record=None): + """Create permission check. + + :param UserRecord user: Current user record + :param Record record: Record to check + :return: True is action can be done + :rtype: bool + """ + return user and user.is_admin + + @classmethod + def read(cls, user, record): + """Read permission check. + + :param UserRecord user: Current user record + :param Record record: Record to check + :return: True is action can be done + :rtype: bool + """ + # Only for moderator users. + if not user or not user.is_submitter: + return False + + # Superuser is allowed. + if user.is_superuser: + return True + + # No organisation. + if not current_organisation: + return False + + record = Record.get_record_by_pid(record['pid']) + record = record.replace_refs() + + return current_organisation['pid'] == record['organisation']['pid'] + + @classmethod + def update(cls, user, record): + """Update permission check. + + :param UserRecord user: Current user record + :param Record record: Record to check + :return: True is action can be done + :rtype: bool + """ + if not cls.read(user, record): + return False + + return cls.create(user, record) + + @classmethod + def delete(cls, user, record): + """Delete permission check. + + :param UserRecord user: Current user record + :param Record record: Record to check + :return: True if action can be done + :rtype: bool + """ + results = DocumentSearch().filter( + 'term', subdivisions__pid=record['pid']).source(includes=['pid']) + + # Cannot remove subdivision associated to a record + if results.count(): + return False + + if not cls.read(user, record): + return False + + return cls.create(user, record) diff --git a/sonar/modules/subdivisions/query.py b/sonar/modules/subdivisions/query.py new file mode 100644 index 00000000..1b915fe5 --- /dev/null +++ b/sonar/modules/subdivisions/query.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Query.""" + +from flask import current_app + +from sonar.modules.organisations.api import current_organisation +from sonar.modules.query import default_search_factory +from sonar.modules.users.api import current_user_record + + +def search_factory(self, search): + """Search factory. + + :param Search search: Search instance + :return: Tuple with search instance and URL arguments + :rtype: tuple + """ + search, urlkwargs = default_search_factory(self, search) + + if current_app.config.get('SONAR_APP_DISABLE_PERMISSION_CHECKS'): + return (search, urlkwargs) + + # Records are not filtered for superusers. + if current_user_record.is_superuser: + return (search, urlkwargs) + + # For admins, records are filtered by organisation of the current user. + search = search.filter('term', + organisation__pid=current_organisation['pid']) + + return (search, urlkwargs) diff --git a/sonar/modules/subdivisions/schemas.py b/sonar/modules/subdivisions/schemas.py new file mode 100644 index 00000000..2718e374 --- /dev/null +++ b/sonar/modules/subdivisions/schemas.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Marshmallow schemas.""" + +from functools import partial + +from invenio_records_rest.schemas import StrictKeysMixin +from invenio_records_rest.schemas.fields import GenFunction, \ + PersistentIdentifier +from marshmallow import fields, pre_dump, pre_load + +from sonar.modules.serializers import schema_from_context +from sonar.modules.users.api import current_user_record +from sonar.modules.utils import get_language_value + +from .api import Record +from .permissions import RecordPermission + +schema_from_record = partial(schema_from_context, schema=Record.schema) + + +class RecordMetadataSchema(StrictKeysMixin): + """Schema for record metadata.""" + + pid = PersistentIdentifier() + name = fields.List(fields.Dict(), required=True) + organisation = fields.Dict() + permissions = fields.Dict(dump_only=True) + label = fields.Method('get_label') + # When loading, if $schema is not provided, it's retrieved by + # Record.schema property. + schema = GenFunction(load_only=True, + attribute="$schema", + data_key="$schema", + deserialize=schema_from_record) + + def get_label(self, obj): + """Get label.""" + return get_language_value(obj['name']) + + @pre_load + def remove_fields(self, data, **kwargs): + """Removes computed fields. + + :param dict data: Record data + :return: Modified data + :rtype: dict + """ + data.pop('permissions', None) + data.pop('label', None) + + return data + + @pre_load + def guess_organisation(self, data, **kwargs): + """Guess organisation from current logged user. + + :param dict data: Record data + :return: Modified data + :rtype: dict + """ + # Organisation already attached to project, we do nothing. + if data.get('organisation'): + return data + + # Store current user organisation in new project. + if current_user_record.get('organisation'): + data['organisation'] = current_user_record['organisation'] + + return data + + @pre_dump + def add_permissions(self, item, **kwargs): + """Add permissions to record. + + :param dict item: Record data + :return: Modified item + :rtype: dict + """ + item['permissions'] = { + 'read': RecordPermission.read(current_user_record, item), + 'update': RecordPermission.update(current_user_record, item), + 'delete': RecordPermission.delete(current_user_record, item) + } + + return item + + +class RecordSchema(StrictKeysMixin): + """Schema for record.""" + + metadata = fields.Nested(RecordMetadataSchema) + created = fields.Str(dump_only=True) + updated = fields.Str(dump_only=True) + links = fields.Dict(dump_only=True) + id = PersistentIdentifier() + explanation = fields.Raw(dump_only=True) diff --git a/sonar/modules/subdivisions/serializers/__init__.py b/sonar/modules/subdivisions/serializers/__init__.py new file mode 100644 index 00000000..017e32c4 --- /dev/null +++ b/sonar/modules/subdivisions/serializers/__init__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Serializers.""" + +from invenio_records_rest.serializers.response import record_responsify, \ + search_responsify + +from sonar.modules.serializers import JSONSerializer + +from ..schemas import RecordSchema + +# Serializers +# =========== +#: JSON serializer definition. +json_v1 = JSONSerializer(RecordSchema) + +# Records-REST serializers +# ======================== +#: JSON record serializer for individual records. +json_v1_response = record_responsify(json_v1, 'application/json') +#: JSON record serializer for search results. +json_v1_search = search_responsify(json_v1, 'application/json') + +__all__ = ( + 'json_v1', + 'json_v1_response', + 'json_v1_search', +) diff --git a/sonar/modules/users/api.py b/sonar/modules/users/api.py index e326404c..ad136f91 100644 --- a/sonar/modules/users/api.py +++ b/sonar/modules/users/api.py @@ -68,25 +68,45 @@ class Meta: index = 'users' doc_types = [] - def get_moderators(self, organisation_pid=None): + def get_moderators(self, organisation_pid=None, subdivision_pid=None): """Get moderators corresponding to organisation. If no organisation provided, return moderators not associated with organisations. - """ - filter_roles = [] - roles = UserRecord.get_all_roles_for_role(UserRecord.ROLE_MODERATOR) - for role in roles: - filter_roles.append(Q('term', role=role)) - query = self.query( - 'bool', - filter=[Q('bool', should=filter_roles, minimum_should_match=1)]) + :param organisation_pid: Organisation PID. + :param subdivision_pid: Subdivision PID. + :returns: List of results + """ + must = [] if organisation_pid: - query = query.filter('term', organisation__pid=organisation_pid) + must.append(Q('term', organisation__pid=organisation_pid)) + + if not subdivision_pid: + must.append( + Q('bool', + should=[ + Q('term', role=UserRecord.ROLE_ADMIN), + Q('bool', + must=Q('term', role=UserRecord.ROLE_MODERATOR), + must_not=Q('exists', field='subdivision')) + ])) + + else: + must.append( + Q('bool', + should=[ + Q('term', role=UserRecord.ROLE_ADMIN), + Q('bool', + must=[ + Q('term', role=UserRecord.ROLE_MODERATOR), + Q('term', subdivision__pid=subdivision_pid) + ]) + ])) - return query.source(includes=['pid', 'email']).scan() + return self.query('bool', filter=Q( + 'bool', must=must)).source(includes=['pid', 'email']).scan() class UserRecord(SonarRecord): @@ -271,15 +291,21 @@ def remove_role_from_account(self, role_name): datastore.remove_role_from_user(self.user, role) datastore.commit() - def get_moderators_emails(self): - """Get the list of moderators emails.""" + def get_moderators_emails(self, subdivision_pid=None): + """Get the list of moderators emails. + + :param subdivision_pid: PID of a subdivision. + :returns: List of emails. + :rtype: list + """ organisation_pid = None if 'organisation' in self: organisation_pid = UserRecord.get_pid_by_ref_link( self['organisation']['$ref']) - moderators = UserSearch().get_moderators(organisation_pid) + moderators = UserSearch().get_moderators(organisation_pid, + subdivision_pid) return [result['email'] for result in moderators] diff --git a/sonar/modules/users/jsonschemas/users/user-v1.0.0.json b/sonar/modules/users/jsonschemas/users/user-v1.0.0.json index 736e342e..f1a7d121 100644 --- a/sonar/modules/users/jsonschemas/users/user-v1.0.0.json +++ b/sonar/modules/users/jsonschemas/users/user-v1.0.0.json @@ -130,11 +130,33 @@ } ] } + }, + "subdivision": { + "title": "Subdivision", + "type": "object", + "additionalProperties": false, + "properties": { + "$ref": { + "type": "string", + "pattern": "^https://sonar.ch/api/subdivisions/.*?$", + "form": { + "remoteTypeahead": { + "type": "subdivisions", + "field": "name.value.suggest", + "label": "label" + } + } + } + }, + "required": [ + "$ref" + ] } }, "propertiesOrder": [ "organisation", "role", + "subdivision", "first_name", "last_name", "email", diff --git a/sonar/modules/users/mappings/v7/users/user-v1.0.0.json b/sonar/modules/users/mappings/v7/users/user-v1.0.0.json index 2157d77b..255e6bf4 100644 --- a/sonar/modules/users/mappings/v7/users/user-v1.0.0.json +++ b/sonar/modules/users/mappings/v7/users/user-v1.0.0.json @@ -67,6 +67,25 @@ } } }, + "subdivision": { + "type": "object", + "properties": { + "pid": { + "type": "keyword" + }, + "name": { + "type": "object", + "properties": { + "value": { + "type": "text" + }, + "language": { + "type": "keyword" + } + } + } + } + }, "_created": { "type": "date" }, diff --git a/sonar/modules/users/marshmallow/json.py b/sonar/modules/users/marshmallow/json.py index f98f810c..f648fcec 100644 --- a/sonar/modules/users/marshmallow/json.py +++ b/sonar/modules/users/marshmallow/json.py @@ -48,6 +48,7 @@ class UserMetadataSchemaV1(StrictKeysMixin): organisation = fields.Dict() role = SanitizedUnicode() full_name = SanitizedUnicode() + subdivision = fields.Dict() # When loading, if $schema is not provided, it's retrieved by # Record.schema property. schema = GenFunction(load_only=True, diff --git a/sonar/modules/users/serializers/__init__.py b/sonar/modules/users/serializers/__init__.py index 7477a3f3..ca1c0559 100644 --- a/sonar/modules/users/serializers/__init__.py +++ b/sonar/modules/users/serializers/__init__.py @@ -22,10 +22,28 @@ from invenio_records_rest.serializers.response import record_responsify, \ search_responsify -from sonar.modules.serializers import JSONSerializer +from sonar.modules.serializers import JSONSerializer as _JSONSerializer +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from ..marshmallow import UserSchemaV1 + +class JSONSerializer(_JSONSerializer): + """JSON serializer for users.""" + + def post_process_serialize_search(self, results, pid_fetcher): + """Post process the search results.""" + # Add subdivision name + for org_term in results.get('aggregations', + {}).get('subdivision', + {}).get('buckets', []): + subdivision = SubdivisionRecord.get_record_by_pid(org_term['key']) + if subdivision: + org_term['name'] = subdivision['name'][0]['value'] + + return super(JSONSerializer, + self).post_process_serialize_search(results, pid_fetcher) + # Serializers # =========== #: JSON serializer definition. diff --git a/sonar/theme/views.py b/sonar/theme/views.py index 387b61c6..7685f2b7 100644 --- a/sonar/theme/views.py +++ b/sonar/theme/views.py @@ -43,6 +43,8 @@ from sonar.modules.documents.permissions import DocumentPermission from sonar.modules.organisations.permissions import OrganisationPermission from sonar.modules.permissions import can_access_manage_view +from sonar.modules.subdivisions.permissions import \ + RecordPermission as SubdivisionPermission from sonar.modules.users.api import current_user_record from sonar.modules.users.permissions import UserPermission from sonar.resources.projects.permissions import RecordPermissionPolicy @@ -157,6 +159,10 @@ def logged_user(): 'collections': { 'add': CollectionPermission.create(user), 'list': CollectionPermission.list(user) + }, + 'subdivisions': { + 'add': SubdivisionPermission.create(user), + 'list': SubdivisionPermission.list(user) } } diff --git a/tests/api/collections/test_collections_permissions.py b/tests/api/collections/test_collections_permissions.py index 32fed464..7851f36c 100644 --- a/tests/api/collections/test_collections_permissions.py +++ b/tests/api/collections/test_collections_permissions.py @@ -54,8 +54,7 @@ def test_list(app, client, make_organisation, make_collection, superuser, # Logged as moderator login_user_via_session(client, email=moderator['email']) res = client.get(url_for('invenio_records_rest.coll_list')) - assert res.status_code == 200 - assert res.json['hits']['total']['value'] == 1 + assert res.status_code == 403 # Logged as admin login_user_via_session(client, email=admin['email']) @@ -104,7 +103,7 @@ def test_create(client, superuser, admin, moderator, submitter, user): res = client.post(url_for('invenio_records_rest.coll_list'), data=json.dumps(data), headers=headers) - assert res.status_code == 201 + assert res.status_code == 403 # Admin login_user_via_session(client, email=admin['email']) @@ -153,7 +152,7 @@ def test_read(client, make_organisation, make_collection, superuser, admin, res = client.get( url_for('invenio_records_rest.coll_item', pid_value=collection1['pid'])) - assert res.status_code == 200 + assert res.status_code == 403 # Logged as admin login_user_via_session(client, email=admin['email']) @@ -233,7 +232,7 @@ def test_update(client, make_organisation, make_collection, superuser, admin, pid_value=collection1['pid']), data=json.dumps(collection1.dumps()), headers=headers) - assert res.status_code == 200 + assert res.status_code == 403 # Logged as admin login_user_via_session(client, email=admin['email']) @@ -258,13 +257,6 @@ def test_update(client, make_organisation, make_collection, superuser, admin, headers=headers) assert res.status_code == 200 - login_user_via_session(client, email=superuser['email']) - res = client.put(url_for('invenio_records_rest.coll_item', - pid_value=collection1['pid']), - data=json.dumps(collection1.dumps()), - headers=headers) - assert res.status_code == 200 - def test_delete(client, db, document, collection, make_organisation, make_collection, superuser, admin, moderator, submitter, user): @@ -290,7 +282,7 @@ def test_delete(client, db, document, collection, make_organisation, login_user_via_session(client, email=moderator['email']) res = client.delete( url_for('invenio_records_rest.coll_item', pid_value=collection['pid'])) - assert res.status_code == 204 + assert res.status_code == 403 make_organisation('org2') collection2 = make_collection('org2') diff --git a/tests/api/deposits/test_deposits_permissions.py b/tests/api/deposits/test_deposits_permissions.py index ff5d7f22..7eeb3f57 100644 --- a/tests/api/deposits/test_deposits_permissions.py +++ b/tests/api/deposits/test_deposits_permissions.py @@ -138,8 +138,8 @@ def test_create(client, deposit_json, bucket_location, superuser, admin, assert res.status_code == 201 -def test_read(client, make_deposit, make_user, superuser, admin, moderator, - submitter, user): +def test_read(client, db, make_deposit, make_user, superuser, admin, moderator, + submitter, user, subdivision): """Test read deposits permissions.""" deposit1 = make_deposit('submitter', 'org') deposit2 = make_deposit('submitter', 'org2') @@ -171,6 +171,19 @@ def test_read(client, make_deposit, make_user, superuser, admin, moderator, url_for('invenio_records_rest.depo_item', pid_value=deposit1['pid'])) assert res.status_code == 200 + # Moderator has subdivision, I cannot read deposit outside of his + # subdivision + moderator['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + moderator.commit() + moderator.reindex() + db.session.commit() + res = client.get( + url_for('invenio_records_rest.depo_item', pid_value=deposit1['pid'])) + assert res.status_code == 403 + + # Cannot read deposit of other organisations res = client.get( url_for('invenio_records_rest.depo_item', pid_value=deposit2['pid'])) assert res.status_code == 403 @@ -181,6 +194,7 @@ def test_read(client, make_deposit, make_user, superuser, admin, moderator, url_for('invenio_records_rest.depo_item', pid_value=deposit1['pid'])) assert res.status_code == 200 + # Cannot read deposit of other organisations res = client.get( url_for('invenio_records_rest.depo_item', pid_value=deposit2['pid'])) assert res.status_code == 403 diff --git a/tests/api/deposits/test_deposits_rest.py b/tests/api/deposits/test_deposits_rest.py index 033f1433..d42781e2 100644 --- a/tests/api/deposits/test_deposits_rest.py +++ b/tests/api/deposits/test_deposits_rest.py @@ -107,8 +107,22 @@ def test_file_put(client, deposit): assert not response.json.get('embargoDate') -def test_publish(client, db, user, moderator, deposit): +def test_publish(client, db, user, moderator, subdivision, deposit): """Test publishing a deposit.""" + # Add a subdivision to moderator and user + user['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + user.commit() + user.reindex() + + moderator['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + moderator.commit() + moderator.reindex() + db.session.commit() + url = url_for('deposits.publish', pid=deposit['pid']) # Everything OK @@ -122,9 +136,7 @@ def test_publish(client, db, user, moderator, deposit): response = client.post(url, data={}) assert response.status_code == 400 - login_user_via_view(client, - email=moderator['email'], - password='123456') + login_user_via_view(client, email=moderator['email'], password='123456') # Test the publication by a moderator deposit['status'] = 'in_progress' @@ -181,9 +193,7 @@ def test_review(client, db, user, moderator, deposit): headers=headers) assert response.status_code == 403 - login_user_via_view(client, - email=moderator['email'], - password='123456') + login_user_via_view(client, email=moderator['email'], password='123456') # Valid approval request response = client.post(url, diff --git a/tests/api/documents/test_documents_permissions.py b/tests/api/documents/test_documents_permissions.py index 5eb68176..b5699a80 100644 --- a/tests/api/documents/test_documents_permissions.py +++ b/tests/api/documents/test_documents_permissions.py @@ -216,8 +216,8 @@ def test_read(client, document, make_user, superuser, admin, moderator, } -def test_update(client, document, make_user, superuser, admin, moderator, - submitter, user): +def test_update(client, db, document, make_user, superuser, admin, moderator, + submitter, user, subdivision, make_subdivision): """Test update documents permissions.""" headers = { @@ -256,6 +256,58 @@ def test_update(client, document, make_user, superuser, admin, moderator, headers=headers) assert res.status_code == 200 + # Document has a subdivision, user have not. + document['subdivisions'] = [{ + '$ref': + f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + }] + document.commit() + document.reindex() + db.session.commit() + res = client.put(url_for('invenio_records_rest.doc_item', + pid_value=document['pid']), + data=json.dumps(document.dumps()), + headers=headers) + assert res.status_code == 200 + + # Document has a subdivision, and user have a different. + new_subdivision = make_subdivision('org') + moderator['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{new_subdivision["pid"]}' + } + moderator.commit() + moderator.reindex() + db.session.commit() + res = client.put(url_for('invenio_records_rest.doc_item', + pid_value=document['pid']), + data=json.dumps(document.dumps()), + headers=headers) + assert res.status_code == 403 + + # Document has a subdivision, and user have the same. + moderator['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + moderator.commit() + moderator.reindex() + db.session.commit() + res = client.put(url_for('invenio_records_rest.doc_item', + pid_value=document['pid']), + data=json.dumps(document.dumps()), + headers=headers) + assert res.status_code == 200 + + # Document has no subdivision, and user has one. + document.pop('subdivisions', None) + document.commit() + document.reindex() + db.session.commit() + res = client.put(url_for('invenio_records_rest.doc_item', + pid_value=document['pid']), + data=json.dumps(document.dumps()), + headers=headers) + assert res.status_code == 403 + # Logged as admin login_user_via_session(client, email=admin['email']) res = client.put(url_for('invenio_records_rest.doc_item', @@ -329,8 +381,8 @@ def test_delete(client, document, make_document, make_user, superuser, admin, # Logged as superuser login_user_via_session(client, email=superuser['email']) pid = document['pid'] - res = client.delete( - url_for('invenio_records_rest.doc_item', pid_value=pid)) + res = client.delete(url_for('invenio_records_rest.doc_item', + pid_value=pid)) assert res.status_code == 204 assert PersistentIdentifier.get('ark', ark_id).status == \ PIDStatus.DELETED diff --git a/tests/api/monitoring/test_monitoring_views.py b/tests/api/monitoring/test_monitoring_views.py index 41b2a435..44cd18f1 100644 --- a/tests/api/monitoring/test_monitoring_views.py +++ b/tests/api/monitoring/test_monitoring_views.py @@ -186,6 +186,12 @@ def test_data_info(client, es_clear, superuser, document, monkeypatch): 'es': 0, 'db-es': 0, 'index': 'collections' + }, + 'subd': { + 'db': 0, + 'es': 0, + 'db-es': 0, + 'index': 'subdivisions' } } } diff --git a/tests/api/subdivisions/test_subdivisions_deposits_facets.py b/tests/api/subdivisions/test_subdivisions_deposits_facets.py new file mode 100644 index 00000000..314fb34e --- /dev/null +++ b/tests/api/subdivisions/test_subdivisions_deposits_facets.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Test subdivisions facets in deposits.""" + +from flask import url_for +from invenio_accounts.testutils import login_user_via_session + + +def test_list(app, db, client, deposit, superuser): + """Test subdivision facet.""" + login_user_via_session(client, email=superuser['email']) + res = client.get(url_for('invenio_records_rest.depo_list')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 1 + assert res.json['aggregations']['subdivision']['buckets'] == [{ + 'key': + '2', + 'doc_count': + 1, + 'name': + 'Subdivision name' + }] diff --git a/tests/api/subdivisions/test_subdivisions_documents_facets.py b/tests/api/subdivisions/test_subdivisions_documents_facets.py new file mode 100644 index 00000000..a8768bdb --- /dev/null +++ b/tests/api/subdivisions/test_subdivisions_documents_facets.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Test subdivisions facets in documents.""" + +from flask import url_for +from invenio_accounts.testutils import login_user_via_session + + +def test_list(app, db, client, document, subdivision, superuser): + document['subdivisions'] = [{ + '$ref': + 'https://sonar.ch/api/subdivisions/{pid}'.format( + pid=subdivision['pid']) + }] + document.commit() + db.session.commit() + document.reindex() + + login_user_via_session(client, email=superuser['email']) + res = client.get(url_for('invenio_records_rest.doc_list', view='org')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 1 + assert res.json['aggregations']['subdivision']['buckets'] == [{ + 'key': + '2', + 'doc_count': + 1, + 'name': + 'Subdivision name' + }] + + # Don't display aggregation in global context + res = client.get(url_for('invenio_records_rest.doc_list', view='global')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 1 + assert 'subdivision' not in res.json['aggregations'] diff --git a/tests/api/subdivisions/test_subdivisions_permissions.py b/tests/api/subdivisions/test_subdivisions_permissions.py new file mode 100644 index 00000000..4d9d10c8 --- /dev/null +++ b/tests/api/subdivisions/test_subdivisions_permissions.py @@ -0,0 +1,348 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Test subdivisions permissions.""" + +import json + +from flask import url_for +from invenio_accounts.testutils import login_user_via_session + + +def test_list(app, client, make_organisation, make_subdivision, superuser, + admin, moderator, submitter, user): + """Test list subdivisions permissions.""" + make_organisation('org2') + make_subdivision('org') + make_subdivision('org2') + + # Not logged + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 401 + + # Not logged but permission checks disabled + app.config.update(SONAR_APP_DISABLE_PERMISSION_CHECKS=True) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 2 + app.config.update(SONAR_APP_DISABLE_PERMISSION_CHECKS=False) + + # Logged as user + login_user_via_session(client, email=user['email']) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 403 + + # Logged as submitter + login_user_via_session(client, email=submitter['email']) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 200 + + # Logged as moderator + login_user_via_session(client, email=moderator['email']) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 200 + + # Logged as admin + login_user_via_session(client, email=admin['email']) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 1 + + # Logged as superuser + login_user_via_session(client, email=superuser['email']) + res = client.get(url_for('invenio_records_rest.subd_list')) + assert res.status_code == 200 + assert res.json['hits']['total']['value'] == 2 + + +def test_create(client, superuser, admin, moderator, submitter, user): + """Test create subdivisions permissions.""" + data = {'name': [{'language': 'eng', 'value': 'Name'}]} + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + # Not logged + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 401 + + # User + login_user_via_session(client, email=user['email']) + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 403 + + # submitter + login_user_via_session(client, email=submitter['email']) + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 403 + + # Moderator + login_user_via_session(client, email=moderator['email']) + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 403 + + # Admin + login_user_via_session(client, email=admin['email']) + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 201 + + # Super user + login_user_via_session(client, email=superuser['email']) + res = client.post(url_for('invenio_records_rest.subd_list'), + data=json.dumps(data), + headers=headers) + assert res.status_code == 201 + + +def test_read(client, make_organisation, make_subdivision, superuser, admin, + moderator, submitter, user): + """Test read subdivisions permissions.""" + make_organisation('org2') + subdivision1 = make_subdivision('org') + subdivision2 = make_subdivision('org2') + + # Not logged + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 401 + + # Logged as user + login_user_via_session(client, email=user['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 403 + + # Logged as submitter + login_user_via_session(client, email=submitter['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 200 + + # Logged as moderator + login_user_via_session(client, email=moderator['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 200 + + # Logged as admin + login_user_via_session(client, email=admin['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 200 + assert res.json['metadata']['permissions'] == { + 'delete': True, + 'read': True, + 'update': True + } + + # Logged as admin of other organisation + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision2['pid'])) + assert res.status_code == 403 + + # Logged as superuser + login_user_via_session(client, email=superuser['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid'])) + assert res.status_code == 200 + + login_user_via_session(client, email=superuser['email']) + res = client.get( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision2['pid'])) + assert res.status_code == 200 + assert res.json['metadata']['permissions'] == { + 'delete': True, + 'read': True, + 'update': True + } + + +def test_update(client, make_organisation, make_subdivision, superuser, admin, + moderator, submitter, user): + """Test update subdivisions permissions.""" + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + make_organisation('org2') + subdivision1 = make_subdivision('org') + subdivision2 = make_subdivision('org2') + + # Not logged + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 401 + + # Logged as user + login_user_via_session(client, email=user['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 403 + + # Logged as submitter + login_user_via_session(client, email=submitter['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 403 + + # Logged as moderator + login_user_via_session(client, email=moderator['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 403 + + # Logged as admin + login_user_via_session(client, email=admin['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 200 + + # Logged as admin of other organisation + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision2['pid']), + data=json.dumps(subdivision2.dumps()), + headers=headers) + assert res.status_code == 403 + + # Logged as superuser + login_user_via_session(client, email=superuser['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 200 + + login_user_via_session(client, email=superuser['email']) + res = client.put(url_for('invenio_records_rest.subd_item', + pid_value=subdivision1['pid']), + data=json.dumps(subdivision1.dumps()), + headers=headers) + assert res.status_code == 200 + + +def test_delete(client, db, document, subdivision, make_organisation, + make_subdivision, superuser, admin, moderator, submitter, + user): + """Test delete subdivisions permissions.""" + # Not logged + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 401 + + # Logged as user + login_user_via_session(client, email=user['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 403 + + # Logged as submitter + login_user_via_session(client, email=submitter['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 403 + + # Logged as moderator + login_user_via_session(client, email=moderator['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 403 + + make_organisation('org2') + subdivision2 = make_subdivision('org2') + + # Cannot remove subdivision from other organisation + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision2['pid'])) + assert res.status_code == 403 + + subdivision = make_subdivision('org') + + # Logged as admin + login_user_via_session(client, email=admin['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 204 + + subdivision = make_subdivision('org') + + # Logged as superuser + login_user_via_session(client, email=superuser['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 204 + + # Can remove any subdivision + login_user_via_session(client, email=superuser['email']) + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision2['pid'])) + assert res.status_code == 204 + + subdivision = make_subdivision('org') + + # Cannot remove subdivision as it is linked to document. + document['subdivisions'] = [{ + '$ref': + 'https://sonar.ch/api/subdivisions/{pid}'.format( + pid=subdivision['pid']) + }] + document.commit() + db.session.commit() + document.reindex() + + res = client.delete( + url_for('invenio_records_rest.subd_item', + pid_value=subdivision['pid'])) + assert res.status_code == 403 diff --git a/tests/api/subdivisions/test_subdivisions_users_facets.py b/tests/api/subdivisions/test_subdivisions_users_facets.py new file mode 100644 index 00000000..d496c1cb --- /dev/null +++ b/tests/api/subdivisions/test_subdivisions_users_facets.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Swiss Open Access Repository +# Copyright (C) 2021 RERO +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Test subdivisions facets in users.""" + +from flask import url_for +from invenio_accounts.testutils import login_user_via_session + + +def test_list(app, db, client, deposit, superuser, subdivision): + """Test subdivision facet.""" + superuser['subdivision'] = { + '$ref': + 'https://sonar.ch/api/subdivisions/{pid}'.format( + pid=subdivision['pid']) + } + superuser.commit() + db.session.commit() + superuser.reindex() + + login_user_via_session(client, email=superuser['email']) + res = client.get(url_for('invenio_records_rest.user_list')) + assert res.status_code == 200 + assert res.json['aggregations']['subdivision']['buckets'] == [{ + 'key': + '2', + 'doc_count': + 1, + 'name': + 'Subdivision name' + }] diff --git a/tests/conftest.py b/tests/conftest.py index e6e51e00..e25f0f6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,7 @@ from sonar.modules.deposits.api import DepositRecord from sonar.modules.documents.api import DocumentRecord from sonar.modules.organisations.api import OrganisationRecord +from sonar.modules.subdivisions.api import Record as SubdivisionRecord from sonar.modules.users.api import UserRecord from sonar.proxies import sonar @@ -41,12 +42,15 @@ def mock_ark(app, monkeypatch): """Mock for the ARK module.""" # be sure that we do not make any request on the ARK server - monkeypatch.setattr('requests.get', - lambda *args, **kwargs: MockArkServer.get(*args, **kwargs)) - monkeypatch.setattr('requests.post', - lambda *args, **kwargs: MockArkServer.post(*args, **kwargs)) - monkeypatch.setattr('requests.put', - lambda *args, **kwargs: MockArkServer.put(*args, **kwargs)) + monkeypatch.setattr( + 'requests.get', lambda *args, **kwargs: MockArkServer.get( + *args, **kwargs)) + monkeypatch.setattr( + 'requests.post', lambda *args, **kwargs: MockArkServer.post( + *args, **kwargs)) + monkeypatch.setattr( + 'requests.put', lambda *args, **kwargs: MockArkServer.put( + *args, **kwargs)) # enable ARK monkeypatch.setitem(app.config, 'SONAR_APP_ARK_NMA', 'https://www.arketype.ch') @@ -454,7 +458,7 @@ def document_with_file(make_document): @pytest.fixture() -def deposit_json(collection): +def deposit_json(collection, subdivision): """Deposit JSON.""" return { '$schema': @@ -542,8 +546,14 @@ def deposit_json(collection): }] }, 'diffusion': { - 'license': 'CC0', - 'oa_status': 'green' + 'license': + 'CC0', + 'oa_status': + 'green', + 'subdivisions': [{ + '$ref': + f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + }], }, 'status': 'in_progress', @@ -780,6 +790,50 @@ def collection(app, db, es, admin, organisation, collection_json): return collection +@pytest.fixture() +def subdivision_json(): + """Subdivision JSON.""" + return {'name': [{'language': 'eng', 'value': 'Subdivision name'}]} + + +@pytest.fixture() +def make_subdivision(app, db, subdivision_json): + """Factory for creating subdivision.""" + + def _make_subdivision(organisation=None): + subdivision_json['organisation'] = { + '$ref': + 'https://sonar.ch/api/organisations/{pid}'.format(pid=organisation) + } + + subdivision_json.pop('pid', None) + + subdivision = SubdivisionRecord.create(subdivision_json, dbcommit=True) + subdivision.commit() + subdivision.reindex() + db.session.commit() + return subdivision + + return _make_subdivision + + +@pytest.fixture() +def subdivision(app, db, es, admin, organisation, subdivision_json): + """Subdivision fixture.""" + json = copy.deepcopy(subdivision_json) + json['organisation'] = { + '$ref': + 'https://sonar.ch/api/organisations/{pid}'.format( + pid=organisation['pid']) + } + + subdivision = SubdivisionRecord.create(json, dbcommit=True) + subdivision.commit() + subdivision.reindex() + db.session.commit() + return subdivision + + @pytest.fixture() def bucket_location(app, db): """Create a default location for managing files.""" diff --git a/tests/ui/deposits/test_deposits_api.py b/tests/ui/deposits/test_deposits_api.py index 808892c0..2369227a 100644 --- a/tests/ui/deposits/test_deposits_api.py +++ b/tests/ui/deposits/test_deposits_api.py @@ -20,20 +20,27 @@ from invenio_accounts.testutils import login_user_via_view -def test_create_document(app, db, project, client, deposit, submitter): +def test_create_document(app, db, project, client, deposit, submitter, + subdivision): """Test create document based on it.""" + submitter['subdivision'] = { + '$ref': f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + submitter.commit() + submitter.reindex() + db.session.commit() + + deposit['user'] = { + '$ref': f'https://sonar.ch/api/users/{submitter["pid"]}' + } deposit['projects'] = [{ '$ref': f'https://sonar.ch/api/projects/{project.id}' }, { - 'name': - 'Project 1', - 'description': - 'Description', - 'startDate': - '2020-01-01', - 'endDate': - '2021-12-31' + 'name': 'Project 1', + 'description': 'Description', + 'startDate': '2020-01-01', + 'endDate': '2021-12-31' }] deposit.commit() deposit.reindex() @@ -188,3 +195,8 @@ def test_create_document(app, db, project, client, deposit, submitter): }, 'role': ['cre'] }] + + # Test subdivision + deposit['diffusion'].pop('subdivisions', None) + document = deposit.create_document() + assert document['subdivisions'][0] == submitter['subdivision'] diff --git a/tests/ui/documents/dojson/rerodoc/test_rerodoc_model.py b/tests/ui/documents/dojson/rerodoc/test_rerodoc_model.py index 0910274b..aee78470 100644 --- a/tests/ui/documents/dojson/rerodoc/test_rerodoc_model.py +++ b/tests/ui/documents/dojson/rerodoc/test_rerodoc_model.py @@ -133,7 +133,7 @@ def test_marc21_to_type_and_organisation(app, bucket_location, '$ref': 'https://sonar.ch/api/organisations/vge' }] - assert data['sections'] == ['bge'] + assert len(data['subdivisions']) == 1 # Specific conversion for mhnge marc21xml = """ @@ -150,7 +150,7 @@ def test_marc21_to_type_and_organisation(app, bucket_location, '$ref': 'https://sonar.ch/api/organisations/vge' }] - assert data['sections'] == ['mhnge'] + assert len(data['subdivisions']) == 1 def test_marc21_to_title_245(): diff --git a/tests/ui/users/test_users_api.py b/tests/ui/users/test_users_api.py index 8047dc66..bf343a63 100644 --- a/tests/ui/users/test_users_api.py +++ b/tests/ui/users/test_users_api.py @@ -17,30 +17,72 @@ """Test API for user records.""" - from sonar.modules.users.api import UserRecord, UserSearch -def test_get_moderators(app, organisation, roles): +def test_get_moderators(app, db, organisation, subdivision, roles, es_clear): """Test search for moderators.""" - user = UserRecord.create( - { + for item in [{ + 'email': 'moderator@gmail.com', + 'role': UserRecord.ROLE_MODERATOR, + 'subdivision': False + }, { + 'email': 'moderator+subdivision@gmail.com', + 'role': UserRecord.ROLE_MODERATOR, + 'subdivision': True + }, { + 'email': 'admin@gmail.com', + 'role': UserRecord.ROLE_ADMIN, + 'subdivision': False + }, { + 'email': 'admin+subdivision@gmail.com', + 'role': UserRecord.ROLE_ADMIN, + 'subdivision': True + }]: + data = { 'first_name': 'John', 'last_name': 'Doe', - 'email': 'john.doe@rero.ch', - 'role': UserRecord.ROLE_MODERATOR, + 'email': item['email'], + 'role': item['role'], 'organisation': { '$ref': 'https://sonar.ch/api/organisations/org' } - }, - dbcommit=True) - user.reindex() - - moderators = UserSearch().get_moderators() - assert list(moderators) + } - moderators = UserSearch().get_moderators('not_existing_organisation') - assert not list(moderators) + if item['subdivision']: + data['subdivision'] = { + '$ref': + f'https://sonar.ch/api/subdivisions/{subdivision["pid"]}' + } + user = UserRecord.create(data, dbcommit=True) + user.reindex() + + moderators = [result['email'] for result in UserSearch().get_moderators()] + assert 'moderator@gmail.com' in moderators + assert 'admin@gmail.com' in moderators + assert 'admin+subdivision@gmail.com' in moderators + + moderators = [ + result['email'] + for result in UserSearch().get_moderators('not_existing_organisation') + ] + assert not moderators + + moderators = [ + result['email'] for result in UserSearch().get_moderators('org') + ] + assert 'moderator@gmail.com' in moderators + assert 'admin@gmail.com' in moderators + assert 'admin+subdivision@gmail.com' in moderators + + # Get moderators from the same subdivision + moderators = [ + result['email'] + for result in UserSearch().get_moderators('org', subdivision['pid']) + ] + assert 'moderator+subdivision@gmail.com' in moderators + assert 'admin@gmail.com' in moderators + assert 'admin+subdivision@gmail.com' in moderators def test_get_reachable_roles(app): @@ -129,8 +171,7 @@ def test_delete(app, admin): """Test removing record.""" admin.delete(dbcommit=True, delindex=True) - deleted = UserRecord.get_record(admin.id, - with_deleted=True) + deleted = UserRecord.get_record(admin.id, with_deleted=True) assert deleted.id == admin.id with app.app_context():