From 4e1e6c7d020f63e58bd868423c9f381e4d8529e8 Mon Sep 17 00:00:00 2001 From: anikachurilova Date: Tue, 12 Sep 2023 16:06:24 +0200 Subject: [PATCH 1/2] requests: implement a new community manage record request * closes https://github.com/zenodo/rdm-project/issues/243 --- site/setup.cfg | 1 + site/zenodo_rdm/legacy/requests/__init__.py | 3 +- .../requests/community_manage_record.py | 211 ++++++++++++++++++ site/zenodo_rdm/legacy/requests/utils.py | 24 ++ .../user_dashboard.html | 41 ++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 site/zenodo_rdm/legacy/requests/community_manage_record.py create mode 100644 templates/semantic-ui/invenio_requests/community-manage-record/user_dashboard.html diff --git a/site/setup.cfg b/site/setup.cfg index d544ff169..4ef350aac 100644 --- a/site/setup.cfg +++ b/site/setup.cfg @@ -52,6 +52,7 @@ invenio_config.module = zenodo_rdm = zenodo_rdm.config invenio_requests.types = legacy_record_upgrade = zenodo_rdm.legacy.requests:LegacyRecordUpgrade + community_manage_record = zenodo_rdm.legacy.requests:CommunityManageRecord invenio_access.actions = media_files_management_action = zenodo_rdm.generators:media_files_management_action diff --git a/site/zenodo_rdm/legacy/requests/__init__.py b/site/zenodo_rdm/legacy/requests/__init__.py index 1d1fec7ea..68c51e152 100644 --- a/site/zenodo_rdm/legacy/requests/__init__.py +++ b/site/zenodo_rdm/legacy/requests/__init__.py @@ -8,6 +8,7 @@ """Request types for ZenodoRDM.""" +from .community_manage_record import CommunityManageRecord from .record_upgrade import LegacyRecordUpgrade -__all__ = ("LegacyRecordUpgrade",) +__all__ = ("LegacyRecordUpgrade", "CommunityManageRecord") diff --git a/site/zenodo_rdm/legacy/requests/community_manage_record.py b/site/zenodo_rdm/legacy/requests/community_manage_record.py new file mode 100644 index 000000000..81f452139 --- /dev/null +++ b/site/zenodo_rdm/legacy/requests/community_manage_record.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023 CERN. +# +# Zenodo is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Zenodo community manage record request.""" + +from flask_login import current_user +from invenio_access.permissions import system_identity +from invenio_communities.proxies import current_communities +from invenio_pidstore.models import PersistentIdentifier +from invenio_rdm_records.proxies import ( + current_rdm_records_service, + current_record_communities_service, +) +from invenio_rdm_records.records import RDMParent, RDMRecord +from invenio_records_resources.services.uow import IndexRefreshOp, RecordCommitOp +from invenio_requests.customizations import CommentEventType, RequestType, actions +from invenio_requests.proxies import current_events_service, current_requests_service +from invenio_search.engine import dsl + + +def _remove_permission_flag(record, uow): + """Remove can_community_manage_record permission flag.""" + try: + record.parent.permission_flags.pop("can_community_manage_record") + record.parent.commit() + uow.register(RecordCommitOp(record.parent)) + except (KeyError, AttributeError): + # if field or flag is absent just continue with an action + pass + + +def _remove_record_from_communities(record, communities_ids): + """Remove record from communities.""" + if communities_ids: + communities = [] + for community_id in communities_ids: + communities.append({"id": community_id}) + + data = dict(communities=communities) + current_record_communities_service.remove(system_identity, record["id"], data) + + +def _create_comment(request, content, uow): + """Create a comment event.""" + comment = {"payload": {"content": content}} + + current_events_service.create( + system_identity, request.id, comment, CommentEventType, uow=uow + ) + + # make event immediately available in search + uow.register(IndexRefreshOp(indexer=current_events_service.indexer)) + + +def _get_legacy_records_by_user(): + """Find legacy records of a specific user.""" + return current_rdm_records_service.search( + system_identity, + extra_filter=dsl.query.Bool( + "must", + must=[ + dsl.Q("terms", **{"parent.access.owned_by.user": [current_user.id]}), + # indicator that record is a legacy one + dsl.Q( + "exists", + field="parent.permission_flags.can_community_manage_record", + ), + ], + ), + ) + + +def _resolve_record(legacy_record): + """Get record byt its pid.""" + record_pid = PersistentIdentifier.query.filter( + PersistentIdentifier.pid_value == legacy_record["id"] + ).one() + record_id = str(record_pid.object_uuid) + return RDMRecord.get_record(record_id) + + +# +# Actions +# +class SubmitAction(actions.SubmitAction): + """Submit action.""" + + def execute(self, identity, uow): + """Execute the submit action.""" + self.request["title"] = "Communities manage legacy records" + + # example: "May 11, 2024" + expires_at = self.request.expires_at.strftime("%B %d, %Y") + self.request["description"] = ( + "

