From d577541505f317c925448a50696c754548bbf366 Mon Sep 17 00:00:00 2001 From: Nicolas Harraudeau Date: Tue, 8 Nov 2016 18:59:03 +0100 Subject: [PATCH] global: Community admin and member roles added * NEW Adds Community admin and member roles. * NEW Creates publication workflows and adds "direct publish" workflow. * NEW Enables communities to restrict new submissions to their members. Signed-off-by: Nicolas Harraudeau --- b2share/config.py | 4 +- b2share/modules/communities/api.py | 49 ++++++-- b2share/modules/communities/errors.py | 8 ++ b2share/modules/communities/ext.py | 2 - b2share/modules/communities/models.py | 62 +++++++++ b2share/modules/communities/serializers.py | 2 + b2share/modules/communities/workflows.py | 79 ++++++++++++ b2share/modules/deposit/api.py | 13 +- b2share/modules/deposit/permissions.py | 106 +++++++++++----- tests/b2share_functional_tests/test_search.py | 11 ++ .../b2share_unit_tests/communities/helpers.py | 2 + .../communities/test_communities_api.py | 8 +- .../deposit/test_deposit_api.py | 16 ++- .../deposit/test_deposit_rest.py | 118 ++++++++++++++++-- tests/b2share_unit_tests/helpers.py | 24 ++-- tests/conftest.py | 36 +++--- 16 files changed, 450 insertions(+), 90 deletions(-) create mode 100644 b2share/modules/communities/workflows.py diff --git a/b2share/config.py b/b2share/config.py index fbdd188f8f..0ce9166043 100644 --- a/b2share/config.py +++ b/b2share/config.py @@ -54,7 +54,7 @@ # FIXME disable authentication by default as B2Access integration is not yet # done. -B2SHARE_COMMUNITIES_REST_ACCESS_CONTROL_DISABLED = True +B2SHARE_COMMUNITIES_REST_ACCESS_CONTROL_DISABLED = False # Records # ======= @@ -82,6 +82,7 @@ 'application/json-patch+json': lambda: request.get_json(force=True), 'application/json': + # FIXME: create a loader so that only allowed fields can be set lambda: request.get_json(), # 'b2share.modules.deposit.loaders:deposit_record_loader' }, @@ -121,6 +122,7 @@ 'application/json-patch+json': lambda: request.get_json(force=True), 'application/json': + # FIXME: create a loader so that only allowed fields can be set lambda: request.get_json(), # 'b2share.modules.deposit.loaders:deposit_record_loader' }, diff --git a/b2share/modules/communities/api.py b/b2share/modules/communities/api.py index fe4cef9220..58af4d2e44 100644 --- a/b2share/modules/communities/api.py +++ b/b2share/modules/communities/api.py @@ -30,12 +30,15 @@ from invenio_db import db from jsonpatch import apply_patch from sqlalchemy.orm.exc import NoResultFound +from invenio_accounts.models import Role from .errors import CommunityDeletedError, CommunityDoesNotExistError, \ InvalidCommunityError from .signals import after_community_delete, after_community_insert, \ after_community_update, before_community_delete, before_community_insert, \ before_community_update +from .models import Community as CommunityMetadata, _communiy_admin_role_name, \ + _communiy_member_role_name class Community(object): @@ -70,7 +73,6 @@ def get(cls, id=None, name=None, with_deleted=False): ValueError: :attr:`id` and :attr:`name` are not set or both are set. """ - from .models import Community as CommunityMetadata if not id and not name: raise ValueError('"id" or "name" should be set.') if id and name: @@ -91,7 +93,6 @@ def get(cls, id=None, name=None, with_deleted=False): @classmethod def get_all(cls, start=None, stop=None, name=None): """Searches for matching communities.""" - from .models import Community as CommunityMeta if (start is None and stop is None): if name is None: metadata = CommunityMeta.query.order_by(CommunityMeta.created) @@ -113,7 +114,9 @@ def get_all(cls, start=None, stop=None, name=None): return [cls(md) for md in metadata] @classmethod - def create_community(cls, name, description, logo=None, id_=None): + def create_community(cls, name, description, logo=None, id_=None, + publication_workflow=None, + restricted_submission=False): """Create a new Community. A new community is implicitly associated with a new, empty, schema @@ -131,7 +134,6 @@ def create_community(cls, name, description, logo=None, id_=None): b2share.modules.communities.errors.InvalidCommunityError: The community creation failed because the arguments are not valid. """ - from .models import Community as CommunityMetadata try: with db.session.begin_nested(): kwargs = {} @@ -155,7 +157,8 @@ def update(self, data, clear_fields=False): Args: data (dict): can have one of those fields: name, description, logo. it replaces the given values. - clear_fields (bool): if True, set not specified fields to None. + clear_fields (bool): if True, set not specified fields to their + default value. Returns: :class:`Community`: self @@ -171,9 +174,14 @@ def update(self, data, clear_fields=False): if clear_fields: for field in ['name', 'description', 'logo']: setattr(self.model, field, data.get(field, None)) - else: - for key, value in data.items(): - setattr(self.model, key, value) + self.model.publication_workflow = \ + 'review_and_publish' + self.model.restricted_submission = False + # FIXME: what do we do when the publication_workflow is changed? + # Do we run the new workflow on all records in order to fix the + # their publication_state? + for key, value in data.items(): + setattr(self.model, key, value) db.session.merge(self.model) except sqlalchemy.exc.IntegrityError as e: raise InvalidCommunityError() from e @@ -203,7 +211,8 @@ def patch(self, patch): data = apply_patch({ 'name': self.model.name, 'description': self.model.description, - 'logo': self.model.logo + 'logo': self.model.logo, + 'publication_workflow': self.model.publication_workflow, }, patch, True) self.update(data) return self @@ -262,3 +271,25 @@ def description(self): def logo(self): """Retrieve community's logo.""" return self.model.logo + + @property + def publication_workflow(self): + """Retrieve the name of the publication workflow.""" + return self.model.publication_workflow + + @property + def restricted_submission(self): + """Retrieve the deposit creation restriction flag.""" + return self.model.restricted_submission + + @property + def admin_role(self): + """Role given to this community's administrators.""" + return Role.query.filter( + Role.name == _communiy_admin_role_name(self)).one() + + @property + def member_role(self): + """Role given to this community's members.""" + return Role.query.filter( + Role.name == _communiy_member_role_name(self)).one() diff --git a/b2share/modules/communities/errors.py b/b2share/modules/communities/errors.py index 6a3d4db31d..4b04c95220 100644 --- a/b2share/modules/communities/errors.py +++ b/b2share/modules/communities/errors.py @@ -25,6 +25,8 @@ from __future__ import absolute_import +from invenio_rest.errors import RESTException + class InvalidCommunityError(Exception): """Exception raised when a community is invalid.""" @@ -40,3 +42,9 @@ class CommunityDeletedError(Exception): """Exception raised when a requested community is marked as deleted.""" pass + +class InvalidPublicationStateError(RESTException): + """Exception raised when a deposit is an invalid publication state.""" + + code = 400 + """HTTP Status code.""" diff --git a/b2share/modules/communities/ext.py b/b2share/modules/communities/ext.py index 2944df6341..4d0b3bb819 100644 --- a/b2share/modules/communities/ext.py +++ b/b2share/modules/communities/ext.py @@ -25,7 +25,6 @@ from werkzeug.utils import cached_property from . import config -from .views import blueprint from .cli import communities as communities_cmd @@ -68,7 +67,6 @@ def __init__(self, app=None): def init_app(self, app): """Flask application initialization.""" self.init_config(app) - app.register_blueprint(blueprint) app.cli.add_command(communities_cmd) app.extensions['b2share-communities'] = _B2ShareCommunitiesState(app) diff --git a/b2share/modules/communities/models.py b/b2share/modules/communities/models.py index 6a3846d8ab..16c4509e52 100644 --- a/b2share/modules/communities/models.py +++ b/b2share/modules/communities/models.py @@ -24,11 +24,15 @@ """Community models.""" import uuid +from itertools import chain from invenio_db import db from sqlalchemy.sql import expression from sqlalchemy_utils.models import Timestamp from sqlalchemy_utils.types import UUIDType +from sqlalchemy import event +from invenio_accounts.models import Role +from invenio_access.models import ActionRoles class Community(db.Model, Timestamp): @@ -62,7 +66,65 @@ class Community(db.Model, Timestamp): deleted = db.Column(db.Boolean, nullable=False, server_default=expression.false()) + # Publication workflow used in this community + publication_workflow = db.Column(db.String(80), nullable=False, + default='review_and_publish') + + # Restrict record creation + restricted_submission = db.Column(db.Boolean, nullable=False, + server_default=expression.false(), + default=False) + + +def _communiy_admin_role_name(community): + """Generate the name of the given community's admin role.""" + return 'com:{0}:{1}'.format(community.id.hex, 'admin') + + +def _communiy_member_role_name(community): + """Generate the name of the given community's member role.""" + return 'com:{0}:{1}'.format(community.id.hex, 'member') + + +@event.listens_for(Community, 'after_insert') +def receive_before_insert(mapper, connection, target): + """Create community admin and member roles and add their permissions.""" + from b2share.modules.deposit.permissions import ( + create_deposit_need_factory, read_deposit_need_factory, + ) + from b2share.modules.deposit.api import PublicationStates + + admin_role = Role( + name=_communiy_admin_role_name(target), + description='Admin role of the community "{}"'.format(target.name) + ) + member_role = Role( + name=_communiy_member_role_name(target), + description='Member role of the community "{}"'.format(target.name) + ) + + db.session.add(admin_role) + db.session.add(member_role) + member_needs = [ + create_deposit_need_factory(str(target.id)), + ] + admin_needs = [ + read_deposit_need_factory( + community=str(target.id), + publication_state=PublicationStates.submitted.name + ), + read_deposit_need_factory( + community=str(target.id), + publication_state=PublicationStates.published.name + ), + ] + for need in member_needs: + db.session.add(ActionRoles.allow(need, role=member_role)) + for need in chain (member_needs, admin_needs): + db.session.add(ActionRoles.allow(need, role=admin_role)) + __all__ = ( 'Community', + 'CommunityRole', ) diff --git a/b2share/modules/communities/serializers.py b/b2share/modules/communities/serializers.py index d91164aaed..35910e6a9e 100644 --- a/b2share/modules/communities/serializers.py +++ b/b2share/modules/communities/serializers.py @@ -51,6 +51,8 @@ def community_to_dict(community): logo=community.logo, created=community.created, updated=community.updated, + publication_workflow=community.publication_workflow, + restricted_submission=community.restricted_submission, links=dict( self=community_self_link(community, _external=True) ) diff --git a/b2share/modules/communities/workflows.py b/b2share/modules/communities/workflows.py new file mode 100644 index 0000000000..0505cce4f1 --- /dev/null +++ b/b2share/modules/communities/workflows.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# This file is part of EUDAT B2Share. +# Copyright (C) 2015, 2016, University of Tuebingen, CERN. +# +# B2Share is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# B2Share is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with B2Share; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + +"""Publication workflows.""" + +from .errors import InvalidPublicationStateError + +def review_and_publish_workflow(previous_model, new_deposit): + """Workflow publishing the deposits on submission.""" + # import ipdb + # ipdb.set_trace() + from b2share.modules.deposit.api import PublicationStates + new_state = new_deposit['publication_state'] + previous_state = previous_model.json['publication_state'] + if previous_state != new_state: + transition = (previous_state, new_state) + # Check that the transition is a valid one + if transition not in [ + (PublicationStates.draft.name, PublicationStates.submitted.name), + (PublicationStates.submitted.name, PublicationStates.draft.name), + (PublicationStates.submitted.name, + PublicationStates.published.name), + ]: + raise InvalidPublicationStateError( + description='Transition from publication state {0} to {1} is' + 'not allowed by community\'s workflow {2}'.format( + previous_state, new_state, 'review_and_publish' + ) + ) + + +def direct_publish_workflow(previous_model, new_deposit): + """Workflow publishing the deposits on submission.""" + from b2share.modules.deposit.api import PublicationStates + + new_state = new_deposit['publication_state'] + previous_state = previous_model.json['publication_state'] + if previous_state != new_state: + transition = (previous_state, new_state) + # Check that the transition is a valid one + if transition not in [ + (PublicationStates.draft.name, PublicationStates.submitted.name), + (PublicationStates.draft.name, PublicationStates.published.name), + ]: + raise InvalidPublicationStateError( + description='Transition from publication state {0} to {1} is ' + 'not allowed by community\'s workflow {2}'.format( + previous_state, new_state, 'review_and_publish' + ) + ) + # Publish automatically when submitted + if new_state == PublicationStates.submitted.name: + new_deposit['publication_state'] = PublicationStates.published.name + + +publication_workflows = { + 'review_and_publish': review_and_publish_workflow, + 'direct_publish': direct_publish_workflow, +} diff --git a/b2share/modules/deposit/api.py b/b2share/modules/deposit/api.py index 941fdd2f6d..78976c8936 100644 --- a/b2share/modules/deposit/api.py +++ b/b2share/modules/deposit/api.py @@ -39,6 +39,8 @@ from invenio_records_files.api import Record from .errors import InvalidDepositDataError, InvalidDepositStateError +from b2share.modules.communities.api import Community +from b2share.modules.communities.workflows import publication_workflows from invenio_records.errors import MissingModelError from b2share.modules.records.errors import InvalidRecordError from b2share.modules.access.policies import is_under_embargo @@ -146,14 +148,9 @@ def commit(self): if is_under_embargo(self): self['open_access'] = False - # test invalid state transitions - if (self['publication_state'] == PublicationStates.submitted.name - and self.model.json['publication_state'] != - PublicationStates.draft.name): - raise InvalidDepositStateError( - 'Cannot submit a deposit in {} state'.format( - self.model.json['publication_state']) - ) + community = Community.get(self['community']) + workflow = publication_workflows[community.publication_workflow] + workflow(self.model, self) # publish the deposition if needed if (self['publication_state'] == PublicationStates.published.name diff --git a/b2share/modules/deposit/permissions.py b/b2share/modules/deposit/permissions.py index 9aad1be9a6..a4c77c04b8 100644 --- a/b2share/modules/deposit/permissions.py +++ b/b2share/modules/deposit/permissions.py @@ -23,9 +23,11 @@ """Access controls for deposits.""" +import json from copy import deepcopy from collections import namedtuple from itertools import chain +from functools import partial from jsonpatch import apply_patch, JsonPatchException from flask_principal import UserNeed @@ -33,37 +35,78 @@ superuser_access, ParameterizedActionNeed, DynamicPermission ) from invenio_access.models import ActionUsers, ActionRoles +from flask_security import current_user from invenio_accounts.models import userrole from flask import request, abort -from b2share.modules.access.permissions import (OrPermissions, AndPermissions, +from b2share.modules.access.permissions import (AuthenticatedNeed, + OrPermissions, AndPermissions, StrictDynamicPermission) +from b2share.modules.communities.api import Community from invenio_db import db + +def _deposit_need_factory(name, **kwargs): + if kwargs: + for key, value in enumerate(kwargs): + if value is None: + del kwargs[key] + + if not kwargs: + argument = None + else: + argument = json.dumps(kwargs, separators=(',', ':'), sort_keys=True) + return ParameterizedActionNeed(name, argument) + + +def create_deposit_need_factory(community=None, publication_state='draft'): + # FIXME: check that the community_id and publication_state exist + return _deposit_need_factory('create-deposit', + community=community, + publication_state=publication_state) + + +def read_deposit_need_factory(community=None, publication_state='draft'): + # FIXME: check that the community_id and publication_state exist + return _deposit_need_factory('read-deposit', + community=community, + publication_state=publication_state) + + +ReadableCommunities = namedtuple('ReadableCommunities', ['all', 'communities']) + + def list_readable_communities(user_id): - result = namedtuple('ReadableCommunities', ['all', 'communities'])( - set(), {}) + """List all communities whose records can be read by the given user. + + Args: + user_id: id of the user which has read access to the retured + communities. + + Returns: + ReadableCommunities: list of communities which can be read by the + given user with the publication_states limitation when the access + is restricted to some states. + """ + result = ReadableCommunities(set(), {}) roles_needs = db.session.query(ActionRoles).join( userrole, ActionRoles.role_id == userrole.columns['role_id'] ).filter( userrole.columns['user_id'] == user_id, - ActionRoles.action.like('read-deposit-%') + ActionRoles.action == 'read-deposit', ).all() user_needs = ActionUsers.query.filter( ActionUsers.user_id == user_id, - ActionRoles.action.like('read-deposit-%'), + ActionUsers.action == 'read-deposit', ).all() for need in chain(roles_needs, user_needs): - publication_state = need.action[13:] - community_id = need.argument - if community_id is None: - result.all.add(publication_state) - else: - result.communities.setdefault(community_id, set()).add( - publication_state) + argument = json.loads(need.argument) + result.communities.setdefault(argument['community'], set()).add( + argument['publication_state']) return result + class CreateDepositPermission(OrPermissions): """Deposit read permission.""" @@ -78,18 +121,20 @@ def __init__(self, record=None): if record is not None: needs = set() - needs.add(ParameterizedActionNeed( - 'create-deposit-{}'.format( - record.get('publication_state', 'draft')), - record['community']) - ) - needs.add(ParameterizedActionNeed( - 'create-deposit-{}'.format( - record.get('publication_state', 'draft')), - None) - ) + community = Community.get(record['community']) + publication_state = record.get('publication_state', 'draft') + if publication_state != 'draft' or community.restricted_submission: + needs.add(create_deposit_need_factory()) + needs.add(create_deposit_need_factory( + community=record['community'], + publication_state=publication_state, + )) + elif not community.restricted_submission: + needs.add(AuthenticatedNeed) + self.permissions.add(StrictDynamicPermission(*needs)) + def allows(self, *args, **kwargs): # allowed if the data is not loaded yet if self.record is None: @@ -127,13 +172,16 @@ class ReadDepositPermission(DepositPermission): """Deposit read permission.""" def _load_additional_permissions(self): - permission = DynamicPermission() - for owner_id in self.deposit['_deposit']['owners']: - permission.needs.add(UserNeed(owner_id)) - permission.needs.add(ParameterizedActionNeed( - 'read-deposit-{}'.format(self.deposit['publication_state']), - self.deposit['community']) - ) + # owners of the deposit are allowed to read the deposit. + needs = set(UserNeed(owner_id) + for owner_id in self.deposit['_deposit']['owners']) + # add specific permission to read deposits of this community + # in this publication state + needs.add(read_deposit_need_factory( + community=self.deposit['community'], + publication_state=self.deposit['publication_state'], + )) + permission = DynamicPermission(*needs) self.permissions.add(permission) diff --git a/tests/b2share_functional_tests/test_search.py b/tests/b2share_functional_tests/test_search.py index 82ee6e5d39..338ae405f6 100644 --- a/tests/b2share_functional_tests/test_search.py +++ b/tests/b2share_functional_tests/test_search.py @@ -61,6 +61,17 @@ def url_for(*args, **kwargs): draft_create_data = json.loads( draft_create_res.get_data(as_text=True)) + # submit the record + draft_submit_res = client.patch( + url_for('b2share_deposit_rest.b2share_deposit_item', + pid_value=draft_create_data['id']), + data=json.dumps([{ + "op": "replace", "path": "/publication_state", + "value": PublicationStates.submitted.name + }]), + headers=patch_headers) + assert draft_submit_res.status_code == 200 + # publish record draft_publish_res = client.patch( url_for('b2share_deposit_rest.b2share_deposit_item', diff --git a/tests/b2share_unit_tests/communities/helpers.py b/tests/b2share_unit_tests/communities/helpers.py index aaa245b1c2..353a31752a 100644 --- a/tests/b2share_unit_tests/communities/helpers.py +++ b/tests/b2share_unit_tests/communities/helpers.py @@ -34,6 +34,8 @@ 'name': 'newcommunity', 'description': 'A new community', 'logo': 'http://example.com/logo', + 'publication_workflow': 'review_and_publish', + 'restricted_submission': False, } community_patch = [ diff --git a/tests/b2share_unit_tests/communities/test_communities_api.py b/tests/b2share_unit_tests/communities/test_communities_api.py index c7aa92a7d4..ae7d09c299 100644 --- a/tests/b2share_unit_tests/communities/test_communities_api.py +++ b/tests/b2share_unit_tests/communities/test_communities_api.py @@ -164,7 +164,13 @@ def test_clear_update(app): assert updated.updated for field, value in updated_community_metadata.items(): if field not in community_update: - assert getattr(updated, field) is None + if field == 'publication_workflow': + assert updated.publication_workflow == \ + 'review_and_publish' + elif field == 'restricted_submission': + assert updated.restricted_submission == False + else: + assert getattr(updated, field) is None else: assert getattr(updated, field) == value diff --git a/tests/b2share_unit_tests/deposit/test_deposit_api.py b/tests/b2share_unit_tests/deposit/test_deposit_api.py index 71705b3c18..59e808ba3f 100644 --- a/tests/b2share_unit_tests/deposit/test_deposit_api.py +++ b/tests/b2share_unit_tests/deposit/test_deposit_api.py @@ -25,7 +25,7 @@ import pytest from b2share.modules.deposit.api import Deposit, PublicationStates -from jsonschema.exceptions import ValidationError +from b2share.modules.communities.errors import InvalidPublicationStateError def test_deposit_create(app, draft_deposits): @@ -89,5 +89,17 @@ def test_deposit_update_unknown_publication_state(app, draft_deposits): deposit = Deposit.get_record(draft_deposits[0].id) deposit.update({'publication_state': 'invalid_state'}) - with pytest.raises(ValidationError): + with pytest.raises(InvalidPublicationStateError): deposit.commit() + + +# def test_direct_publish_workflow(app, draft_deposits, draft_community): +# """Test deposit submission with "direct_publish" workflow""" +# test_communities + +# with app.app_context(): +# deposit = Deposit.get_record(draft_deposits[0].id) +# deposit.update({'publication_state': +# 'invalid_state'}) +# with pytest.raises(ValidationError): +# deposit.commit() diff --git a/tests/b2share_unit_tests/deposit/test_deposit_rest.py b/tests/b2share_unit_tests/deposit/test_deposit_rest.py index 041ee38f5b..b4046da3cc 100644 --- a/tests/b2share_unit_tests/deposit/test_deposit_rest.py +++ b/tests/b2share_unit_tests/deposit/test_deposit_rest.py @@ -38,6 +38,10 @@ from invenio_access.models import ActionUsers, ActionRoles from b2share.modules.records.providers import RecordUUIDProvider from six import BytesIO +from b2share.modules.deposit.permissions import create_deposit_need_factory, \ + read_deposit_need_factory +from b2share.modules.communities.api import Community +from invenio_db import db def test_deposit_create(app, test_records_data, test_users, login_user): @@ -124,12 +128,12 @@ def test_deposit_submit(app, test_records_data, draft_deposits, test_users, client) -def test_deposit_publish(app, test_records_data, draft_deposits, test_users, +def test_deposit_publish(app, test_records_data, submitted_deposits, test_users, login_user): """Test record draft publication with HTTP PATCH.""" record_data = test_records_data[0] with app.app_context(): - deposit = Deposit.get_record(draft_deposits[0].id) + deposit = Deposit.get_record(submitted_deposits[0].id) with app.test_client() as client: user = test_users['deposits_creator'] login_user(user, client) @@ -155,7 +159,7 @@ def test_deposit_publish(app, test_records_data, draft_deposits, test_users, ) with app.app_context(): - deposit = Deposit.get_record(draft_deposits[0].id) + deposit = Deposit.get_record(submitted_deposits[0].id) with app.test_client() as client: user = test_users['deposits_creator'] login_user(user, client) @@ -259,13 +263,71 @@ def test_deposit_files(app, test_communities, login_user, test_users): ###################### -def test_deposit_read_permissions(app, test_records_data, - login_user, test_users): +def test_deposit_create_permission(app, test_users, login_user, + test_communities): + """Test record draft creation.""" + headers = [('Content-Type', 'application/json'), + ('Accept', 'application/json')] + + with app.app_context(): + community_name = 'MyTestCommunity1' + record_data = generate_record_data(community=community_name) + community_id = test_communities[community_name] + community = Community.get(community_id) + + creator = create_user('creator') + need = create_deposit_need_factory(str(community_id)) + allowed = create_user('allowed', permissions=[need]) + com_member = create_user('com_member', roles=[community.member_role]) + com_admin = create_user('com_admin', roles=[community.admin_role]) + + def restrict_creation(restricted): + community.update({'restricted_submission':restricted}) + db.session.commit() + + def test_creation(expected_code, user=None): + with app.test_client() as client: + if user is not None: + login_user(user, client) + draft_create_res = client.post( + url_for('b2share_records_rest.b2share_record_list'), + data=json.dumps(record_data), + headers=headers + ) + assert draft_create_res.status_code == expected_code + + # test creating a deposit with anonymous user + restrict_creation(False) + test_creation(401) + restrict_creation(True) + test_creation(401) + + # test creating a deposit with a logged in user + restrict_creation(False) + test_creation(201, creator) + restrict_creation(True) + test_creation(403, creator) + # test with a use who is allowed + test_creation(201, allowed) + # test with a community member and admin + test_creation(201, com_member) + test_creation(201, com_admin) + + community.update({'restricted_submission': True}) + +def test_deposit_read_permissions(app, login_user, test_users, + test_communities): """Test deposit read with HTTP GET.""" with app.app_context(): + community_name = 'MyTestCommunity1' + record_data = generate_record_data(community=community_name) + community = Community.get(name=community_name) + admin = test_users['admin'] creator = create_user('creator') non_creator = create_user('non-creator') + com_member = create_user('com_member', roles=[community.member_role]) + com_admin = create_user('com_admin', roles=[community.admin_role]) def test_get(deposit, status, user=None): with app.test_client() as client: @@ -279,34 +341,48 @@ def test_get(deposit, status, user=None): assert request_res.status_code == status # test with anonymous user - deposit = create_deposit(test_records_data[0], creator) + deposit = create_deposit(record_data, creator) test_get(deposit, 401) deposit.submit() test_get(deposit, 401) deposit.publish() test_get(deposit, 401) - deposit = create_deposit(test_records_data[0], creator) + deposit = create_deposit(record_data, creator) test_get(deposit, 403, non_creator) deposit.submit() test_get(deposit, 403, non_creator) deposit.publish() test_get(deposit, 403, non_creator) - deposit = create_deposit(test_records_data[0], creator) + deposit = create_deposit(record_data, creator) test_get(deposit, 200, creator) deposit.submit() test_get(deposit, 200, creator) deposit.publish() test_get(deposit, 200, creator) - deposit = create_deposit(test_records_data[0], creator) + deposit = create_deposit(record_data, creator) test_get(deposit, 200, admin) deposit.submit() test_get(deposit, 200, admin) deposit.publish() test_get(deposit, 200, admin) + deposit = create_deposit(record_data, creator) + test_get(deposit, 403, com_member) + deposit.submit() + test_get(deposit, 403, com_member) + deposit.publish() + test_get(deposit, 403, com_member) + + deposit = create_deposit(record_data, creator) + test_get(deposit, 403, com_admin) + deposit.submit() + test_get(deposit, 200, com_admin) + deposit.publish() + test_get(deposit, 200, com_admin) + def test_deposit_search_permissions(app, draft_deposits, submitted_deposits, test_users, login_user, test_communities): @@ -319,18 +395,29 @@ def test_deposit_search_permissions(app, draft_deposits, submitted_deposits, creator = test_users['deposits_creator'] non_creator = create_user('non-creator') + permission_to_read_all_submitted_deposits = read_deposit_need_factory( + community=str(test_communities['MyTestCommunity2']), + publication_state='submitted', + ) allowed_role = create_role( 'allowed_role', - permissions=[('read-deposit-submitted', None),] + permissions=[ + permission_to_read_all_submitted_deposits + ] ) user_allowed_by_role = create_user('user-allowed-by-role', roles=[allowed_role]) user_allowed_by_permission = create_user( 'user-allowed-by-permission', - permissions=[('read-deposit-submitted', - str(test_communities['MyTestCommunity2']))] + permissions=[ + permission_to_read_all_submitted_deposits + ] ) + community = Community.get(test_communities['MyTestCommunity2']) + com_member = create_user('com_member', roles=[community.member_role]) + com_admin = create_user('com_admin', roles=[community.admin_role]) + search_deposits_url = url_for( 'b2share_records_rest.b2share_record_list', drafts=1, size=100) headers = [('Content-Type', 'application/json'), @@ -367,16 +454,21 @@ def test_search(status, expected_deposits, user=None): test_search(401, [], None) test_search(200, [], non_creator) - test_search(200, submitted_deposits, user_allowed_by_role) # search for submitted records community2_deposits = [dep for dep in submitted_deposits if dep.data['community'] == str(test_communities['MyTestCommunity2'])] + test_search(200, community2_deposits, user_allowed_by_role) test_search(200, community2_deposits, user_allowed_by_permission) + # community admin should have access to all submitted records + # in their community + test_search(200, [], com_member) + test_search(200, community2_deposits, com_admin) + def test_deposit_delete_permissions(app, test_records_data, login_user, test_users): diff --git a/tests/b2share_unit_tests/helpers.py b/tests/b2share_unit_tests/helpers.py index 91d7edeb4a..bf66afef7c 100644 --- a/tests/b2share_unit_tests/helpers.py +++ b/tests/b2share_unit_tests/helpers.py @@ -136,7 +136,8 @@ def test_files_permission(read_status, update_status, delete_status): """Generated user information.""" -def create_user(name, roles=None, permissions=None): +def create_user(name, roles=None, permissions=None, admin_communities=None, + member_communities=None): """Create a user. Args: @@ -164,12 +165,13 @@ def create_user(name, roles=None, permissions=None): for role in roles: security.datastore.add_role_to_user(user, role) - # permissions of the form [('action name', 'argument'), (...)] if permissions is not None: - for action_name, argument in permissions: - db.session.add(ActionUsers.allow( - ParameterizedActionNeed(action_name, argument), user=user, - )) + for permission in permissions: + # permissions of the form [('action name', 'argument'), (...)] + if len(permission) == 2: + permission = ParameterizedActionNeed(permission[0], + permission[1]) + db.session.add(ActionUsers.allow(permission, user=user)) return UserInfo(email=email, password=users_password, id=user.id) @@ -179,10 +181,12 @@ def create_role(name, permissions=None): security = current_app.extensions['security'] role = security.datastore.find_or_create_role(name) if permissions is not None: - for action_name, argument in permissions: - db.session.add(ActionRoles.allow( - ParameterizedActionNeed(action_name, argument), role=role - )) + for permission in permissions: + # permissions of the form [('action name', 'argument'), (...)] + if len(permission) == 2: + permission = ParameterizedActionNeed(permission[0], + permission[1]) + db.session.add(ActionRoles.allow(permission, role=role)) return role diff --git a/tests/conftest.py b/tests/conftest.py index 4fb340fa45..268cc6aa5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,7 +80,7 @@ def app(request, tmpdir): JSONSCHEMAS_HOST='localhost:5000', DEBUG_TB_ENABLED=False, SQLALCHEMY_DATABASE_URI=os.environ.get( - 'SQLALCHEMY_DATABASE_URI', 'sqlite:///test.db'), + 'SQLALCHEMY_DATABASE_URI', 'sqlite://'), LOGIN_DISABLED=False, WTF_CSRF_ENABLED=False, SECRET_KEY="CHANGE_ME", @@ -255,23 +255,26 @@ def test_records_data(app, test_communities): def create_deposits(app, test_records_data, creator): """Create test deposits.""" DepositInfo = namedtuple('DepositInfo', ['id', 'data', 'deposit']) - with app.app_context(): - indexer = RecordIndexer() + indexer = RecordIndexer() - with authenticated_user(creator): - deposits = [Deposit.create(data=data) - for data in deepcopy(test_records_data)] - for deposit in deposits: - indexer.index(deposit) - db.session.commit() - return [DepositInfo(dep.id, dep.dumps(), dep) for dep in deposits] + with authenticated_user(creator): + deposits = [Deposit.create(data=data) + for data in deepcopy(test_records_data)] + for deposit in deposits: + indexer.index(deposit) + deposit.commit() + deposit.commit() + return [DepositInfo(dep.id, dep.dumps(), dep) for dep in deposits] @pytest.fixture(scope='function') def draft_deposits(app, test_records_data, test_users): """Fixture creating deposits in draft state.""" - return create_deposits(app, test_records_data, - test_users['deposits_creator']) + with app.app_context(): + result = create_deposits(app, test_records_data, + test_users['deposits_creator']) + db.session.commit() + return result @pytest.fixture(scope='function') @@ -282,6 +285,7 @@ def submitted_deposits(app, test_records_data, test_users): test_users['deposits_creator']) for dep in deposits: dep.deposit.submit() + db.session.commit() return deposits @@ -296,14 +300,16 @@ def test_records(app, request, test_records_data, test_users): 'record_id', 'data']) indexer = RecordIndexer() def publish(deposit): + deposit.submit() deposit.publish() pid, record = deposit.fetch_published() indexer.index(record) - db.session.commit() return RecordInfo(deposit.id, str(pid.pid_value), record.id, record.dumps()) - return [publish(deposit_info.deposit) - for deposit_info in test_deposits] + result = [publish(deposit_info.deposit) + for deposit_info in test_deposits] + db.session.commit() + return result @pytest.yield_fixture()