diff --git a/oarepo_requests/actions/components.py b/oarepo_requests/actions/components.py index 1df209d5..911617ce 100644 --- a/oarepo_requests/actions/components.py +++ b/oarepo_requests/actions/components.py @@ -39,13 +39,19 @@ class WorkflowTransitionComponent(RequestActionComponent): @contextlib.contextmanager def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): from oarepo_workflows.proxies import current_oarepo_workflows + from sqlalchemy.exc import NoResultFound yield - transitions = ( - current_oarepo_workflows.get_workflow(topic) - .requests()[request_type.type_id] - .transitions - ) + try: + transitions = ( + current_oarepo_workflows.get_workflow(topic) + .requests()[request_type.type_id] + .transitions + ) + except ( + NoResultFound + ): # parent might be deleted - this is the case for delete_draft request type + return target_state = transitions[action.status_to] if ( target_state and not topic.model.is_deleted diff --git a/oarepo_requests/actions/delete_draft.py b/oarepo_requests/actions/delete_draft.py new file mode 100644 index 00000000..57300fbf --- /dev/null +++ b/oarepo_requests/actions/delete_draft.py @@ -0,0 +1,11 @@ +from oarepo_runtime.datastreams.utils import get_record_service_for_record + +from .generic import OARepoAcceptAction + + +class DeleteDraftAcceptAction(OARepoAcceptAction): + def apply(self, identity, request_type, topic, uow, *args, **kwargs): + topic_service = get_record_service_for_record(topic) + if not topic_service: + raise KeyError(f"topic {topic} service not found") + topic_service.delete_draft(identity, topic["id"], uow=uow, *args, **kwargs) diff --git a/oarepo_requests/actions/delete_topic.py b/oarepo_requests/actions/delete_published_record.py similarity index 87% rename from oarepo_requests/actions/delete_topic.py rename to oarepo_requests/actions/delete_published_record.py index 894a5d0d..57dbd7a3 100644 --- a/oarepo_requests/actions/delete_topic.py +++ b/oarepo_requests/actions/delete_published_record.py @@ -3,7 +3,7 @@ from .generic import OARepoAcceptAction -class DeleteTopicAcceptAction(OARepoAcceptAction): +class DeletePublishedRecordAcceptAction(OARepoAcceptAction): def apply(self, identity, request_type, topic, uow, *args, **kwargs): topic_service = get_record_service_for_record(topic) if not topic_service: diff --git a/oarepo_requests/actions/publish_draft.py b/oarepo_requests/actions/publish_draft.py index ecc8ca70..378f4a43 100644 --- a/oarepo_requests/actions/publish_draft.py +++ b/oarepo_requests/actions/publish_draft.py @@ -17,7 +17,7 @@ def can_execute(self): return False try: topic = self.request.topic.resolve() - except: # noqa: used for links, so ignore errors here + except: # noqa: used for links, so ignore errors here return False topic_service = get_record_service_for_record(topic) try: diff --git a/oarepo_requests/services/permissions/generators.py b/oarepo_requests/services/permissions/generators.py index 924ba440..cefe46c0 100644 --- a/oarepo_requests/services/permissions/generators.py +++ b/oarepo_requests/services/permissions/generators.py @@ -1,7 +1,7 @@ from flask_principal import Identity from invenio_records_permissions.generators import ConditionalGenerator, Generator from invenio_records_resources.references.entity_resolvers import EntityProxy -from invenio_requests.proxies import current_requests +from invenio_requests.resolvers.registry import ResolverRegistry from invenio_search.engine import dsl from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_workflows.requests.policy import RecipientGeneratorMixin @@ -30,6 +30,12 @@ def _condition(self, request_type, **kwargs): return request_type.type_id in self.request_types +class IfEventOnRequestType(IfRequestType): + + def _condition(self, request, **kwargs): + return request.type.type_id in self.request_types + + class IfEventType(ConditionalGenerator): def __init__(self, event_types, then_, else_=None): else_ = [] if else_ is None else else_ @@ -142,10 +148,7 @@ def _condition(self, *, request_type, creator, **kwargs): needs = creator.provides else: if not isinstance(creator, EntityProxy): - # convert to entityproxy - creator = current_requests.entity_resolvers_registry.reference_entity( - creator - ) + creator = ResolverRegistry.reference_entity(creator) needs = creator.get_needs() for condition in self.requesters: diff --git a/oarepo_requests/types/__init__.py b/oarepo_requests/types/__init__.py index 86eb7fca..cc7c30f0 100644 --- a/oarepo_requests/types/__init__.py +++ b/oarepo_requests/types/__init__.py @@ -1,4 +1,4 @@ -from .delete_record import DeletePublishedRecordRequestType +from .delete_published_record import DeletePublishedRecordRequestType from .edit_record import EditPublishedRecordRequestType from .generic import NonDuplicableOARepoRequestType from .publish_draft import PublishDraftRequestType diff --git a/oarepo_requests/types/delete_draft.py b/oarepo_requests/types/delete_draft.py new file mode 100644 index 00000000..b8166a5f --- /dev/null +++ b/oarepo_requests/types/delete_draft.py @@ -0,0 +1,62 @@ +from oarepo_runtime.i18n import lazy_gettext as _ +from typing_extensions import override + +from ..actions.delete_draft import DeleteDraftAcceptAction +from ..utils import is_auto_approved, request_identity_matches +from .generic import NonDuplicableOARepoRequestType +from .ref_types import ModelRefTypes + + +class DeleteDraftRequestType(NonDuplicableOARepoRequestType): + type_id = "delete_draft" + name = _("Delete draft") + + dangerous = True + + @classmethod + @property + def available_actions(cls): + return { + **super().available_actions, + "accept": DeleteDraftAcceptAction, + } + + description = _("Request deletion of draft") + receiver_can_be_none = True + allowed_topic_ref_types = ModelRefTypes(published=False, draft=True) + + @override + def stateful_name(self, identity, *, topic=None, request=None): + if is_auto_approved(self, identity=identity, topic=topic): + return self.name + if not request: + return _("Request draft deletion") + match request.status: + case "submitted": + return _("Draft deletion requested") + case _: + return _("Request draft deletion") + + @override + def stateful_description(self, identity, *, topic=None, request=None): + if is_auto_approved(self, identity=identity, topic=topic): + return _("Click to permanently delete the draft.") + + if not request: + return _("Request permission to delete the draft.") + match request.status: + case "submitted": + if request_identity_matches(request.created_by, identity): + return _( + "Permission to delete draft requested. " + "You will be notified about the decision by email." + ) + if request_identity_matches(request.receiver, identity): + return _( + "You have been asked to approve the request to permanently delete the draft. " + "You can approve or reject the request." + ) + return _("Permission to delete draft (including files) requested. ") + case _: + if request_identity_matches(request.created_by, identity): + return _("Submit request to get permission to delete the draft.") diff --git a/oarepo_requests/types/delete_record.py b/oarepo_requests/types/delete_published_record.py similarity index 93% rename from oarepo_requests/types/delete_record.py rename to oarepo_requests/types/delete_published_record.py index 33cede0b..c5ee6a60 100644 --- a/oarepo_requests/types/delete_record.py +++ b/oarepo_requests/types/delete_published_record.py @@ -1,7 +1,9 @@ from oarepo_runtime.i18n import lazy_gettext as _ from typing_extensions import override -from oarepo_requests.actions.delete_topic import DeleteTopicAcceptAction +from oarepo_requests.actions.delete_published_record import ( + DeletePublishedRecordAcceptAction, +) from ..utils import is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType @@ -19,7 +21,7 @@ class DeletePublishedRecordRequestType(NonDuplicableOARepoRequestType): def available_actions(cls): return { **super().available_actions, - "accept": DeleteTopicAcceptAction, + "accept": DeletePublishedRecordAcceptAction, } description = _("Request deletion of published record") diff --git a/oarepo_requests/utils.py b/oarepo_requests/utils.py index 4ff499ab..0b3ee1b9 100644 --- a/oarepo_requests/utils.py +++ b/oarepo_requests/utils.py @@ -161,8 +161,9 @@ def get_receiver_for_request_type(request_type, identity, topic): except KeyError: return None + receivers = workflow_request.reference_receivers( - identity=identity, topic=topic, request_type=request_type + identity=identity, record=topic, request_type=request_type, creator=identity ) if not receivers: return None diff --git a/setup.cfg b/setup.cfg index cdf60eb6..04d694df 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,7 +47,8 @@ invenio_assets.webpack = invenio_i18n.translations = oarepo_requests_ui = oarepo_requests invenio_requests.types = - delete_published_record = oarepo_requests.types.delete_record:DeletePublishedRecordRequestType + delete_published_record = oarepo_requests.types.delete_published_record:DeletePublishedRecordRequestType + delete_draft = oarepo_requests.types.delete_draft:DeleteDraftRequestType edit_published_record = oarepo_requests.types.edit_record:EditPublishedRecordRequestType publish_draft = oarepo_requests.types.publish_draft:PublishDraftRequestType new_version = oarepo_requests.types.new_version:NewVersionRequestType diff --git a/tests/conftest.py b/tests/conftest.py index e715a892..50eb6748 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,6 +100,11 @@ class DefaultRequests(WorkflowRequestPolicy): submitted="deleting", accepted="deleted", declined="published" ), ) + delete_draft = WorkflowRequest( + requesters=[IfInState("draft", [RecordOwners()])], + recipients=[AutoApprove()], + transitions=WorkflowTransitions(), + ) edit_published_record = WorkflowRequest( requesters=[IfNoEditDraft([IfInState("published", [RecordOwners()])])], recipients=[AutoApprove()], @@ -346,6 +351,17 @@ def ret_data(record_id): return ret_data +@pytest.fixture() +def delete_draft_function(): + def ret_data(record_id): + return { + "request_type": "delete_draft", + "topic": {"thesis_draft": record_id}, + } + + return ret_data + + @pytest.fixture() def serialization_result(): def _result(topic_id, request_id): @@ -660,3 +676,31 @@ def role_ui_serialization(): @pytest.fixture() def default_workflow_json(): return {"parent": {"workflow": "default"}, "metadata": {"title": "blabla"}} + + +@pytest.fixture() +def get_request_type(): + """ + gets request create link from serialized request types + """ + + def _get_request_type(request_types_json, request_type): + selected_entry = [ + entry for entry in request_types_json if entry["type_id"] == request_type + ][0] + return selected_entry + + return _get_request_type + + +@pytest.fixture() +def get_request_link(get_request_type): + """ + gets request create link from serialized request types + """ + + def _create_request_from_link(request_types_json, request_type): + selected_entry = get_request_type(request_types_json, request_type) + return selected_entry["links"]["actions"]["create"] + + return _create_request_from_link diff --git a/tests/test_requests/test_allowed_request_types_link_and_service.py b/tests/test_requests/test_allowed_request_types_link_and_service.py index 1eb7b860..2d9b6a14 100644 --- a/tests/test_requests/test_allowed_request_types_link_and_service.py +++ b/tests/test_requests/test_allowed_request_types_link_and_service.py @@ -28,24 +28,26 @@ def test_allowed_request_types_on_draft_service( creator.identity, draft1.json["id"] ) ) - assert allowed_request_types.to_dict() == { - "hits": { - "hits": [ - { - "links": { - "actions": { - "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/publish_draft' - } - }, - "type_id": "publish_draft", + assert sorted( + allowed_request_types.to_dict()["hits"]["hits"], key=lambda x: x["type_id"] + ) == [ + { + "links": { + "actions": { + "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/delete_draft' } - ], - "total": 1, + }, + "type_id": "delete_draft", }, - "links": { - "self": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/applicable' + { + "links": { + "actions": { + "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/publish_draft' + } + }, + "type_id": "publish_draft", }, - } + ] def test_allowed_request_types_on_draft_resource( @@ -72,24 +74,26 @@ def test_allowed_request_types_on_draft_resource( allowed_request_types = creator_client.get( link_api2testclient(applicable_requests_link) ) - assert allowed_request_types.json == { - "hits": { - "hits": [ - { - "links": { - "actions": { - "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/publish_draft' - } - }, - "type_id": "publish_draft", + assert sorted( + allowed_request_types.json["hits"]["hits"], key=lambda x: x["type_id"] + ) == [ + { + "links": { + "actions": { + "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/delete_draft' } - ], - "total": 1, + }, + "type_id": "delete_draft", }, - "links": { - "self": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/applicable' + { + "links": { + "actions": { + "create": f'https://127.0.0.1:5000/api/thesis/{draft1.json["id"]}/draft/requests/publish_draft' + } + }, + "type_id": "publish_draft", }, - } + ] def publish_record( @@ -223,7 +227,25 @@ def test_ui_serialization( headers={"Accept": "application/vnd.inveniordm.v1+json"}, ) - assert allowed_request_types_draft.json["hits"]["hits"] == [ + sorted_draft_list = allowed_request_types_draft.json["hits"]["hits"] + sorted_draft_list.sort(key=lambda serialized_rt: serialized_rt["type_id"]) + + assert sorted_draft_list == [ + { + "dangerous": True, + "description": "Request deletion of draft", + "editable": True, + "has_form": False, + "links": { + "actions": { + "create": f"https://127.0.0.1:5000/api/thesis/{draft_id}/draft/requests/delete_draft" + } + }, + "name": "Delete draft", + "stateful_description": "Click to permanently delete the draft.", + "stateful_name": "Delete draft", + "type_id": "delete_draft", + }, { "description": "Request publishing of a draft", "links": { @@ -242,7 +264,7 @@ def test_ui_serialization( "possible until the request is accepted or declined. " "You will be notified about the decision by email.", "stateful_name": "Submit for review", - } + }, ] sorted_published_list = allowed_request_types_published.json["hits"]["hits"] sorted_published_list.sort(key=lambda serialized_rt: serialized_rt["type_id"]) diff --git a/tests/test_requests/test_delete.py b/tests/test_requests/test_delete.py index bdf8edc4..6255af91 100644 --- a/tests/test_requests/test_delete.py +++ b/tests/test_requests/test_delete.py @@ -88,3 +88,39 @@ def test_delete( f"{urls['BASE_URL_REQUESTS']}{resp_request_create.json['id']}" ) assert canceled_request.json["status"] == "cancelled" + + +def test_delete_draft( + vocab_cf, + logged_client, + create_draft_via_resource, + users, + urls, + delete_draft_function, + get_request_link, + search_clear, +): + creator_client = logged_client(users[0]) + + draft1 = create_draft_via_resource(creator_client) + draft_id = draft1.json["id"] + + read = creator_client.get(f"{urls['BASE_URL']}{draft_id}/draft?expand=true") + assert read.status_code == 200 + + resp_request_create = creator_client.post( + link_api2testclient( + get_request_link(read.json["expanded"]["request_types"], "delete_draft") + ) + ) + resp_request_submit = creator_client.post( + link_api2testclient(resp_request_create.json["links"]["actions"]["submit"]), + ) + + read_deleted = creator_client.get(f"{urls['BASE_URL']}{draft_id}/draft?expand=true") + request_after = creator_client.get( + f"{urls['BASE_URL_REQUESTS']}{resp_request_create.json['id']}" + ) # autoapprove suggested here + assert request_after.json["status"] == "accepted" + assert request_after.json["is_closed"] + assert read_deleted.status_code == 404