Some of your records, that are going through migration process are part " + "of the communities that don't belong to you.
Accept this request to keep the old " + "behaviour and allow community curators to manage (edit, create new version, add to " + "another community, etc.) corresponding record.
In case of declining this " + "request all your legacy records will be removed from all communities " + "that you are not an owner of.

If you do not perform any action by " + f"{expires_at}, the permission for community curators to manage the record " + "will automatically be fully granted.

" + ) + + super().execute(identity, uow) + + +class AcceptAction(actions.AcceptAction): + """Accept action.""" + + def execute(self, identity, uow): + """Grant permission to manage all legacy records of a user to all the communities.""" + legacy_records = _get_legacy_records_by_user() + + for hit in legacy_records.hits: + # remove flag from record parent, permissions logic will do the rest + record = _resolve_record(hit) + _remove_permission_flag(record, uow) + + comment = ( + "You accepted the request. The community curators " + "can now manage all of your legacy records." + ) + _create_comment(self.request, comment, uow) + + super().execute(identity, uow) + + +class DeclineAction(actions.DeclineAction): + """Decline action.""" + + def execute(self, identity, uow): + """Deny access to manage legacy records for community curators.""" + legacy_records = _get_legacy_records_by_user() + + for legacy_record in legacy_records.hits: + record = _resolve_record(legacy_record) + communities = ( + legacy_record["parent"].get("communities", None).get("ids", None) + ) + if communities is not None: + not_my_communities = [] + for community_id in communities: + com_members = current_communities.service.members.search( + system_identity, + community_id, + extra_filter=dsl.query.Bool( + "must", + must=[ + dsl.Q("term", **{"role": "owner"}), + ~dsl.Q("term", **{"user_id": current_user.id}), + ], + ), + ) + if com_members.total > 0: + not_my_communities.append(community_id) + _remove_record_from_communities(record, not_my_communities) + _remove_permission_flag(record, uow) + + super().execute(identity, uow) + + +class ExpireAction(actions.ExpireAction): + """Expire action.""" + + def execute(self, identity, uow): + """Grant permission to manage all legacy records of a user to all the communities.""" + legacy_records = _get_legacy_records_by_user() + + for hit in legacy_records.hits: + # remove flag from record parent, permissions logic will do the rest + record = _resolve_record(hit) + _remove_permission_flag(record, uow) + + comment = ( + "The request was expired. The community curators " + "can now manage all of your legacy records." + ) + _create_comment(self.request, comment, uow) + + super().execute(identity, uow) + + +# +# Request +# +class CommunityManageRecord(RequestType): + """Request for granting permissions to manage a record for its community curators.""" + + type_id = "community-manage-record" + name = "Community manage record" + + available_actions = { + "create": actions.CreateAction, + "submit": SubmitAction, + "delete": actions.DeleteAction, + "accept": AcceptAction, + "cancel": actions.CancelAction, + "decline": DeclineAction, + "expire": ExpireAction, + } + + creator_can_be_none = False + topic_can_be_none = True + + allowed_creator_ref_types = ["user"] + allowed_receiver_ref_types = ["user"] diff --git a/site/zenodo_rdm/legacy/requests/utils.py b/site/zenodo_rdm/legacy/requests/utils.py index 137358e5c..27d975431 100644 --- a/site/zenodo_rdm/legacy/requests/utils.py +++ b/site/zenodo_rdm/legacy/requests/utils.py @@ -15,6 +15,7 @@ from invenio_requests.resolvers.registry import ResolverRegistry from invenio_search.engine import dsl +from .community_manage_record import CommunityManageRecord from .record_upgrade import LegacyRecordUpgrade @@ -82,3 +83,26 @@ def submit_record_upgrade_request(record, uow, comment=None): return current_requests_service.execute_action( system_identity, request_item._request.id, "submit", data=comment, uow=uow ) + + +@unit_of_work() +def submit_community_manage_record_request(user_id, uow, comment=None): + """Create and submit a CommunityManageRecord request.""" + type_ = current_request_type_registry.lookup(CommunityManageRecord.type_id) + receiver = ResolverRegistry.resolve_entity_proxy({"user": user_id}).resolve() + expires_at = datetime.utcnow() + timedelta(weeks=20) + + # create a request + request_item = current_requests_service.create( + system_identity, + {}, + type_, + receiver, + expires_at=expires_at, + uow=uow, + ) + + # submit the request + return current_requests_service.execute_action( + system_identity, request_item._request.id, "submit", data=comment, uow=uow + ) diff --git a/templates/semantic-ui/invenio_requests/community-manage-record/user_dashboard.html b/templates/semantic-ui/invenio_requests/community-manage-record/user_dashboard.html new file mode 100644 index 000000000..b0164ac6c --- /dev/null +++ b/templates/semantic-ui/invenio_requests/community-manage-record/user_dashboard.html @@ -0,0 +1,41 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} + +{# + Renders the Community manage record request details page. +#} + +{% extends "invenio_requests/details/index.html" %} + +{% set active_dashboard_menu_item = 'requests' %} + +{%- block request_header %} + {% set back_button_url = url_for("invenio_app_rdm_users.requests") %} + {% from "invenio_requests/macros/request_header.html" import inclusion_request_header %} + {{ inclusion_request_header( + request=invenio_request, + accepted=request_is_accepted, + back_button_url=back_button_url, + back_button_text=_("Back to requests") + ) }} +{%- endblock request_header %} + +{% block request_timeline %} +
+
+ {{ super() }} +
+
+{% endblock request_timeline %} From c06902b3d938a6ba6ffb0231a229e2464515b3ee Mon Sep 17 00:00:00 2001 From: anikachurilova Date: Thu, 14 Sep 2023 10:33:23 +0200 Subject: [PATCH 2/2] requests: cover community manage record request with tests --- site/tests/conftest.py | 5 +- .../test_community_manage_record_request.py | 409 ++++++++++++++++++ .../requests/community_manage_record.py | 10 +- 3 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 site/tests/requests/test_community_manage_record_request.py diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 848ac7c25..bc730d2aa 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -18,6 +18,7 @@ from invenio_app import factory as app_factory from invenio_communities import current_communities from invenio_communities.communities.records.api import Community +from invenio_communities.generators import CommunityRoleNeed from invenio_pidstore.errors import PIDDoesNotExistError from invenio_rdm_records.cli import create_records_custom_field from invenio_rdm_records.services.pids import providers @@ -809,10 +810,10 @@ def community2(running_app, community_type_record, community_owner, minimal_comm @pytest.fixture() def community_with_uploader_owner( - running_app, community_type_record, uploader, minimal_community + running_app, community_type_record, uploader, minimal_community2 ): """Create a community with an uploader owner.""" - return _community_get_or_create(minimal_community, uploader.identity) + return _community_get_or_create(minimal_community2, uploader.identity) @pytest.fixture(scope="module") diff --git a/site/tests/requests/test_community_manage_record_request.py b/site/tests/requests/test_community_manage_record_request.py new file mode 100644 index 000000000..83a44e393 --- /dev/null +++ b/site/tests/requests/test_community_manage_record_request.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023 CERN. +# +# Zenodo is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Test Zenodo community manage record request.""" +import arrow +import pytest +from invenio_access.permissions import system_identity +from invenio_communities.generators import CommunityRoleNeed +from invenio_rdm_records.proxies import current_rdm_records +from invenio_rdm_records.records import RDMParent, RDMRecord +from invenio_records_resources.services.errors import PermissionDeniedError +from invenio_requests import current_requests_service + +from zenodo_rdm.legacy.requests.utils import submit_community_manage_record_request + + +@pytest.fixture() +def service(running_app): + """The record service.""" + return current_rdm_records.records_service + + +def _send_post_action(action, request_id, client, headers, expected_status_code): + """Send http-post request to perform an action on a request""" + response = client.post( + f"/requests/{request_id}/actions/{action}", + headers=headers, + json={}, + ) + + assert response.status_code == expected_status_code + return response + + +def _create_legacy_record(identity, record, permission_flags, db, service): + """Create and publish a legacy record""" + record["files"]["enabled"] = False + draft = service.create(identity, record) + record = service.publish(identity, draft.id) + + # modify record in the db + db_record = RDMRecord.get_record(record._record.id) + db_record.parent.permission_flags = permission_flags + db_record.parent.commit() + db.session.commit() + + service.indexer.index(db_record) + db_record.index.refresh() + + return db_record + + +def _add_to_community(record, community, service, db): + """Add a record to a community.""" + record.parent.communities.add(community._record, default=False) + record.parent.commit() + record.commit() + db.session.commit() + service.indexer.index(record, arguments={"refresh": True}) + return record + + +def _add_embargo(record, db): + """Add embargo. Only for permission check purpose.""" + record.access.embargo.until = arrow.utcnow().shift(days=-10).datetime + record.access.embargo.active = True + record.commit() + db.session.commit() + + +def test_submit_a_request(uploader): + """Tests creation and submission of a community manage record request.""" + request_item = submit_community_manage_record_request(uploader.id) + db_request = current_requests_service.read( + system_identity, request_item._request.id + ) + + assert db_request["status"] == "submitted" + assert db_request["type"] == "community-manage-record" + assert db_request["receiver"] == {"user": "1"} + assert db_request["topic"] is None + assert db_request["created_by"] == {"user": "system"} + assert db_request["title"] == "Communities manage legacy records" + assert db_request["expires_at"] is not None + assert db_request["description"].startswith( + "

Some of your records, that are going through migration" + ) + + +def test_decline_a_request_no_communities( + client, + minimal_record, + headers, + uploader, + db, + service, +): + """Tests case when record doesn't belong to any community.""" + record_owner = uploader.login(client) + + # publish a legacy record + legacy_record = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # decline a request + response = _send_post_action("decline", request.id, record_owner, headers, 200) + assert "declined" == response.json["status"] + assert response.json["is_closed"] is True + + # check that the flag is removed from the record + res_record = RDMRecord.get_record(legacy_record.id) + assert res_record.parent.permission_flags == {} + + +def test_decline_a_request_no_legacy_records( + client, + minimal_record, + headers, + uploader, + db, + service, +): + """Tests that if user doesn't have legacy records, the request will be declined.""" + record_owner = uploader.login(client) + + # publish a non-legacy record + _ = _create_legacy_record(uploader.identity, minimal_record, {}, db, service) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # decline a request + response = _send_post_action("decline", request.id, record_owner, headers, 200) + assert "declined" == response.json["status"] + assert response.json["is_closed"] is True + + +def test_accept_a_request_no_legacy_records( + client, + minimal_record, + headers, + uploader, + db, + service, +): + """Tests that if user doesn't have legacy records, the request will be accepted.""" + record_owner = uploader.login(client) + + # publish a non-legacy record + _ = _create_legacy_record(uploader.identity, minimal_record, {}, db, service) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # accept a request + response = _send_post_action("accept", request.id, record_owner, headers, 200) + assert "accepted" == response.json["status"] + assert response.json["is_closed"] is True + + +def test_accept_a_request( + client, + minimal_record, + headers, + uploader, + test_user, + service, + db, + community, + community_owner, +): + """Tests accept a community manage record request for different users.""" + record_owner = uploader.login(client) + + # create 2 legacy records (with permission flag) + legacy_record1 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + legacy_record2 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + + community_owner.identity.provides.add(CommunityRoleNeed(community.id, "owner")) + _add_to_community(legacy_record1, community, service, db) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # accept a request + response = _send_post_action("accept", request.id, record_owner, headers, 200) + assert "accepted" == response.json["status"] + assert response.json["is_closed"] is True + + # check that the flag is removed from both records + res_record1 = RDMRecord.get_record(legacy_record1.id) + assert res_record1.parent.permission_flags == {} + + res_record2 = RDMRecord.get_record(legacy_record2.id) + assert res_record2.parent.permission_flags == {} + + # check that comment was added + response = record_owner.get(f"/requests/{request.id}/timeline", headers=headers) + assert ( + response.json["hits"]["hits"][0]["payload"]["content"] + == "You accepted the request. The community curators " + "can now manage all of your legacy records." + ) + + # check permissions + # tested on the lift embargo action, as editing a record is a bigger headache, while the + # same permissions affects them + recid = legacy_record1["id"] + + # record owner can lift embargo + _add_embargo(res_record1, db) + service.lift_embargo(_id=recid, identity=uploader.identity) + + # community owner can lift embargo + _add_embargo(res_record1, db) + service.lift_embargo(_id=recid, identity=community_owner.identity) + + # random user can't lift embargo + _add_embargo(res_record1, db) + with pytest.raises(PermissionDeniedError) as e: + service.lift_embargo(_id=recid, identity=test_user.identity) + + +def test_decline_a_request( + client, + minimal_record, + headers, + uploader, + test_user, + service, + db, + community, + community_owner, + community_with_uploader_owner, +): + """Tests decline a request for different users (with legacy records and 1 my community 1 not mine).""" + record_owner = uploader.login(client) + + # create 2 legacy records (with permission flag) + legacy_record1 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + legacy_record2 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + + # add both records to someone else's community + community_owner.identity.provides.add(CommunityRoleNeed(community.id, "owner")) + _add_to_community(legacy_record1, community, service, db) + _add_to_community(legacy_record2, community, service, db) + + # add both records to my community + my_community_id = community_with_uploader_owner.id + uploader.identity.provides.add(CommunityRoleNeed(my_community_id, "owner")) + _add_to_community(legacy_record1, community_with_uploader_owner, service, db) + _add_to_community(legacy_record2, community_with_uploader_owner, service, db) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # decline a request + response = _send_post_action("decline", request.id, record_owner, headers, 200) + assert "declined" == response.json["status"] + assert response.json["is_closed"] is True + + # check that the flag is removed from both records + res_record1 = RDMRecord.get_record(legacy_record1.id) + assert res_record1.parent.permission_flags == {} + + res_record2 = RDMRecord.get_record(legacy_record2.id) + assert res_record2.parent.permission_flags == {} + + # check that records only have my communities left + assert res_record1.parent["communities"] == {"ids": [my_community_id]} + assert res_record2.parent["communities"] == {"ids": [my_community_id]} + + # check permissions + # tested on the lift embargo action, as editing a record is a bigger headache, while the + # same permissions affects them + recid = legacy_record1["id"] + + # record owner can lift embargo + _add_embargo(res_record1, db) + service.lift_embargo(_id=recid, identity=uploader.identity) + + # community owner can't lift embargo + _add_embargo(res_record1, db) + with pytest.raises(PermissionDeniedError) as e: + service.lift_embargo(_id=recid, identity=community_owner.identity) + + # random user can't lift embargo + _add_embargo(res_record1, db) + with pytest.raises(PermissionDeniedError) as e: + service.lift_embargo(_id=recid, identity=test_user.identity) + + +def test_request_expire( + client, + minimal_record, + headers, + uploader, + test_user, + community, + community_owner, + service, + db, +): + """Tests expire a request for different users.""" + record_owner = uploader.login(client) + + # create 2 legacy records (with permission flag) + legacy_record1 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + legacy_record2 = _create_legacy_record( + uploader.identity, + minimal_record, + {"can_community_manage_record": True}, + db, + service, + ) + + community_owner.identity.provides.add(CommunityRoleNeed(community.id, "owner")) + _add_to_community(legacy_record1, community, service, db) + + # create and submit a request + request_item = submit_community_manage_record_request(uploader.id) + request = request_item._request + + # expire a request + result = current_requests_service.execute_action( + system_identity, request.id, "expire" + ) + assert result["status"] == "expired" + assert result["is_closed"] is True + + # check that the flag is removed from both records + res_record1 = RDMRecord.get_record(legacy_record1.id) + assert res_record1.parent.permission_flags == {} + + res_record2 = RDMRecord.get_record(legacy_record2.id) + assert res_record2.parent.permission_flags == {} + + # check that comment was added + response = record_owner.get(f"/requests/{request.id}/timeline", headers=headers) + assert ( + response.json["hits"]["hits"][0]["payload"]["content"] + == "The request was expired. The community curators " + "can now manage all of your legacy records." + ) + + # check permissions + # tested on the lift embargo action, as editing a record is a bigger headache, while the + # same permissions affects them + recid = legacy_record1["id"] + + # record owner can lift embargo + _add_embargo(res_record1, db) + service.lift_embargo(_id=recid, identity=uploader.identity) + + # community owner can lift embargo + _add_embargo(res_record1, db) + service.lift_embargo(_id=recid, identity=community_owner.identity) + + # random user can't lift embargo + _add_embargo(res_record1, db) + with pytest.raises(PermissionDeniedError) as e: + service.lift_embargo(_id=recid, identity=test_user.identity) diff --git a/site/zenodo_rdm/legacy/requests/community_manage_record.py b/site/zenodo_rdm/legacy/requests/community_manage_record.py index 81f452139..a1cacce3f 100644 --- a/site/zenodo_rdm/legacy/requests/community_manage_record.py +++ b/site/zenodo_rdm/legacy/requests/community_manage_record.py @@ -143,7 +143,7 @@ def execute(self, identity, uow): legacy_record["parent"].get("communities", None).get("ids", None) ) if communities is not None: - not_my_communities = [] + my_communities = [] for community_id in communities: com_members = current_communities.service.members.search( system_identity, @@ -151,13 +151,15 @@ def execute(self, identity, uow): extra_filter=dsl.query.Bool( "must", must=[ - dsl.Q("term", **{"role": "owner"}), - ~dsl.Q("term", **{"user_id": current_user.id}), + dsl.Q("term", **{"role": "owner"}) + | dsl.Q("term", **{"role": "curator"}), + dsl.Q("term", **{"user_id": current_user.id}), ], ), ) if com_members.total > 0: - not_my_communities.append(community_id) + my_communities.append(community_id) + not_my_communities = list(set(communities) - set(my_communities)) _remove_record_from_communities(record, not_my_communities) _remove_permission_flag(record, uow)