diff --git a/.copyright.tmpl b/.copyright.tmpl new file mode 100644 index 00000000..7c6a848d --- /dev/null +++ b/.copyright.tmpl @@ -0,0 +1,5 @@ +Copyright (C) 2024 CESNET z.s.p.o. + +oarepo-requests is free software; you can redistribute it and/or +modify it under the terms of the MIT License; see LICENSE file for more +details. diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..ba18d3ca --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[report] +exclude_lines = + pragma: no cover + ^# +exclude_also = + if TYPE_CHECKING: + if __name__ == .__main__.: + if TYPE_CHECKING: + class .*\bProtocol\): + @(abc\.)?abstractmethod + raise AssertionError + raise NotImplementedError \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 678c69a7..695d455e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Cache pip uses: actions/cache@v4 with: diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..336aa089 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +disable_error_code = import-untyped, import-not-found diff --git a/oarepo_requests/__init__.py b/oarepo_requests/__init__.py index e69de29b..2a86e54f 100644 --- a/oarepo_requests/__init__.py +++ b/oarepo_requests/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Invenio extension for better handling of requests.""" diff --git a/oarepo_requests/actions/__init__.py b/oarepo_requests/actions/__init__.py index e69de29b..200b2519 100644 --- a/oarepo_requests/actions/__init__.py +++ b/oarepo_requests/actions/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request actions.""" diff --git a/oarepo_requests/actions/cascade_events.py b/oarepo_requests/actions/cascade_events.py index f5d234f0..88924e96 100644 --- a/oarepo_requests/actions/cascade_events.py +++ b/oarepo_requests/actions/cascade_events.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Helper functions for cascading request update on topic change or delete.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_access.permissions import system_identity from invenio_requests import ( current_events_service, @@ -7,23 +20,35 @@ from invenio_requests.records import Request from invenio_requests.resolvers.registry import ResolverRegistry -from oarepo_requests.utils import _reference_query_term +from oarepo_requests.utils import create_query_term_for_reference + +if TYPE_CHECKING: + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import EventType + + from oarepo_requests.typing import EntityReference -def _str_from_ref(ref): - k, v = list(ref.items())[0] +def _str_from_ref(ref: EntityReference) -> str: + k, v = next(iter(ref.items())) return f"{k}.{v}" -def _get_topic_ref_with_requests(topic): - topic_ref = ResolverRegistry.reference_entity(topic) +def _get_topic_reference(topic: Any) -> EntityReference: + return ResolverRegistry.reference_entity(topic) + + +def _get_requests_with_topic_reference(topic_ref: EntityReference) -> list[dict]: requests_with_topic = current_requests_service.scan( - system_identity, extra_filter=_reference_query_term("topic", topic_ref) + system_identity, + extra_filter=create_query_term_for_reference("topic", topic_ref), ) - return requests_with_topic, topic_ref + return requests_with_topic -def _create_event(cur_request, payload, event_type, uow): +def _create_event( + cur_request: Request, payload: dict, event_type: type[EventType], uow: UnitOfWork +) -> None: data = {"payload": payload} current_events_service.create( system_identity, @@ -34,10 +59,20 @@ def _create_event(cur_request, payload, event_type, uow): ) -def update_topic(request, old_topic, new_topic, uow): +def update_topic( + request: Request, old_topic: Any, new_topic: Any, uow: UnitOfWork +) -> None: + """Update topic on all requests with the old topic to the new topic. + + :param request: Request on which the action is being executed, might be handled differently than the rest of the requests with the same topic + :param old_topic: Old topic + :param new_topic: New topic + :param uow: Unit of work + """ from oarepo_requests.types.events import TopicUpdateEventType - requests_with_topic, old_topic_ref = _get_topic_ref_with_requests(old_topic) + old_topic_ref = _get_topic_reference(old_topic) + requests_with_topic = _get_requests_with_topic_reference(old_topic_ref) new_topic_ref = ResolverRegistry.reference_entity(new_topic) for request_from_search in requests_with_topic: request_type = current_request_type_registry.lookup( @@ -60,10 +95,14 @@ def update_topic(request, old_topic, new_topic, uow): _create_event(cur_request, payload, TopicUpdateEventType, uow) -def cancel_requests_on_topic_delete(request, topic, uow): +def cancel_requests_on_topic_delete( + request: Request, topic: Any, uow: UnitOfWork +) -> None: + """Cancel all requests with the topic that is being deleted.""" from oarepo_requests.types.events import TopicDeleteEventType - requests_with_topic, topic_ref = _get_topic_ref_with_requests(topic) + topic_ref = _get_topic_reference(topic) + requests_with_topic = _get_requests_with_topic_reference(topic_ref) for request_from_search in requests_with_topic: request_type = current_request_type_registry.lookup( request_from_search["type"], quiet=True diff --git a/oarepo_requests/actions/components.py b/oarepo_requests/actions/components.py index 911617ce..a140705b 100644 --- a/oarepo_requests/actions/components.py +++ b/oarepo_requests/actions/components.py @@ -1,32 +1,87 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request action components. + +These components are called as context managers when an action is executed. +""" + +from __future__ import annotations + import abc import contextlib -from typing import Generator +from typing import ( + TYPE_CHECKING, + Any, + ContextManager, + Generator, + override, +) -from invenio_requests.customizations import RequestActions +from invenio_requests.customizations import RequestAction, RequestActions, RequestType from invenio_requests.errors import CannotExecuteActionError from oarepo_requests.services.permissions.identity import request_active +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.records.api import Request + + from .generic import OARepoGenericActionMixin + +type ActionType = ( + OARepoGenericActionMixin | RequestAction +) # should be a type intersection, not yet in python + + +class RequestActionComponent(abc.ABC): + """Abstract request action component.""" -class RequestActionComponent: @abc.abstractmethod def apply( - self, identity, request_type, action, topic, uow, *args, **kwargs - ) -> Generator: - """ + self, + identity: Identity, + request_type: RequestType, + action: ActionType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> ContextManager: + """Apply the component. - :param action: - :param identity: - :param uow: - :param args: - :param kwargs: - :return: + Must return a context manager + + :param identity: Identity of the user. + :param request_type: Request type. + :param action: Action being executed. + :param topic: Topic of the request. + :param uow: Unit of work. + :param args: Additional arguments. + :param kwargs: Additional keyword arguments. """ class RequestIdentityComponent(RequestActionComponent): + """A component that adds a request active permission to the identity and removes it after processing.""" + + @override @contextlib.contextmanager - def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + action: ActionType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Generator[None, None, None]: identity.provides.add(request_active) try: yield @@ -36,8 +91,25 @@ def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): class WorkflowTransitionComponent(RequestActionComponent): + """A component that applies a workflow transition after processing the action. + + When the action is applied, the "status_to" of the request is looked up in + the workflow transitions for the request and if found, the topic's state is changed + to the target state. + """ + + @override @contextlib.contextmanager - def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + action: ActionType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Generator[None, None, None]: from oarepo_workflows.proxies import current_oarepo_workflows from sqlalchemy.exc import NoResultFound @@ -52,7 +124,7 @@ def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): NoResultFound ): # parent might be deleted - this is the case for delete_draft request type return - target_state = transitions[action.status_to] + target_state = transitions[action.status_to] # type: ignore if ( target_state and not topic.model.is_deleted ): # commit doesn't work on deleted record? @@ -60,22 +132,35 @@ def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): identity, topic, target_state, - request=action.request, + request=action.request, # type: ignore uow=uow, ) class AutoAcceptComponent(RequestActionComponent): + """A component that auto-accepts the request if the receiver has auto-approve enabled.""" + + @override @contextlib.contextmanager - def apply(self, identity, request_type, action, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + action: ActionType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Generator[None, None, None]: yield - if action.request.status != "submitted": + request: Request = action.request # type: ignore + if request.status != "submitted": return - receiver_ref = action.request.receiver # this is proxy, not dict + receiver_ref = request.receiver # this is proxy, not dict if not receiver_ref.reference_dict.get("auto_approve"): return - action_obj = RequestActions.get_action(action.request, "accept") + action_obj = RequestActions.get_action(request, "accept") if not action_obj.can_execute(): raise CannotExecuteActionError("accept") action_obj.execute(identity, uow, *args, **kwargs) diff --git a/oarepo_requests/actions/delete_draft.py b/oarepo_requests/actions/delete_draft.py index a412e942..4a8a7d4d 100644 --- a/oarepo_requests/actions/delete_draft.py +++ b/oarepo_requests/actions/delete_draft.py @@ -1,13 +1,43 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Actions for delete draft record request.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, override + from oarepo_runtime.datastreams.utils import get_record_service_for_record from .cascade_events import cancel_requests_on_topic_delete from .generic import OARepoAcceptAction +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import RequestType + class DeleteDraftAcceptAction(OARepoAcceptAction): - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + """Accept request for deletion of a draft record and delete the record.""" + + @override + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Record, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> None: 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) + topic_service.delete_draft(identity, topic["id"], *args, uow=uow, **kwargs) cancel_requests_on_topic_delete(self.request, topic, uow) diff --git a/oarepo_requests/actions/delete_published_record.py b/oarepo_requests/actions/delete_published_record.py index cf6e9543..37dd05bd 100644 --- a/oarepo_requests/actions/delete_published_record.py +++ b/oarepo_requests/actions/delete_published_record.py @@ -1,20 +1,52 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Actions for delete published record request.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, override + from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_runtime.i18n import lazy_gettext as _ from .cascade_events import cancel_requests_on_topic_delete from .generic import OARepoAcceptAction, OARepoDeclineAction +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import RequestType + class DeletePublishedRecordAcceptAction(OARepoAcceptAction): + """Accept request for deletion of a published record and delete the record.""" + name = _("Permanently delete") - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + @override + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Record, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> None: topic_service = get_record_service_for_record(topic) if not topic_service: raise KeyError(f"topic {topic} service not found") - topic_service.delete(identity, topic["id"], uow=uow, *args, **kwargs) + topic_service.delete(identity, topic["id"], *args, uow=uow, **kwargs) cancel_requests_on_topic_delete(self.request, topic, uow) class DeletePublishedRecordDeclineAction(OARepoDeclineAction): + """Decline request for deletion of a published record.""" + name = _("Keep the record") diff --git a/oarepo_requests/actions/edit_topic.py b/oarepo_requests/actions/edit_topic.py index 22920e63..10021133 100644 --- a/oarepo_requests/actions/edit_topic.py +++ b/oarepo_requests/actions/edit_topic.py @@ -1,17 +1,48 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Actions for creating a draft of published record for editing metadata.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, override + from oarepo_runtime.datastreams.utils import get_record_service_for_record from .cascade_events import update_topic from .generic import AddTopicLinksOnPayloadMixin, OARepoAcceptAction +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import RequestType + class EditTopicAcceptAction(AddTopicLinksOnPayloadMixin, OARepoAcceptAction): + """Accept creation of a draft of a published record for editing metadata.""" + self_link = "draft_record:links:self" self_html_link = "draft_record:links:self_html" - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + @override + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Record, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> None: + """Apply the action, creating a draft of the record for editing metadata.""" topic_service = get_record_service_for_record(topic) if not topic_service: raise KeyError(f"topic {topic} service not found") edit_topic = topic_service.edit(identity, topic["id"], uow=uow) update_topic(self.request, topic, edit_topic._record, uow) - return super().apply(identity, request_type, edit_topic, uow, *args, **kwargs) + super().apply(identity, request_type, edit_topic, uow, *args, **kwargs) diff --git a/oarepo_requests/actions/generic.py b/oarepo_requests/actions/generic.py index e84089a8..3c2cd3e7 100644 --- a/oarepo_requests/actions/generic.py +++ b/oarepo_requests/actions/generic.py @@ -1,25 +1,78 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Mixin for all oarepo actions.""" + +from __future__ import annotations + from functools import cached_property +from typing import TYPE_CHECKING, Any from invenio_requests.customizations import actions from oarepo_runtime.i18n import lazy_gettext as _ from oarepo_requests.proxies import current_oarepo_requests +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_records_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import RequestType + from invenio_requests.records.api import Request + + from oarepo_requests.actions.components import RequestActionComponent + class OARepoGenericActionMixin: + """Mixin for all oarepo actions.""" + + name: str + @classmethod - def stateful_name(cls, identity, **kwargs): + def stateful_name(cls, identity: Identity, **kwargs: Any) -> str | LazyString: + """Return the name of the action. + + The name can be a lazy multilingual string and may depend on the state of the action, + request or identity of the caller. + """ return cls.name - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> None: + """Apply the action to the topic.""" pass def _execute_with_components( - self, components, identity, request_type, topic, uow, *args, **kwargs - ): + self, + components: list[RequestActionComponent], + identity: Identity, + request_type: RequestType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> None: + """Execute the action with the given components. + + Each component has an apply method that must return a context manager. + The context manager is entered and exited in the order of the components + and the action is executed inside the most inner context manager. + """ if not components: self.apply(identity, request_type, topic, uow, *args, **kwargs) - super().execute(identity, uow, *args, **kwargs) + super().execute(identity, uow, *args, **kwargs) # type: ignore else: with components[0].apply( identity, request_type, self, topic, uow, *args, **kwargs @@ -29,53 +82,76 @@ def _execute_with_components( ) @cached_property - def components(self): + def components(self) -> list[RequestActionComponent]: + """Return a list of components for this action.""" return [ component_cls() for component_cls in current_oarepo_requests.action_components(self) ] - def execute(self, identity, uow, *args, **kwargs): - request_type = self.request.type - topic = self.request.topic.resolve() + def execute( + self, identity: Identity, uow: UnitOfWork, *args: Any, **kwargs: Any + ) -> None: + """Execute the action.""" + request: Request = self.request # type: ignore + request_type = request.type + topic = request.topic.resolve() self._execute_with_components( self.components, identity, request_type, topic, uow, *args, **kwargs ) class AddTopicLinksOnPayloadMixin: - self_link = None - self_html_link = None - - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + """A mixin for action that takes links from the topic and stores them inside the payload.""" + + self_link: str | None = None + self_html_link: str | None = None + + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Any, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Record: + """Apply the action to the topic.""" topic_dict = topic.to_dict() - if "payload" not in self.request: - self.request["payload"] = {} + request: Request = self.request # type: ignore + + if "payload" not in request: + request["payload"] = {} # invenio does not allow non-string values in the payload, so using colon notation here # client will need to handle this and convert to links structure # can not use dot notation as marshmallow tries to be too smart and does not serialize dotted keys - self.request["payload"][self.self_link] = topic_dict["links"]["self"] - self.request["payload"][self.self_html_link] = topic_dict["links"]["self_html"] + request["payload"][self.self_link] = topic_dict["links"]["self"] + request["payload"][self.self_html_link] = topic_dict["links"]["self_html"] return topic._record class OARepoSubmitAction(OARepoGenericActionMixin, actions.SubmitAction): + """Submit action extended for oarepo requests.""" + name = _("Submit") - """""" class OARepoDeclineAction(OARepoGenericActionMixin, actions.DeclineAction): + """Decline action extended for oarepo requests.""" + name = _("Decline") - """""" class OARepoAcceptAction(OARepoGenericActionMixin, actions.AcceptAction): + """Accept action extended for oarepo requests.""" + name = _("Accept") - """""" class OARepoCancelAction(actions.CancelAction): + """Cancel action extended for oarepo requests.""" + status_from = ["created", "submitted"] status_to = "cancelled" diff --git a/oarepo_requests/actions/new_version.py b/oarepo_requests/actions/new_version.py index ca0b36da..8df194c9 100644 --- a/oarepo_requests/actions/new_version.py +++ b/oarepo_requests/actions/new_version.py @@ -1,14 +1,44 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Actions for creating a new version of a published record.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from oarepo_runtime.datastreams.utils import get_record_service_for_record from .cascade_events import update_topic from .generic import AddTopicLinksOnPayloadMixin, OARepoAcceptAction +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.customizations import RequestType + class NewVersionAcceptAction(AddTopicLinksOnPayloadMixin, OARepoAcceptAction): + """Accept creation of a new version of a published record.""" + self_link = "draft_record:links:self" self_html_link = "draft_record:links:self_html" - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Record, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Record: + """Apply the action, creating a new version of the record.""" topic_service = get_record_service_for_record(topic) if not topic_service: raise KeyError(f"topic {topic} service not found") @@ -19,7 +49,7 @@ def apply(self, identity, request_type, topic, uow, *args, **kwargs): and "keep_files" in self.request["payload"] and self.request["payload"]["keep_files"] == "true" ): - res = topic_service.import_files(identity, new_version_topic.id) + topic_service.import_files(identity, new_version_topic.id) update_topic(self.request, topic, new_version_topic._record, uow) return super().apply( identity, request_type, new_version_topic, uow, *args, **kwargs diff --git a/oarepo_requests/actions/publish_draft.py b/oarepo_requests/actions/publish_draft.py index 2be10fa9..bc24aef9 100644 --- a/oarepo_requests/actions/publish_draft.py +++ b/oarepo_requests/actions/publish_draft.py @@ -1,5 +1,18 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Actions for publishing draft requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_access.permissions import system_identity -from invenio_records_resources.services.uow import RecordCommitOp +from invenio_records_resources.services.uow import RecordCommitOp, UnitOfWork from marshmallow import ValidationError from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_runtime.i18n import lazy_gettext as _ @@ -12,14 +25,22 @@ OARepoSubmitAction, ) +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations import RequestType + class PublishDraftSubmitAction(OARepoSubmitAction): - def can_execute(self): + """Submit action for publishing draft requests.""" + + def can_execute(self) -> bool: + """Check if the action can be executed.""" if not super().can_execute(): return False try: topic = self.request.topic.resolve() - except: # noqa: used for links, so ignore errors here + except: # noqa E722: used for displaying buttons, so ignore errors here return False topic_service = get_record_service_for_record(topic) try: @@ -30,12 +51,23 @@ def can_execute(self): class PublishDraftAcceptAction(AddTopicLinksOnPayloadMixin, OARepoAcceptAction): + """Accept action for publishing draft requests.""" + self_link = "published_record:links:self" self_html_link = "published_record:links:self_html" name = _("Publish") - def apply(self, identity, request_type, topic, uow, *args, **kwargs): + def apply( + self, + identity: Identity, + request_type: RequestType, + topic: Record, + uow: UnitOfWork, + *args: Any, + **kwargs: Any, + ) -> Record: + """Publish the draft.""" topic_service = get_record_service_for_record(topic) if not topic_service: raise KeyError(f"topic {topic} service not found") @@ -46,7 +78,7 @@ def apply(self, identity, request_type, topic, uow, *args, **kwargs): uow.register(RecordCommitOp(topic, indexer=topic_service.indexer)) published_topic = topic_service.publish( - identity, id_, uow=uow, expand=False, *args, **kwargs + identity, id_, *args, uow=uow, expand=False, **kwargs ) update_topic(self.request, topic, published_topic._record, uow) return super().apply( @@ -55,4 +87,6 @@ def apply(self, identity, request_type, topic, uow, *args, **kwargs): class PublishDraftDeclineAction(OARepoDeclineAction): + """Decline action for publishing draft requests.""" + name = _("Return for correction") diff --git a/oarepo_requests/config.py b/oarepo_requests/config.py index 96e13216..52a38d1a 100644 --- a/oarepo_requests/config.py +++ b/oarepo_requests/config.py @@ -1,27 +1,25 @@ -from oarepo_requests.actions.components import ( - AutoAcceptComponent, - RequestIdentityComponent, -) -from oarepo_requests.types.events import TopicDeleteEventType -from oarepo_requests.types.events.topic_update import TopicUpdateEventType - -try: - import oarepo_workflows # noqa - from oarepo_workflows.requests.events import WorkflowEvent - - from oarepo_requests.actions.components import WorkflowTransitionComponent - - workflow_action_components = [WorkflowTransitionComponent] -except ImportError: - workflow_action_components = [] - WorkflowEvent = None +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Default configuration of oarepo-requests.""" import invenio_requests.config +import oarepo_workflows # noqa from invenio_requests.customizations import CommentEventType, LogEventType from invenio_requests.services.permissions import ( PermissionPolicy as InvenioRequestsPermissionPolicy, ) +from oarepo_workflows.requests.events import WorkflowEvent +from oarepo_requests.actions.components import ( + AutoAcceptComponent, + RequestIdentityComponent, + WorkflowTransitionComponent, +) from oarepo_requests.resolvers.ui import ( FallbackEntityReferenceUIResolver, GroupEntityReferenceUIResolver, @@ -32,6 +30,8 @@ EditPublishedRecordRequestType, PublishDraftRequestType, ) +from oarepo_requests.types.events import TopicDeleteEventType +from oarepo_requests.types.events.topic_update import TopicUpdateEventType REQUESTS_REGISTERED_TYPES = [ DeletePublishedRecordRequestType(), @@ -46,20 +46,17 @@ REQUESTS_ALLOWED_RECEIVERS = ["user", "group", "auto_approve"] -if WorkflowEvent: - DEFAULT_WORKFLOW_EVENT_SUBMITTERS = { - CommentEventType.type_id: WorkflowEvent( - submitters=InvenioRequestsPermissionPolicy.can_create_comment - ), - LogEventType.type_id: WorkflowEvent( - submitters=InvenioRequestsPermissionPolicy.can_create_comment - ), - TopicUpdateEventType.type_id: WorkflowEvent( - submitters=InvenioRequestsPermissionPolicy.can_create_comment - ), - } -else: - DEFAULT_WORKFLOW_EVENT_SUBMITTERS = {} +DEFAULT_WORKFLOW_EVENTS = { + CommentEventType.type_id: WorkflowEvent( + submitters=InvenioRequestsPermissionPolicy.can_create_comment + ), + LogEventType.type_id: WorkflowEvent( + submitters=InvenioRequestsPermissionPolicy.can_create_comment + ), + TopicUpdateEventType.type_id: WorkflowEvent( + submitters=InvenioRequestsPermissionPolicy.can_create_comment + ), +} ENTITY_REFERENCE_UI_RESOLVERS = { @@ -70,6 +67,8 @@ REQUESTS_UI_SERIALIZATION_REFERENCED_FIELDS = ["created_by", "receiver", "topic"] +workflow_action_components = [WorkflowTransitionComponent] + REQUESTS_ACTION_COMPONENTS = { "accepted": [ *workflow_action_components, diff --git a/oarepo_requests/errors.py b/oarepo_requests/errors.py index f73587da..3e6346e2 100644 --- a/oarepo_requests/errors.py +++ b/oarepo_requests/errors.py @@ -1,55 +1,90 @@ -from marshmallow import ValidationError +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Errors raised by oarepo-requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from oarepo_workflows.errors import ( + EventTypeNotInWorkflow as WorkflowEventTypeNotInWorkflow, +) +from oarepo_workflows.errors import ( + RequestTypeNotInWorkflow as WorkflowRequestTypeNotInWorkflow, +) +from typing_extensions import deprecated + +if TYPE_CHECKING: + from invenio_records_resources.records import Record + from invenio_requests.customizations import RequestType + + +@deprecated( + "This exception is deprecated. Use oarepo_workflows.errors.RequestTypeNotInWorkflow instead." +) +class EventTypeNotInWorkflow(WorkflowEventTypeNotInWorkflow): + """Raised when an event type is not in the workflow.""" + + ... + + +@deprecated( + "This exception is deprecated. Use oarepo_workflows.errors.RequestTypeNotInWorkflow instead." +) +class RequestTypeNotInWorkflow(WorkflowRequestTypeNotInWorkflow): + """Raised when a request type is not in the workflow.""" + + ... class OpenRequestAlreadyExists(Exception): """An open request already exists.""" - def __init__(self, request, record): - self.request = request + def __init__(self, request_type: RequestType, record: Record) -> None: + """Initialize the exception.""" + self.request_type = request_type self.record = record @property - def description(self): + def description(self) -> str: """Exception's description.""" - return f"There is already an open request of {self.request.name} on {self.record.id}." + return f"There is already an open request of {self.request_type.name} on {self.record.id}." class UnknownRequestType(Exception): - def __init__(self, request_type): + """Exception raised when user tries to create a request with an unknown request type.""" + + def __init__(self, request_type: str) -> None: + """Initialize the exception.""" self.request_type = request_type @property - def description(self): + def description(self) -> str: """Exception's description.""" return f"Unknown request type {self.request_type}." -class RequestTypeNotInWorkflow(Exception): - def __init__(self, request_type, workflow): - self.request_type = request_type - self.workflow = workflow +class ReceiverNonReferencable(Exception): + """Raised when receiver is required but could not be estimated from the record/caller.""" - @property - def description(self): - """Exception's description.""" - return f"Request type {self.request_type} not in workflow {self.workflow}." - - -class ReceiverUnreferencable(Exception): - def __init__(self, request_type, record, **kwargs): + def __init__( + self, request_type: RequestType, record: Record, **kwargs: Any + ) -> None: + """Initialize the exception.""" self.request_type = request_type self.record = record self.kwargs = kwargs @property - def description(self): + def description(self) -> str: """Exception's description.""" message = f"Receiver for request type {self.request_type} is required but wasn't successfully referenced on record {self.record['id']}." if self.kwargs: message += "\n Additional keyword arguments:" message += f"\n{', '.join(self.kwargs)}" return message - - -class MissingTopicError(ValidationError): - """""" diff --git a/oarepo_requests/ext.py b/oarepo_requests/ext.py index d1496521..5fda5c9b 100644 --- a/oarepo_requests/ext.py +++ b/oarepo_requests/ext.py @@ -1,10 +1,22 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""OARepo-Requests extension.""" + +from __future__ import annotations + +import dataclasses from functools import cached_property +from typing import TYPE_CHECKING, Callable import importlib_metadata from invenio_base.utils import obj_or_import_string from invenio_requests.proxies import current_events_service -from oarepo_requests.proxies import current_oarepo_requests from oarepo_requests.resources.events.config import OARepoRequestsCommentsResourceConfig from oarepo_requests.resources.events.resource import OARepoRequestsCommentsResource from oarepo_requests.resources.oarepo.config import OARepoRequestsResourceConfig @@ -12,15 +24,34 @@ from oarepo_requests.services.oarepo.config import OARepoRequestsServiceConfig from oarepo_requests.services.oarepo.service import OARepoRequestsService +if TYPE_CHECKING: + from flask import Flask + from flask_principal import Identity + from invenio_records_resources.records.api import Record + from invenio_requests.customizations import RequestType + + from oarepo_requests.actions.components import RequestActionComponent + from oarepo_requests.resolvers.ui import OARepoUIResolver + from oarepo_requests.typing import EntityReference + + +@dataclasses.dataclass +class ServiceConfigs: + """Configurations for services provided by this package.""" + + requests: OARepoRequestsServiceConfig + # request_events = RequestEventsServiceConfig.build(app) + class OARepoRequests: - def __init__(self, app=None): + """OARepo-Requests extension.""" + + def __init__(self, app: Flask = None) -> None: """Extension initialization.""" - self.requests_resource = None if app: self.init_app(app) - def init_app(self, app): + def init_app(self, app: Flask) -> None: """Flask application initialization.""" self.app = app self.init_config(app) @@ -29,14 +60,43 @@ def init_app(self, app): app.extensions["oarepo-requests"] = self @property - def entity_reference_ui_resolvers(self): + def entity_reference_ui_resolvers(self) -> dict[str, OARepoUIResolver]: + """Resolvers for entity references. + + :return: a dictionary (entity-type -> resolver instance) + """ return self.app.config["ENTITY_REFERENCE_UI_RESOLVERS"] @property - def ui_serialization_referenced_fields(self): + def ui_serialization_referenced_fields(self) -> list[str]: + """Request fields containing EntityReference that should be serialized in the UI. + + These fields will be dereferenced, serialized to UI using one of the entity_reference_ui_resolvers + and included in the serialized request. + """ return self.app.config["REQUESTS_UI_SERIALIZATION_REFERENCED_FIELDS"] - def default_request_receiver(self, identity, request_type, record, creator, data): + def default_request_receiver( + self, + identity: Identity, + request_type: RequestType, + record: Record, + creator: EntityReference | Identity, + data: dict, + ) -> EntityReference | None: + """Return the default receiver for the request. + + Gets the default receiver for the request based on the request type, record and data. + It is used when the receiver is not explicitly set when creating a request. It does so + by taking a function from the configuration under the key OAREPO_REQUESTS_DEFAULT_RECEIVER + and calling it with the given parameters. + + :param identity: Identity of the user creating the request. + :param request_type: Type of the request. + :param record: Record the request is about. + :param creator: Creator of the request. + :param data: Payload of the request. + """ # TODO: if the topic is one of the workflow topics, use the workflow to determine the receiver # otherwise use the default receiver return obj_or_import_string( @@ -50,42 +110,52 @@ def default_request_receiver(self, identity, request_type, record, creator, data ) @property - def allowed_receiver_ref_types(self): + def allowed_receiver_ref_types(self) -> list[str]: + """Return a list of allowed receiver entity reference types. + + This value is taken from the configuration key REQUESTS_ALLOWED_RECEIVERS. + """ return self.app.config.get("REQUESTS_ALLOWED_RECEIVERS", []) @cached_property - def identity_to_entity_references_fncs(self): + def identity_to_entity_references_functions(self) -> list[Callable]: + """Return a list of functions that map identity to entity references. + + These functions are used to map the identity of the user to entity references + that represent the needs of the identity. The functions are taken from the entrypoints + registered under the group oarepo_requests.identity_to_entity_references. + """ group_name = "oarepo_requests.identity_to_entity_references" return [ x.load() for x in importlib_metadata.entry_points().select(group=group_name) ] - def identity_to_entity_references(self, identity): - mappings = current_oarepo_requests.identity_to_entity_references_fncs + def identity_to_entity_references( + self, identity: Identity + ) -> list[EntityReference]: + """Map the identity to entity references.""" ret = [ - mapping_fnc(identity) for mapping_fnc in mappings if mapping_fnc(identity) + mapping_fnc(identity) + for mapping_fnc in (self.identity_to_entity_references_functions) + if mapping_fnc(identity) ] flattened_ret = [] for mapping_result in ret: - flattened_ret += mapping_result + if mapping_result: + flattened_ret += mapping_result return flattened_ret # copied from invenio_requests for now - def service_configs(self, app): - """Customized service configs.""" - - class ServiceConfigs: - requests = OARepoRequestsServiceConfig.build(app) - # request_events = RequestEventsServiceConfig.build(app) - - return ServiceConfigs + def service_configs(self, app: Flask) -> ServiceConfigs: + """Return customized service configs.""" + return ServiceConfigs(requests=OARepoRequestsServiceConfig.build(app)) - def init_services(self, app): + def init_services(self, app: Flask) -> None: + """Initialize services provided by this extension.""" service_configs = self.service_configs(app) - """Initialize the service and resource for Requests.""" self.requests_service = OARepoRequestsService(config=service_configs.requests) - def init_resources(self, app): + def init_resources(self, app: Flask) -> None: """Init resources.""" self.requests_resource = OARepoRequestsResource( oarepo_requests_service=self.requests_service, @@ -98,10 +168,11 @@ def init_resources(self, app): from invenio_requests.customizations.actions import RequestAction - def action_components(self, action: RequestAction): - from . import config - - components = config.REQUESTS_ACTION_COMPONENTS + def action_components( + self, action: RequestAction + ) -> list[type[RequestActionComponent]]: + """Return components for the given action.""" + components = self.app.config["REQUESTS_ACTION_COMPONENTS"] if callable(components): return components(action) return [ @@ -109,9 +180,8 @@ def action_components(self, action: RequestAction): for component in components[action.status_to] ] - def init_config(self, app): + def init_config(self, app: Flask) -> None: """Initialize configuration.""" - from . import config app.config.setdefault("REQUESTS_ALLOWED_RECEIVERS", []).extend( @@ -123,9 +193,19 @@ def init_config(self, app): app.config.setdefault("REQUESTS_UI_SERIALIZATION_REFERENCED_FIELDS", []).extend( config.REQUESTS_UI_SERIALIZATION_REFERENCED_FIELDS ) - app.config.setdefault("DEFAULT_WORKFLOW_EVENT_SUBMITTERS", {}).update( - config.DEFAULT_WORKFLOW_EVENT_SUBMITTERS + # do not overwrite user's stuff + app_default_workflow_events = app.config.setdefault( + "DEFAULT_WORKFLOW_EVENTS", {} ) + for k, v in config.DEFAULT_WORKFLOW_EVENTS.items(): + if k not in app_default_workflow_events: + app_default_workflow_events[k] = v + + # let the user override the action components + rac = app.config.setdefault("REQUESTS_ACTION_COMPONENTS", {}) + for k, v in config.REQUESTS_ACTION_COMPONENTS.items(): + if k not in rac: + rac[k] = v app_registered_event_types = app.config.setdefault( "REQUESTS_REGISTERED_EVENT_TYPES", [] @@ -135,12 +215,12 @@ def init_config(self, app): app_registered_event_types.append(event_type) -def api_finalize_app(app): +def api_finalize_app(app: Flask) -> None: """Finalize app.""" finalize_app(app) -def finalize_app(app): +def finalize_app(app: Flask) -> None: """Finalize app.""" from invenio_requests.proxies import current_event_type_registry diff --git a/oarepo_requests/identity_to_entity_references.py b/oarepo_requests/identity_to_entity_references.py index 175aaf36..702de297 100644 --- a/oarepo_requests/identity_to_entity_references.py +++ b/oarepo_requests/identity_to_entity_references.py @@ -1,7 +1,28 @@ -def user_mappings(identity): +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Helper functions to convert identity to entity references.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flask_principal import Identity + + from oarepo_requests.typing import EntityReference + + +def user_mappings(identity: Identity) -> list[EntityReference]: + """Convert identity to entity references of type "user".""" return [{"user": identity.id}] -def group_mappings(identity): +def group_mappings(identity: Identity) -> list[EntityReference]: + """Convert groups of the identity to entity references of type "group".""" roles = [n.value for n in identity.provides if n.method == "role"] return [{"group": role_id} for role_id in roles] diff --git a/oarepo_requests/invenio_patches.py b/oarepo_requests/invenio_patches.py index 93b5d429..941158ea 100644 --- a/oarepo_requests/invenio_patches.py +++ b/oarepo_requests/invenio_patches.py @@ -1,4 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Patches to invenio service to allow for more flexible requests handling.""" + +from __future__ import annotations + from functools import cached_property +from typing import TYPE_CHECKING, Any, Callable from flask_resources import JSONSerializer, ResponseHandler from invenio_records_resources.resources.records.headers import etag_headers @@ -13,6 +25,7 @@ RequestsServiceConfig, ) from invenio_requests.services.requests.params import IsOpenParam +from invenio_search.engine import dsl from marshmallow import fields from opensearch_dsl.query import Bool @@ -22,36 +35,50 @@ OARepoRequestsUIJSONSerializer, ) from oarepo_requests.services.oarepo.config import OARepoRequestsServiceConfig -from oarepo_requests.utils import _reference_query_term +from oarepo_requests.utils import create_query_term_for_reference + +if TYPE_CHECKING: + from flask.blueprints import BlueprintSetupState + from flask_principal import Identity + from flask_resources.serializers.base import BaseSerializer + from opensearch_dsl.query import Query class RequestOwnerFilterParam(FilterParam): - def apply(self, identity, search, params): + """Filter requests by owner.""" + + def apply(self, identity: Identity, search: Query, params: dict[str, str]) -> Query: + """Apply the filter to the search.""" value = params.pop(self.param_name, None) if value is not None: search = search.filter("term", **{self.field_name: identity.id}) return search -from invenio_search.engine import dsl +class RequestReceiverFilterParam(FilterParam): + """Filter requests by receiver. + Note: This is different from the invenio handling. Invenio requires receiver to be + a user, we handle it as a more generic reference. + """ -class RequestReceiverFilterParam(FilterParam): - def apply(self, identity, search, params): + def apply(self, identity: Identity, search: Query, params: dict[str, str]) -> Query: + """Apply the filter to the search.""" value = params.pop(self.param_name, None) terms = dsl.Q("match_none") if value is not None: references = current_oarepo_requests.identity_to_entity_references(identity) for reference in references: - query_term = _reference_query_term(self.field_name, reference) + query_term = create_query_term_for_reference(self.field_name, reference) terms |= query_term search = search.filter(Bool(filter=terms)) return search class IsClosedParam(IsOpenParam): + """Get just the closed requests.""" - def apply(self, identity, search, params): + def apply(self, identity: Identity, search: Query, params: dict[str, str]) -> Query: """Evaluate the is_closed parameter on the search.""" if params.get("is_closed") is True: search = search.filter("term", **{self.field_name: True}) @@ -61,6 +88,8 @@ def apply(self, identity, search, params): class EnhancedRequestSearchOptions(RequestSearchOptions): + """Searched options enhanced with additional filters.""" + params_interpreters_cls = RequestSearchOptions.params_interpreters_cls + [ RequestOwnerFilterParam.factory("mine", "created_by.user"), RequestReceiverFilterParam.factory("assigned", "receiver"), @@ -69,13 +98,22 @@ class EnhancedRequestSearchOptions(RequestSearchOptions): class ExtendedRequestSearchRequestArgsSchema(RequestSearchRequestArgsSchema): + """Marshmallow schema for the extra filters.""" + mine = fields.Boolean() assigned = fields.Boolean() is_closed = fields.Boolean() -def override_invenio_requests_config(blueprint, *args, **kwargs): - with blueprint.app.app_context(): +def override_invenio_requests_config( + state: BlueprintSetupState, *args: Any, **kwargs: Any +) -> None: + """Override the invenio requests configuration. + + This function is called from the blueprint setup function as this should be a safe moment + to monkey patch the invenio requests configuration. + """ + with state.app.app_context(): # this monkey patch should be done better (support from invenio) RequestsServiceConfig.search = EnhancedRequestSearchOptions RequestsResourceConfig.request_search_args = ( @@ -87,19 +125,19 @@ def override_invenio_requests_config(blueprint, *args, **kwargs): RequestsServiceConfig.links_item[k] = v class LazySerializer: - def __init__(self, serializer_cls): + def __init__(self, serializer_cls: type[BaseSerializer]) -> None: self.serializer_cls = serializer_cls @cached_property - def __instance(self): + def __instance(self) -> BaseSerializer: return self.serializer_cls() @property - def serialize_object_list(self): + def serialize_object_list(self) -> Callable: return self.__instance.serialize_object_list @property - def serialize_object(self): + def serialize_object(self) -> Callable: return self.__instance.serialize_object RequestsResourceConfig.response_handlers = { diff --git a/oarepo_requests/proxies.py b/oarepo_requests/proxies.py index ff33385e..2365876a 100644 --- a/oarepo_requests/proxies.py +++ b/oarepo_requests/proxies.py @@ -1,10 +1,30 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Proxy objects for accessing the current application's requests service and resource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import current_app from werkzeug.local import LocalProxy -current_oarepo_requests = LocalProxy(lambda: current_app.extensions["oarepo-requests"]) -current_oarepo_requests_service = LocalProxy( +if TYPE_CHECKING: + from oarepo_requests.ext import OARepoRequests + from oarepo_requests.resources.oarepo.resource import OARepoRequestsResource + from oarepo_requests.services.oarepo.service import OARepoRequestsService + +current_oarepo_requests: OARepoRequests = LocalProxy( # type: ignore + lambda: current_app.extensions["oarepo-requests"] +) +current_oarepo_requests_service: OARepoRequestsService = LocalProxy( # type: ignore lambda: current_app.extensions["oarepo-requests"].requests_service ) -current_oarepo_requests_resource = LocalProxy( +current_oarepo_requests_resource: OARepoRequestsResource = LocalProxy( # type: ignore lambda: current_app.extensions["oarepo-requests"].requests_resource ) diff --git a/oarepo_requests/receiver.py b/oarepo_requests/receiver.py index 2937013f..9c7fa9f5 100644 --- a/oarepo_requests/receiver.py +++ b/oarepo_requests/receiver.py @@ -1,7 +1,35 @@ -from oarepo_requests.errors import ReceiverUnreferencable, RequestTypeNotInWorkflow +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Default workflow receiver function.""" +from __future__ import annotations -def default_workflow_receiver_function(record=None, request_type=None, **kwargs): +from typing import TYPE_CHECKING, Any + +from oarepo_requests.errors import ReceiverNonReferencable, RequestTypeNotInWorkflow + +if TYPE_CHECKING: + from invenio_records_resources.records.api import Record + from invenio_requests.customizations.request_types import RequestType + from oarepo_workflows import WorkflowRequest + + from oarepo_requests.typing import EntityReference + + +def default_workflow_receiver_function( + record: Record = None, request_type: RequestType = None, **kwargs: Any +) -> EntityReference | None: + """Get the receiver of the request. + + This function is called by oarepo-requests when a new request is created. It should + return the receiver of the request. The receiver is the entity that is responsible for + accepting/declining the request. + """ from oarepo_workflows.proxies import current_oarepo_workflows workflow_id = current_oarepo_workflows.get_workflow_from_record(record) @@ -9,16 +37,18 @@ def default_workflow_receiver_function(record=None, request_type=None, **kwargs) return None # exception? try: - request = getattr( + request: WorkflowRequest = getattr( current_oarepo_workflows.record_workflows[workflow_id].requests(), request_type.type_id, ) - except AttributeError: - raise RequestTypeNotInWorkflow(request_type.type_id, workflow_id) + except AttributeError as e: + raise RequestTypeNotInWorkflow(request_type.type_id, workflow_id) from e - receiver = request.reference_receivers( + receiver = request.recipient_entity_reference( record=record, request_type=request_type, **kwargs ) if not request_type.receiver_can_be_none and not receiver: - raise ReceiverUnreferencable(request_type=request_type, record=record, **kwargs) + raise ReceiverNonReferencable( + request_type=request_type, record=record, **kwargs + ) return receiver diff --git a/oarepo_requests/resolvers/__init__.py b/oarepo_requests/resolvers/__init__.py index e69de29b..d49388d5 100644 --- a/oarepo_requests/resolvers/__init__.py +++ b/oarepo_requests/resolvers/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Entity resolvers.""" diff --git a/oarepo_requests/resolvers/autoapprove.py b/oarepo_requests/resolvers/autoapprove.py deleted file mode 100644 index feee30f2..00000000 --- a/oarepo_requests/resolvers/autoapprove.py +++ /dev/null @@ -1,41 +0,0 @@ -from invenio_records_resources.references.entity_resolvers import EntityProxy -from invenio_records_resources.references.entity_resolvers.base import EntityResolver - - -class AutoApprover: - def __init__(self, value): - self.value = value - - -class AutoApproveProxy(EntityProxy): - def _resolve(self): - value = self._parse_ref_dict_id() - return AutoApprover(value) - - def get_needs(self, ctx=None): - return [] # granttokens calls this - - def pick_resolved_fields(self, identity, resolved_dict): - return {"auto_approve": resolved_dict["value"]} - - -class AutoApproveResolver(EntityResolver): - type_id = "auto_approve" - - def __init__(self): - self.type_key = self.type_id - super().__init__( - None, - ) - - def matches_reference_dict(self, ref_dict): - return self._parse_ref_dict_type(ref_dict) == self.type_id - - def _reference_entity(self, entity): - return {self.type_key: str(entity.value)} - - def matches_entity(self, entity): - return isinstance(entity, AutoApprover) - - def _get_entity_proxy(self, ref_dict): - return AutoApproveProxy(self, ref_dict) diff --git a/oarepo_requests/resolvers/ui.py b/oarepo_requests/resolvers/ui.py index d76a6850..e6bcd391 100644 --- a/oarepo_requests/resolvers/ui.py +++ b/oarepo_requests/resolvers/ui.py @@ -1,3 +1,20 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""UI resolvers of common entities.""" + +from __future__ import annotations + +import abc +import contextlib +import copy +from typing import TYPE_CHECKING, Any, TypedDict, cast, override + +from flask import request from invenio_records_resources.resources.errors import PermissionDeniedError from invenio_search.engine import dsl from invenio_users_resources.proxies import ( @@ -5,254 +22,524 @@ current_users_service, ) from oarepo_runtime.i18n import gettext as _ -from invenio_pidstore.errors import PIDDoesNotExistError from ..proxies import current_oarepo_requests from ..utils import get_matching_service_for_refdict +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.services.records import RecordService as DraftsService + from invenio_records_resources.services.records.results import RecordItem + + from oarepo_requests.typing import EntityReference + + +class UIResolvedReference(TypedDict): + """Resolved UI reference.""" + + reference: EntityReference + type: str + label: str | LazyString + links: dict[str, str] + + +def resolve(identity: Identity, reference: dict[str, str]) -> UIResolvedReference: + """Resolve a reference to a UI representation. + + :param identity: Identity of the user. + :param reference: Reference to resolve. + """ + reference_type, reference_value = next(iter(reference.items())) + + # use cache to avoid multiple resolve for the same reference within one request + # the runtime error is risen when we are outside the request context - in this case we just skip the cache + with contextlib.suppress(RuntimeError): + if not hasattr(request, "current_oarepo_requests_ui_resolve_cache"): + request.current_oarepo_requests_ui_resolve_cache = {} + cache = request.current_oarepo_requests_ui_resolve_cache + if (reference_type, reference_value) in cache: + return cache[(reference_type, reference_value)] -def resolve(identity, reference): - reference_type = list(reference.keys())[0] entity_resolvers = current_oarepo_requests.entity_reference_ui_resolvers if reference_type in entity_resolvers: - return entity_resolvers[reference_type].resolve_one(identity, reference) + resolved = entity_resolvers[reference_type].resolve_one( + identity, reference_value + ) else: + fallback_resolver = copy.copy(entity_resolvers["fallback"]) + fallback_resolver.reference_type = reference_type # TODO log warning - return entity_resolvers["fallback"].resolve_one(identity, reference) + resolved = fallback_resolver.resolve_one(identity, reference_value) + # use cache to avoid multiple resolve for the same reference within one request + # the runtime error is risen when we are outside the request context - in this case we just skip the cache + with contextlib.suppress(RuntimeError): + request.current_oarepo_requests_ui_resolve_cache[ + (reference_type, reference_value) + ] = resolved -def fallback_label_result(reference): + return resolved + + +def fallback_label_result(reference: dict[str, str]) -> str: + """Get a fallback label for a reference if there is no other way to get it.""" id_ = list(reference.values())[0] return f"id: {id_}" -def fallback_result(reference): - type = list(reference.keys())[0] +def fallback_result(reference: dict[str, str], **kwargs: Any) -> UIResolvedReference: + """Get a fallback result for a reference if there is no other way to get it.""" return { "reference": reference, - "type": type, + "type": next(iter(reference.keys())), "label": fallback_label_result(reference), + "links": {}, } -class OARepoUIResolver: - def __init__(self, reference_type): - self.reference_type = reference_type - - def _get_id(self, result): - raise NotImplementedError("Parent entity ui resolver should be abstract") - - def _search_many(self, identity, values, *args, **kwargs): - raise NotImplementedError("Parent entity ui resolver should be abstract") +class OARepoUIResolver(abc.ABC): + """Base class for entity reference UI resolvers.""" - def _search_one(self, identity, reference, *args, **kwargs): - raise NotImplementedError("Parent entity ui resolver should be abstract") + def __init__(self, reference_type: str) -> None: + """Initialize the resolver. - def _resolve(self, record, reference): - raise NotImplementedError("Parent entity ui resolver should be abstract") + :param reference_type: type of the reference (the key in the reference dict) + """ + self.reference_type = reference_type - def resolve_one(self, identity, reference): - try: - record = self._search_one(identity, reference) - if not record: - return fallback_result(reference) - except PIDDoesNotExistError: - return fallback_result(reference) - resolved = self._resolve(record, reference) + @abc.abstractmethod + def _get_id(self, entity: dict) -> str: + """Get the id of the serialized entity returned from a service. + + :result: entity returned from a service (as a RecordItem instance) + """ + raise NotImplementedError(f"Implement this in {self.__class__.__name__}") + + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found. Each entity must be API serialized to dict. + """ + raise NotImplementedError(f"Implement this in {self.__class__.__name__}") + + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + :return: API serialization of the data of the entity or + None if the entity no longer exists + + Note: this call must return None if the entity does not exist, + not raise an exception! + """ + raise NotImplementedError(f"Implement this in {self.__class__.__name__}") + + def _get_entity_ui_representation( + self, entity: dict, reference: EntityReference + ) -> UIResolvedReference: + """Create a UI representation of a entity. + + :entity: resolved and API serialized entity + :reference: reference to the entity + :return: UI representation of the entity + """ + raise NotImplementedError(f"Implement this in {self.__class__.__name__}") + + def resolve_one( + self, identity: Identity, _id: str, **kwargs: Any + ) -> UIResolvedReference: + """Resolve a single reference to a UI representation. + + :param identity: identity of the user + :param _id: id of the entity + :return: UI representation of the reference + """ + reference = {self.reference_type: _id} + entity = self._search_one(identity, _id) + if not entity: + return fallback_result(reference, **kwargs) + resolved = self._get_entity_ui_representation(entity, reference) return resolved - def resolve_many(self, identity, values): - # the pattern is broken here by using values instead of reference? - search_results = self._search_many(identity, values) + def resolve_many( + self, identity: Identity, ids: list[str] + ) -> list[UIResolvedReference]: + """Resolve many references to UI representations. + + :param identity: identity of the user + :param ids: ids of the records of the type self.reference_type + :return: list of UI representations of the references + """ + search_results = self._search_many(identity, ids) ret = [] + result: dict for result in search_results: # it would be simple if there was a map of results, can opensearch do this? ret.append( - self._resolve(result, {self.reference_type: self._get_id(result)}) + self._get_entity_ui_representation( + result, {self.reference_type: self._get_id(result)} + ) ) return ret - def _resolve_links(self, record): + def _extract_links_from_resolved_reference( + self, resolved_reference: dict + ) -> dict[str, str]: + """Extract links from a entity.""" links = {} - record_links = {} - if isinstance(record, dict): - if "links" in record: - record_links = record["links"] - elif hasattr(record, "data"): - if "links" in record.data: - record_links = record.data["links"] + entity_links = {} + if "links" in resolved_reference: + entity_links = resolved_reference["links"] for link_type in ("self", "self_html"): - if link_type in record_links: - links[link_type] = record_links[link_type] + if link_type in entity_links: + links[link_type] = entity_links[link_type] return links class GroupEntityReferenceUIResolver(OARepoUIResolver): - def _get_id(self, result): - return result.data["id"] + """UI resolver for group entity references.""" + + @override + def _get_id(self, result: dict) -> str: + """Get the id of the entity returned from a service. - def _search_many(self, identity, values, *args, **kwargs): + :result: entity returned from a service (as a RecordItem instance) + """ + return result["id"] + + @override + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found + """ result = [] - for group in values: + for group_id in ids: try: - group = current_groups_service.read(identity, group) - result.append(group) + group: RecordItem = current_groups_service.read(identity, group_id) + result.append(group.data) except PermissionDeniedError: pass return result - def _search_one(self, identity, reference, *args, **kwargs): - value = list(reference.values())[0] + @override + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + """ try: - group = current_groups_service.read(identity, value) - return group + group: RecordItem = current_groups_service.read(identity, _id) + return group.data except PermissionDeniedError: return None - def _resolve(self, record, reference): - label = record.data["name"] - ret = { - "reference": reference, - "type": "group", - "label": label, - "links": self._resolve_links(record), - } - return ret + @override + def _get_entity_ui_representation( + self, entity: dict, reference: EntityReference + ) -> UIResolvedReference: + """Create a UI representation of a entity. + + :entity: resolved entity + :reference: reference to the entity + :return: UI representation of the entity + """ + label = entity["name"] + return UIResolvedReference( + reference=reference, + type="group", + label=label, + links=self._extract_links_from_resolved_reference(entity), + ) class UserEntityReferenceUIResolver(OARepoUIResolver): - def _get_id(self, result): - return result.data["id"] + """UI resolver for user entity references.""" - def _search_many(self, identity, values, *args, **kwargs): - result = [] - for user in values: + @override + def _get_id(self, result: RecordItem) -> str: + """Get the id of the entity returned from a service. + + :result: entity returned from a service (as a RecordItem instance) + """ + return result["id"] + + @override + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found + """ + result: list[dict] = [] + for user_id in ids: try: - user = current_users_service.read(identity, user) - result.append(user) + user: RecordItem = current_users_service.read(identity, user_id) + result.append(user.data) except PermissionDeniedError: pass return result - def _search_one(self, identity, reference, *args, **kwargs): - value = list(reference.values())[0] + @override + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + """ try: - user = current_users_service.read(identity, value) - return user + user = current_users_service.read(identity, _id) + return user.data except PermissionDeniedError: return None - def _resolve(self, record, reference): - - if record.data["id"] == "system": + @override + def _get_entity_ui_representation( + self, entity: dict, reference: EntityReference + ) -> UIResolvedReference: + """Create a UI representation of a entity. + + :entity: resolved entity + :reference: reference to the entity + :return: UI representation of the entity + """ + if entity["id"] == "system": label = _("System user") elif ( - "profile" in record.data - and "full_name" in record.data["profile"] - and record.data["profile"]["full_name"] + "profile" in entity + and "full_name" in entity["profile"] + and entity["profile"]["full_name"] ): - label = record.data["profile"]["full_name"] - elif "username" in record.data and record.data["username"]: - label = record.data["username"] + label = entity["profile"]["full_name"] + elif "username" in entity and entity["username"]: + label = entity["username"] else: label = fallback_label_result(reference) - ret = { - "reference": reference, - "type": "user", - "label": label, - "links": self._resolve_links(record), - } + ret = UIResolvedReference( + reference=reference, + type="user", + label=label, + links=self._extract_links_from_resolved_reference(entity), + ) return ret class RecordEntityReferenceUIResolver(OARepoUIResolver): - def _get_id(self, result): + """UI resolver for entity entity references.""" + + @override + def _get_id(self, result: dict) -> str: + """Get the id of the entity returned from a service. + + :result: entity returned from a service (as a RecordItem instance) + """ return result["id"] - def _search_many(self, identity, values, *args, **kwargs): + @override + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found + """ # using values instead of references breaks the pattern, perhaps it's lesser evil to construct them on go? - if not values: + if not ids: return [] # todo what if search not permitted? - service = get_matching_service_for_refdict( - {self.reference_type: list(values)[0]} - ) - filter = dsl.Q("terms", **{"id": list(values)}) - return list(service.search(identity, extra_filter=filter).hits) - - def _search_one(self, identity, reference, *args, **kwargs): - id = list(reference.values())[0] - service = get_matching_service_for_refdict(reference) - return service.read(identity, id).data - - def _resolve(self, record, reference): - if "metadata" in record and "title" in record["metadata"]: - label = record["metadata"]["title"] + service = get_matching_service_for_refdict({self.reference_type: list(ids)[0]}) + if not service: + raise ValueError( + f"No service found for handling reference type {self.reference_type}" + ) + extra_filter = dsl.Q("terms", **{"id": list(ids)}) + return service.search(identity, extra_filter=extra_filter).data["hits"]["hits"] + + @override + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + """ + service = get_matching_service_for_refdict({self.reference_type: _id}) + if not service: + raise ValueError( + f"No service found for handling reference type {self.reference_type}" + ) + return service.read(identity, _id).data + + @override + def _get_entity_ui_representation( + self, entity: dict, reference: EntityReference + ) -> UIResolvedReference: + """Create a UI representation of a entity. + + :entity: resolved entity + :reference: reference to the entity + :return: UI representation of the entity + """ + if "metadata" in entity and "title" in entity["metadata"]: + label = entity["metadata"]["title"] else: label = fallback_label_result(reference) - ret = { - "reference": reference, - "type": list(reference.keys())[0], - "label": label, - "links": self._resolve_links(record), - } + ret = UIResolvedReference( + reference=reference, + type=list(reference.keys())[0], + label=label, + links=self._extract_links_from_resolved_reference(entity), + ) return ret class RecordEntityDraftReferenceUIResolver(RecordEntityReferenceUIResolver): - def _search_many(self, identity, values, *args, **kwargs): + """UI resolver for entity entity draft references.""" + + @override + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found + """ # using values instead of references breaks the pattern, perhaps it's lesser evil to construct them on go? - if not values: + if not ids: return [] - service = get_matching_service_for_refdict( - {self.reference_type: list(values)[0]} + service: DraftsService = get_matching_service_for_refdict( + {self.reference_type: list(ids)[0]} ) - filter = dsl.Q("terms", **{"id": list(values)}) - return list(service.search_drafts(identity, extra_filter=filter).hits) - - def _search_one(self, identity, reference, *args, **kwargs): - id = list(reference.values())[0] - service = get_matching_service_for_refdict(reference) - return service.read_draft(identity, id).data + extra_filter = dsl.Q("terms", **{"id": list(ids)}) + return service.search_drafts(identity, extra_filter=extra_filter).to_dict()[ + "hits" + ]["hits"] + + @override + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + """ + service: DraftsService = get_matching_service_for_refdict( + {self.reference_type: _id} + ) + return service.read_draft(identity, _id).data class FallbackEntityReferenceUIResolver(OARepoUIResolver): - def _get_id(self, result): - if hasattr(result, "data"): - return result.data["id"] - return result["id"] + """Fallback UI resolver if no other resolver is found.""" + + @override + def _get_id(self, result: dict) -> str: + """Get the id of the entity returned from a service. - def _search_many(self, identity, values, *args, **kwargs): - """""" + :result: entity returned from a service (as a RecordItem instance) + """ + return result["id"] - def _search_one(self, identity, reference, *args, **kwargs): - id = list(reference.values())[0] + @override + def _search_many( + self, identity: Identity, ids: list[str], *args: Any, **kwargs: Any + ) -> list[dict]: + """Search for many records of the same type at once. + + :param identity: identity of the user + :param ids: ids to search for + :param args: additional arguments + :param kwargs: additional keyword arguments + :return: list of records found + """ + raise NotImplementedError("Intentionally not implemented") + + @override + def _search_one( + self, identity: Identity, _id: str, *args: Any, **kwargs: Any + ) -> dict | None: + """Search for a single entity of the same type. + + :param identity: identity of the user + :param _id: id to search for + """ + reference = {self.reference_type: _id} try: service = get_matching_service_for_refdict(reference) - except: - return fallback_result(reference) + except: # noqa - we don't care which exception has been caught, just returning fallback result + return cast(dict, fallback_result(reference)) + + if not service: + return cast(dict, fallback_result(reference)) + try: - response = service.read(identity, id) - except: - try: - response = service.read_draft(identity, id) - except: - return fallback_result(reference) + if self.reference_type.endswith("_draft"): + response = service.read_draft(identity, _id) # type: ignore + else: + response = service.read(identity, _id) + except: # noqa - we don't care which exception has been caught, just returning fallback result + return cast(dict, fallback_result(reference)) + + # TODO: should not this be "to_dict" ? if hasattr(response, "data"): response = response.data return response - def _resolve(self, record, reference): - if "metadata" in record and "title" in record["metadata"]: - label = record["metadata"]["title"] + @override + def _get_entity_ui_representation( + self, entity: dict, reference: EntityReference + ) -> UIResolvedReference: + """Create a UI representation of a entity. + + :entity: resolved entity + :reference: reference to the entity + :return: UI representation of the entity + """ + if "metadata" in entity and "title" in entity["metadata"]: + label = entity["metadata"]["title"] else: label = fallback_label_result(reference) - ret = { - "reference": reference, - "type": list(reference.keys())[0], - "label": label, - "links": self._resolve_links(record), - } - return ret + return UIResolvedReference( + reference=reference, + type=list(reference.keys())[0], + label=label, + links=self._extract_links_from_resolved_reference(entity), + ) diff --git a/oarepo_requests/resources/__init__.py b/oarepo_requests/resources/__init__.py index e69de29b..f2c018ab 100644 --- a/oarepo_requests/resources/__init__.py +++ b/oarepo_requests/resources/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API for the requests & applicable requests on drafts and published records.""" diff --git a/oarepo_requests/resources/draft/__init__.py b/oarepo_requests/resources/draft/__init__.py index e69de29b..16d98c0a 100644 --- a/oarepo_requests/resources/draft/__init__.py +++ b/oarepo_requests/resources/draft/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API for the requests & applicable requests on drafts.""" diff --git a/oarepo_requests/resources/draft/config.py b/oarepo_requests/resources/draft/config.py index 5174628a..39d579e8 100644 --- a/oarepo_requests/resources/draft/config.py +++ b/oarepo_requests/resources/draft/config.py @@ -1,7 +1,18 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Configuration of the draft record requests resource.""" + from oarepo_requests.resources.record.config import RecordRequestsResourceConfig class DraftRecordRequestsResourceConfig(RecordRequestsResourceConfig): + """Configuration of the draft record requests resource.""" + routes = { **RecordRequestsResourceConfig.routes, "list-requests-draft": "//draft/requests", diff --git a/oarepo_requests/resources/draft/resource.py b/oarepo_requests/resources/draft/resource.py index 7cec60a3..36e3d27b 100644 --- a/oarepo_requests/resources/draft/resource.py +++ b/oarepo_requests/resources/draft/resource.py @@ -1,3 +1,14 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Draft record requests resource.""" + +from __future__ import annotations + from flask import g from flask_resources import resource_requestctx, response_handler, route from invenio_records_resources.resources.records.resource import ( @@ -13,7 +24,10 @@ class DraftRecordRequestsResource(RecordRequestsResource): - def create_url_rules(self): + """Draft record requests resource.""" + + def create_url_rules(self) -> list[dict]: + """Create the URL rules for the record resource.""" old_rules = super().create_url_rules() """Create the URL rules for the record resource.""" routes = self.config.routes @@ -28,7 +42,7 @@ def create_url_rules(self): @request_search_args @request_view_args @response_handler(many=True) - def search_requests_for_draft(self): + def search_requests_for_draft(self) -> tuple[dict, int]: """Perform a search over the items.""" hits = self.service.search_requests_for_draft( identity=g.identity, @@ -43,7 +57,7 @@ def search_requests_for_draft(self): @request_view_args @request_data @response_handler() - def create_for_draft(self): + def create_for_draft(self) -> tuple[dict, int]: """Create an item.""" items = self.service.create_for_draft( identity=g.identity, diff --git a/oarepo_requests/resources/draft/types/__init__.py b/oarepo_requests/resources/draft/types/__init__.py index e69de29b..15ee0cb3 100644 --- a/oarepo_requests/resources/draft/types/__init__.py +++ b/oarepo_requests/resources/draft/types/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API for applicable request types for a draft record.""" diff --git a/oarepo_requests/resources/draft/types/config.py b/oarepo_requests/resources/draft/types/config.py index fb88d609..bf85ab49 100644 --- a/oarepo_requests/resources/draft/types/config.py +++ b/oarepo_requests/resources/draft/types/config.py @@ -1,9 +1,20 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Draft request types resource configuration.""" + from oarepo_requests.resources.record.types.config import ( RecordRequestTypesResourceConfig, ) class DraftRequestTypesResourceConfig(RecordRequestTypesResourceConfig): + """Draft request types resource configuration.""" + routes = { **RecordRequestTypesResourceConfig.routes, "list-applicable-requests-draft": "//draft/requests/applicable", diff --git a/oarepo_requests/resources/draft/types/resource.py b/oarepo_requests/resources/draft/types/resource.py index a59ea91b..db1b9d16 100644 --- a/oarepo_requests/resources/draft/types/resource.py +++ b/oarepo_requests/resources/draft/types/resource.py @@ -1,3 +1,14 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API resource for applicable request types for a draft record.""" + +from __future__ import annotations + from flask import g from flask_resources import resource_requestctx, response_handler, route from invenio_records_resources.resources.records.resource import request_view_args @@ -6,7 +17,10 @@ class DraftRequestTypesResource(RecordRequestTypesResource): - def create_url_rules(self): + """API resource for applicable request types for a draft record.""" + + def create_url_rules(self) -> list[dict]: + """Create the URL rules for the record resource.""" old_rules = super().create_url_rules() """Create the URL rules for the record resource.""" routes = self.config.routes @@ -22,9 +36,12 @@ def create_url_rules(self): @request_view_args @response_handler(many=True) - def get_applicable_request_types_for_draft(self): + def get_applicable_request_types_for_draft(self) -> tuple[dict, int]: """List request types.""" - hits = self.service.get_applicable_request_types_for_draft( + # TODO: split the resource to service-agnostic part (just the configuration) + # and service-dependent part (the actual service) + # this will then allow removing the type: ignore below + hits = self.service.get_applicable_request_types_for_draft_record( # type: ignore identity=g.identity, record_id=resource_requestctx.view_args["pid_value"], ) diff --git a/oarepo_requests/resources/events/__init__.py b/oarepo_requests/resources/events/__init__.py index e69de29b..3f2b5312 100644 --- a/oarepo_requests/resources/events/__init__.py +++ b/oarepo_requests/resources/events/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API for events on requests.""" diff --git a/oarepo_requests/resources/events/config.py b/oarepo_requests/resources/events/config.py index 7a238d4f..534242a4 100644 --- a/oarepo_requests/resources/events/config.py +++ b/oarepo_requests/resources/events/config.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Resource configuration for events and comments.""" + from flask_resources import ResponseHandler from invenio_records_resources.services.base.config import ConfiguratorMixin from invenio_requests.resources.events.config import RequestCommentsResourceConfig @@ -8,6 +17,8 @@ class OARepoRequestsCommentsResourceConfig( RequestCommentsResourceConfig, ConfiguratorMixin ): + """Resource configuration for comments.""" + blueprint_name = "oarepo_request_events" url_prefix = "/requests" routes = { @@ -18,7 +29,11 @@ class OARepoRequestsCommentsResourceConfig( } @property - def response_handlers(self): + def response_handlers(self) -> dict[str, ResponseHandler]: + """Get response handlers. + + :return: Response handlers (dict of content-type -> handler) + """ return { "application/vnd.inveniordm.v1+json": ResponseHandler( OARepoRequestEventsUIJSONSerializer() diff --git a/oarepo_requests/resources/events/resource.py b/oarepo_requests/resources/events/resource.py index 884dd35d..5b75b0c8 100644 --- a/oarepo_requests/resources/events/resource.py +++ b/oarepo_requests/resources/events/resource.py @@ -1,10 +1,21 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Resource for request events/comments that lives on the extended url.""" + from flask_resources import route from invenio_records_resources.resources.errors import ErrorHandlersMixin from invenio_requests.resources.events.resource import RequestCommentsResource class OARepoRequestsCommentsResource(RequestCommentsResource, ErrorHandlersMixin): - def create_url_rules(self): + """OARepo extensions to invenio requests comments resource.""" + + def create_url_rules(self) -> list[dict]: """Create the URL rules for the record resource.""" base_routes = super().create_url_rules() routes = self.config.routes @@ -19,17 +30,22 @@ def create_url_rules(self): return url_rules + base_routes # from parent - def create_extended(self): + def create_extended(self) -> tuple[dict, int]: + """Create a new comment.""" return super().create() - def read_extended(self): + def read_extended(self) -> tuple[dict, int]: + """Read a comment.""" return super().read() - def update_extended(self): + def update_extended(self) -> tuple[dict, int]: + """Update a comment.""" return super().update() - def delete_extended(self): + def delete_extended(self) -> tuple[dict, int]: + """Delete a comment.""" return super().delete() - def search_extended(self): + def search_extended(self) -> tuple[dict, int]: + """Search for comments.""" return super().search() diff --git a/oarepo_requests/resources/oarepo/__init__.py b/oarepo_requests/resources/oarepo/__init__.py index e69de29b..4bbe5ee8 100644 --- a/oarepo_requests/resources/oarepo/__init__.py +++ b/oarepo_requests/resources/oarepo/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""OARepo extended request API.""" diff --git a/oarepo_requests/resources/oarepo/config.py b/oarepo_requests/resources/oarepo/config.py index 77c6f823..5aaaa5d7 100644 --- a/oarepo_requests/resources/oarepo/config.py +++ b/oarepo_requests/resources/oarepo/config.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Config for the extended requests API.""" + from flask_resources import ResponseHandler from invenio_records_resources.services.base.config import ConfiguratorMixin from invenio_requests.resources import RequestsResourceConfig @@ -6,7 +15,7 @@ class OARepoRequestsResourceConfig(RequestsResourceConfig, ConfiguratorMixin): - """""" + """Config for the extended requests API.""" blueprint_name = "oarepo-requests" url_prefix = "/requests" @@ -18,7 +27,8 @@ class OARepoRequestsResourceConfig(RequestsResourceConfig, ConfiguratorMixin): } @property - def response_handlers(self): + def response_handlers(self) -> dict[str, ResponseHandler]: + """Response handlers for the extended requests API.""" return { "application/vnd.inveniordm.v1+json": ResponseHandler( OARepoRequestsUIJSONSerializer() diff --git a/oarepo_requests/resources/oarepo/resource.py b/oarepo_requests/resources/oarepo/resource.py index c59396b7..5d3f7676 100644 --- a/oarepo_requests/resources/oarepo/resource.py +++ b/oarepo_requests/resources/oarepo/resource.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""OARepo extensions to invenio requests resource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import g from flask_resources import resource_requestctx, response_handler, route from invenio_records_resources.resources.errors import ErrorHandlersMixin @@ -12,22 +25,31 @@ from oarepo_requests.utils import resolve_reference_dict, stringify_first_val +if TYPE_CHECKING: + from invenio_requests.services.requests import RequestsService + + from ...services.oarepo.service import OARepoRequestsService + from .config import OARepoRequestsResourceConfig + class OARepoRequestsResource(RequestsResource, ErrorHandlersMixin): + """OARepo extensions to invenio requests resource.""" + def __init__( self, - config, - oarepo_requests_service, - invenio_requests_service=current_requests_service, - ): + config: OARepoRequestsResourceConfig, + oarepo_requests_service: OARepoRequestsService, + invenio_requests_service: RequestsService = current_requests_service, + ) -> None: + """Initialize the service.""" # so super methods can be used with original service super().__init__(config, invenio_requests_service) self.oarepo_requests_service = oarepo_requests_service - def create_url_rules(self): + def create_url_rules(self) -> list[dict]: """Create the URL rules for the record resource.""" - def p(route): + def p(route: str) -> str: """Prefix a route with the URL prefix.""" return f"{self.config.url_prefix}{route}" @@ -51,7 +73,8 @@ def p(route): @request_view_args @request_data @response_handler() - def update(self): + def update(self) -> tuple[dict, int]: + """Update a request with a new payload.""" item = self.oarepo_requests_service.update( id_=resource_requestctx.view_args["id"], identity=g.identity, @@ -65,7 +88,20 @@ def update(self): @request_headers @request_data @response_handler() - def create(self): + def create(self) -> tuple[dict, int]: + """Create a new request based on a request type. + + The data is in the form of: + .. code-block:: json + { + "request_type": "request_type", + "topic": { + "type": "pid", + "value": "value" + }, + ...payload + } + """ # request_type = resource_requestctx.data.pop("request_type", None) # topic = stringify_first_val(resource_requestctx.data.pop("topic", None)) # resolved_topic = resolve_reference_dict(topic) @@ -89,8 +125,8 @@ def create(self): @request_extra_args @request_view_args @response_handler() - def read_extended(self): - """Read an item.""" + def read_extended(self) -> tuple[dict, int]: + """Read a request on this url.""" item = self.oarepo_requests_service.read( id_=resource_requestctx.view_args["id"], identity=g.identity, diff --git a/oarepo_requests/resources/record/__init__.py b/oarepo_requests/resources/record/__init__.py index e69de29b..a53cca56 100644 --- a/oarepo_requests/resources/record/__init__.py +++ b/oarepo_requests/resources/record/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Record request an applicable requests API resource.""" diff --git a/oarepo_requests/resources/record/config.py b/oarepo_requests/resources/record/config.py index efe2b6a5..bfc702d7 100644 --- a/oarepo_requests/resources/record/config.py +++ b/oarepo_requests/resources/record/config.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Configuration of the record requests resource.""" + import marshmallow as ma from flask_resources import JSONSerializer, ResponseHandler from invenio_records_resources.resources import RecordResourceConfig @@ -7,6 +16,16 @@ class RecordRequestsResourceConfig: + """Configuration of the record requests resource. + + This configuration is merged with the configuration of a record on top of which + the requests resource lives. + """ + + blueprint_name: str | None = ( + None # will be merged from the record's resource config + ) + routes = { "list-requests": "//requests", "request-type": "//requests/", @@ -16,7 +35,8 @@ class RecordRequestsResourceConfig: } @property - def response_handlers(self): + def response_handlers(self) -> dict[str, ResponseHandler]: + """Response handlers for the record requests resource.""" return { "application/vnd.inveniordm.v1+json": ResponseHandler( OARepoRequestsUIJSONSerializer() diff --git a/oarepo_requests/resources/record/resource.py b/oarepo_requests/resources/record/resource.py index e160e062..ccd961f3 100644 --- a/oarepo_requests/resources/record/resource.py +++ b/oarepo_requests/resources/record/resource.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API resource for serving record requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import g from flask_resources import resource_requestctx, response_handler, route from invenio_records_resources.resources import RecordResource @@ -11,10 +24,24 @@ from oarepo_requests.utils import merge_resource_configs +if TYPE_CHECKING: + from invenio_records_resources.resources.records import RecordResourceConfig + + from ...services.record.service import RecordRequestsService + from .config import RecordRequestsResourceConfig + class RecordRequestsResource(RecordResource): - def __init__(self, record_requests_config, config, service): - """ + """API resource for serving record requests.""" + + def __init__( + self, + record_requests_config: RecordRequestsResourceConfig, + config: RecordResourceConfig, + service: RecordRequestsService, + ) -> None: + """Initialize the service. + :param config: main record resource config :param service: :param record_requests_config: config specific for the record request serivce @@ -25,7 +52,7 @@ def __init__(self, record_requests_config, config, service): ) super().__init__(actual_config, service) - def create_url_rules(self): + def create_url_rules(self) -> list[dict]: """Create the URL rules for the record resource.""" routes = self.config.routes @@ -39,7 +66,7 @@ def create_url_rules(self): @request_search_args @request_view_args @response_handler(many=True) - def search_requests_for_record(self): + def search_requests_for_record(self) -> tuple[dict, int]: """Perform a search over the items.""" hits = self.service.search_requests_for_record( identity=g.identity, @@ -54,7 +81,7 @@ def search_requests_for_record(self): @request_view_args @request_data @response_handler() - def create(self): + def create(self) -> tuple[dict, int]: """Create an item.""" items = self.service.create( identity=g.identity, diff --git a/oarepo_requests/resources/record/types/__init__.py b/oarepo_requests/resources/record/types/__init__.py index e69de29b..21da6d3f 100644 --- a/oarepo_requests/resources/record/types/__init__.py +++ b/oarepo_requests/resources/record/types/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API Resource for record request types.""" diff --git a/oarepo_requests/resources/record/types/config.py b/oarepo_requests/resources/record/types/config.py index 10168d5f..37a7f71a 100644 --- a/oarepo_requests/resources/record/types/config.py +++ b/oarepo_requests/resources/record/types/config.py @@ -1,3 +1,14 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Config for applicable request types for a record resource.""" + +from __future__ import annotations + from flask_resources import JSONSerializer, ResponseHandler from invenio_records_resources.resources.records.headers import etag_headers @@ -5,12 +16,23 @@ class RecordRequestTypesResourceConfig: + """Config for applicable request types for a record resource. + + Note: this config is merged with the configuration of a record on top of which + the request types resource lives. + """ + + blueprint_name: str | None = ( + None # will be merged from the record's resource config + ) + routes = { "list-applicable-requests": "//requests/applicable", } @property - def response_handlers(self): + def response_handlers(self) -> dict[str, ResponseHandler]: + """Response handlers for the record request types resource.""" return { "application/vnd.inveniordm.v1+json": ResponseHandler( OARepoRequestTypesUIJSONSerializer() diff --git a/oarepo_requests/resources/record/types/resource.py b/oarepo_requests/resources/record/types/resource.py index ce5c833a..247263a2 100644 --- a/oarepo_requests/resources/record/types/resource.py +++ b/oarepo_requests/resources/record/types/resource.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API resource for applicable request types for a record.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import g from flask_resources import resource_requestctx, response_handler, route from flask_resources.resources import Resource @@ -6,10 +19,26 @@ from oarepo_requests.utils import merge_resource_configs +if TYPE_CHECKING: + from invenio_records_resources.resources.records import RecordResourceConfig + + from ....services.record.types.service import RecordRequestTypesService + from .config import RecordRequestTypesResourceConfig + +# TODO: is this class used? + class RecordRequestTypesResource(ErrorHandlersMixin, Resource): - def __init__(self, record_requests_config, config, service): - """ + """API resource for applicable request types for a record.""" + + def __init__( + self, + record_requests_config: RecordRequestTypesResourceConfig, + config: RecordResourceConfig, + service: RecordRequestTypesService, + ) -> None: + """Initialize the resource. + :param config: main record resource config :param service: :param record_requests_config: config specific for the record request serivce @@ -23,7 +52,7 @@ def __init__(self, record_requests_config, config, service): super().__init__(actual_config) self.service = service - def create_url_rules(self): + def create_url_rules(self) -> list[dict]: """Create the URL rules for the record resource.""" routes = self.config.routes @@ -38,9 +67,9 @@ def create_url_rules(self): @request_view_args @response_handler(many=True) - def get_applicable_request_types(self): + def get_applicable_request_types(self) -> tuple[dict, int]: """List request types.""" - hits = self.service.get_applicable_request_types( + hits = self.service.get_applicable_request_types_for_published_record( identity=g.identity, record_id=resource_requestctx.view_args["pid_value"], ) diff --git a/oarepo_requests/resources/ui.py b/oarepo_requests/resources/ui.py index 22e113d0..33558d5a 100644 --- a/oarepo_requests/resources/ui.py +++ b/oarepo_requests/resources/ui.py @@ -1,4 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Serializers for the UI schema.""" + +from __future__ import annotations + from collections import defaultdict +from typing import TYPE_CHECKING, Any, cast from flask import g from flask_resources import BaseListSchema @@ -15,14 +27,23 @@ ) from ..utils import reference_to_tuple +if TYPE_CHECKING: + from flask_principal import Identity + + +def _reference_map_from_list(obj_list: list[dict]) -> dict[str, set]: + """Create a map of references from a list of requests from opensearch. + + For each of the serialized requests in the list, extract the fields that represent + references to other entities and create a map of entity type to a set of identifiers. -def _reference_map_from_list(obj_list): + The fields are determined by the REQUESTS_UI_SERIALIZATION_REFERENCED_FIELDS config key. + """ if not obj_list: return {} - hits = obj_list["hits"]["hits"] reference_map = defaultdict(set) reference_types = current_oarepo_requests.ui_serialization_referenced_fields - for hit in hits: + for hit in obj_list: for reference_type in reference_types: if reference_type in hit: reference = hit[reference_type] @@ -32,34 +53,61 @@ def _reference_map_from_list(obj_list): return reference_map -def _create_cache(identity, reference_map): - cache = {} +def _create_cache( + identity: Identity, reference_map: dict[str, set[str]] +) -> dict[tuple[str, str], dict]: + """Create a cache of resolved references. + + For each of the (entity_type, entity_ids) pairs in the reference_map, resolve the references + using the entity resolvers and create a cache of the resolved references. + + This call uses resolve_many to extract all references of the same type. + """ + cache: dict[tuple[str, str], dict] = {} entity_resolvers = current_oarepo_requests.entity_reference_ui_resolvers for reference_type, values in reference_map.items(): if reference_type in entity_resolvers: resolver = entity_resolvers[reference_type] - results = resolver.resolve_many(identity, values) + results = resolver.resolve_many(identity, list(values)) # we are expecting "reference" in results cache_for_type = { - reference_to_tuple(result["reference"]): result for result in results + reference_to_tuple(result["reference"]): cast(dict, result) + for result in results } cache |= cache_for_type return cache class CachedReferenceResolver: - def __init__(self, identity, references): - reference_map = _reference_map_from_list(references) + """Cached reference resolver.""" + + def __init__(self, identity: Identity, serialized_requests: list[dict]) -> None: + """Initialise the resolver. + + The references is a dictionary of requests in opensearch list format + """ + reference_map = _reference_map_from_list(serialized_requests) self._cache = _create_cache(identity, reference_map) self._identity = identity - def dereference(self, reference, **kwargs): + def dereference(self, reference: dict, **context: Any) -> dict: + """Dereference a reference. + + If the reference is in the cache, return the cached value. + Otherwise, resolve the reference and return the resolved value. + + The resolved value has a "reference" key and the rest is the + dereferenced dict given from ui entity resolver. + + :param reference: reference to resolve + :param context: additional context + """ key = reference_to_tuple(reference) if key in self._cache: return self._cache[key] else: try: - return resolve(self._identity, reference) + return cast(dict, resolve(self._identity, reference)) except PIDDeletedError: return {"reference": reference, "status": "deleted"} @@ -67,7 +115,7 @@ def dereference(self, reference, **kwargs): class OARepoRequestsUIJSONSerializer(LocalizedUIJSONSerializer): """UI JSON serializer.""" - def __init__(self): + def __init__(self) -> None: """Initialise Serializer.""" super().__init__( format_serializer_cls=JSONSerializer, @@ -76,24 +124,36 @@ def __init__(self): schema_context={"object_key": "ui", "identity": g.identity}, ) - def dump_obj(self, obj, *args, **kwargs): - # do not create; there's no benefit for caching single objects now + def dump_obj(self, obj: Any, *args: Any, **kwargs: Any) -> dict: + """Dump a single object. + + Do not create a cache for single objects as there is no performance boost by doing so. + """ extra_context = { "resolved": CachedReferenceResolver(self.schema_context["identity"], []) } return super().dump_obj(obj, *args, extra_context=extra_context, **kwargs) - def dump_list(self, obj_list, *args, **kwargs): + def dump_list(self, obj_list: dict, *args: Any, **kwargs: Any) -> list[dict]: + """Dump a list of objects. + + This call at first creates a cache of resolved references which is used later on + to serialize them. + + :param obj_list: objects to serialize in opensearch list format + """ extra_context = { "resolved": CachedReferenceResolver( - self.schema_context["identity"], obj_list + self.schema_context["identity"], obj_list["hits"]["hits"] ) } return super().dump_list(obj_list, *args, extra_context=extra_context, **kwargs) class OARepoRequestEventsUIJSONSerializer(LocalizedUIJSONSerializer): - def __init__(self): + """UI JSON serializer for request events.""" + + def __init__(self) -> None: """Initialise Serializer.""" super().__init__( format_serializer_cls=JSONSerializer, @@ -104,7 +164,9 @@ def __init__(self): class OARepoRequestTypesUIJSONSerializer(LocalizedUIJSONSerializer): - def __init__(self): + """UI JSON serializer for request types.""" + + def __init__(self) -> None: """Initialise Serializer.""" super().__init__( format_serializer_cls=JSONSerializer, @@ -113,14 +175,13 @@ def __init__(self): schema_context={"object_key": "ui", "identity": g.identity}, ) - def dump_obj(self, obj, *args, **kwargs): - if hasattr(obj, "topic"): - extra_context = {"topic": obj.topic} - else: - extra_context = {} + def dump_obj(self, obj: Any, *args: Any, **kwargs: Any) -> dict: + """Dump a single object.""" + extra_context = {"topic": obj.topic} if hasattr(obj, "topic") else {} return super().dump_obj(obj, *args, extra_context=extra_context, **kwargs) - def dump_list(self, obj_list, *args, **kwargs): + def dump_list(self, obj_list: dict, *args: Any, **kwargs: Any) -> list[dict]: + """Dump a list of objects.""" return super().dump_list( obj_list, *args, diff --git a/oarepo_requests/services/__init__.py b/oarepo_requests/services/__init__.py index e69de29b..e4d620af 100644 --- a/oarepo_requests/services/__init__.py +++ b/oarepo_requests/services/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Service layer.""" diff --git a/oarepo_requests/services/draft/__init__.py b/oarepo_requests/services/draft/__init__.py index e69de29b..b31ca9d0 100644 --- a/oarepo_requests/services/draft/__init__.py +++ b/oarepo_requests/services/draft/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Service layer for requests on draft records.""" diff --git a/oarepo_requests/services/draft/service.py b/oarepo_requests/services/draft/service.py index 67c129be..190019b2 100644 --- a/oarepo_requests/services/draft/service.py +++ b/oarepo_requests/services/draft/service.py @@ -1,27 +1,51 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Draft record requests service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_records_resources.services.uow import unit_of_work from invenio_search.engine import dsl from oarepo_requests.services.record.service import RecordRequestsService -from oarepo_requests.utils import get_type_id_for_record_cls +from oarepo_requests.utils import get_entity_key_for_record_cls + +if TYPE_CHECKING: + from datetime import datetime + + from flask_principal import Identity + from invenio_drafts_resources.records.api import Record + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.services.requests.results import RequestItem + from opensearch_dsl.query import Query class DraftRecordRequestsService(RecordRequestsService): + """Draft record requests service.""" + @property - def draft_cls(self): + def draft_cls(self) -> type[Record]: """Factory for creating a record class.""" return self.record_service.config.draft_cls # from invenio_rdm_records.services.requests.service.RecordRequestsService def search_requests_for_draft( self, - identity, - record_id, - params=None, - search_preference=None, - expand=False, - extra_filter=None, - **kwargs, - ): + identity: Identity, + record_id: str, + params: dict[str, str] | None = None, + search_preference: Any = None, + expand: bool = False, + extra_filter: Query | None = None, + **kwargs: Any, + ) -> RequestItem: """Search for record's requests.""" record = self.draft_cls.pid.resolve(record_id, registered_only=False) self.record_service.require_permission(identity, "read_draft", record=record) @@ -32,7 +56,7 @@ def search_requests_for_draft( dsl.Q( "term", **{ - f"topic.{get_type_id_for_record_cls(self.draft_cls)}": record_id + f"topic.{get_entity_key_for_record_cls(self.draft_cls)}": record_id }, ), ], @@ -52,14 +76,15 @@ def search_requests_for_draft( @unit_of_work() def create_for_draft( self, - identity, - data, - request_type, - topic_id, - expires_at=None, - uow=None, - expand=False, - ): + identity: Identity, + data: dict, + request_type: str, + topic_id: str, + expires_at: datetime | None = None, + uow: UnitOfWork | None = None, + expand: bool = False, + ) -> RequestItem: + """Create a request on a draft record.""" record = self.draft_cls.pid.resolve(topic_id, registered_only=False) return self.oarepo_requests_service.create( identity=identity, diff --git a/oarepo_requests/services/draft/types/__init__.py b/oarepo_requests/services/draft/types/__init__.py index e69de29b..c737fe36 100644 --- a/oarepo_requests/services/draft/types/__init__.py +++ b/oarepo_requests/services/draft/types/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Service returning applicable request types for a record.""" diff --git a/oarepo_requests/services/draft/types/service.py b/oarepo_requests/services/draft/types/service.py index 7c32b5ff..e394604d 100644 --- a/oarepo_requests/services/draft/types/service.py +++ b/oarepo_requests/services/draft/types/service.py @@ -1,12 +1,39 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Draft record request types service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from oarepo_requests.services.record.types.service import RecordRequestTypesService +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_drafts_resources.records.api import Record + + from oarepo_requests.services.results import RequestTypesList + class DraftRecordRequestTypesService(RecordRequestTypesService): + """Draft record request types service. + + This service sits on /model/draft/id/applicable-requests endpoint and provides a list of request types. + """ + @property - def draft_cls(self): + def draft_cls(self) -> type[Record]: """Factory for creating a record class.""" return self.record_service.config.draft_cls - def get_applicable_request_types_for_draft(self, identity, record_id): + def get_applicable_request_types_for_draft_record( + self, identity: Identity, record_id: str + ) -> RequestTypesList: + """Return applicable request types for a draft record.""" record = self.draft_cls.pid.resolve(record_id, registered_only=False) return self._get_applicable_request_types(identity, record) diff --git a/oarepo_requests/services/oarepo/__init__.py b/oarepo_requests/services/oarepo/__init__.py index e69de29b..8707cdaa 100644 --- a/oarepo_requests/services/oarepo/__init__.py +++ b/oarepo_requests/services/oarepo/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""OARepo requests service.""" diff --git a/oarepo_requests/services/oarepo/config.py b/oarepo_requests/services/oarepo/config.py index eee89aff..e19fdc80 100644 --- a/oarepo_requests/services/oarepo/config.py +++ b/oarepo_requests/services/oarepo/config.py @@ -1,52 +1,89 @@ -from invenio_requests.records.api import Request +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Configuration for the oarepo request service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, cast + from invenio_requests.services import RequestsServiceConfig from invenio_requests.services.requests import RequestLink from oarepo_requests.resolvers.ui import resolve +if TYPE_CHECKING: + from invenio_requests.records.api import Request + class RequestEntityLink(RequestLink): - def __init__(self, uritemplate, when=None, vars=None, entity="topic"): + """Link to an entity within a request.""" + + def __init__( + self, + uritemplate: str, + when: Callable | None = None, + vars: dict | None = None, + entity: str = "topic", + ) -> None: + """Create a new link.""" super().__init__(uritemplate, when, vars) self.entity = entity - def vars(self, record: Request, vars): + def vars(self, record: Request, vars: dict) -> dict: + """Expand the vars with the entity.""" super().vars(record, vars) entity = self._resolve(record, vars) self._expand_entity(entity, vars) return vars - def should_render(self, obj, ctx): + def should_render(self, obj: Request, ctx: dict[str, Any]) -> bool: + """Check if the link should be rendered.""" if not super().should_render(obj, ctx): return False - if self.expand(obj, ctx): - return True + return bool(self.expand(obj, ctx)) - def _resolve(self, obj, ctx): - reference_dict = getattr(obj, self.entity).reference_dict + def _resolve(self, obj: Request, ctx: dict[str, Any]) -> dict: + """Resolve the entity and put it into the context cache. + + :param obj: Request object + :param ctx: Context cache + :return: The resolved entity + """ + reference_dict: dict = getattr(obj, self.entity).reference_dict key = "entity:" + ":".join( f"{x[0]}:{x[1]}" for x in sorted(reference_dict.items()) ) if key in ctx: return ctx[key] try: - entity = resolve(ctx["identity"], reference_dict) + entity = cast(dict, resolve(ctx["identity"], reference_dict)) except Exception: # noqa entity = {} ctx[key] = entity return entity - def _expand_entity(self, entity, vars): + def _expand_entity(self, entity: Any, vars: dict) -> None: + """Expand the entity links into the vars.""" vars.update({f"entity_{k}": v for k, v in entity.get("links", {}).items()}) - def expand(self, obj, context): + def expand(self, obj: Request, context: dict[str, Any]) -> str: """Expand the URI Template.""" # Optimization: pre-resolve the entity and put it into the shared context # under the key - so that it can be reused by other links self._resolve(obj, context) + + # now expand the link return super().expand(obj, context) + class OARepoRequestsServiceConfig(RequestsServiceConfig): + """Configuration for the oarepo request service.""" + service_id = "oarepo_requests" links_item = { diff --git a/oarepo_requests/services/oarepo/service.py b/oarepo_requests/services/oarepo/service.py index 566aec86..0799ef0d 100644 --- a/oarepo_requests/services/oarepo/service.py +++ b/oarepo_requests/services/oarepo/service.py @@ -1,4 +1,16 @@ -from invenio_records.api import RecordBase +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""OARepo extension to invenio-requests service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_records_resources.services.uow import IndexRefreshOp, unit_of_work from invenio_requests import current_request_type_registry from invenio_requests.services import RequestsService @@ -6,23 +18,49 @@ from oarepo_requests.errors import UnknownRequestType from oarepo_requests.proxies import current_oarepo_requests +if TYPE_CHECKING: + from datetime import datetime + + from flask_principal import Identity + from invenio_records.api import RecordBase + from invenio_records_resources.services.uow import UnitOfWork + from invenio_requests.services.requests.results import RequestItem + + from oarepo_requests.typing import EntityReference + class OARepoRequestsService(RequestsService): + """OARepo extension to invenio-requests service.""" + @unit_of_work() def create( self, - identity, - data, - request_type, - receiver=None, - creator=None, + identity: Identity, + data: dict, + request_type: str, + receiver: EntityReference | Any | None = None, + creator: EntityReference | Any | None = None, topic: RecordBase = None, - expires_at=None, - uow=None, - expand=False, - *args, - **kwargs, - ): + expires_at: datetime | None = None, + uow: UnitOfWork = None, + expand: bool = False, + *args: Any, + **kwargs: Any, + ) -> RequestItem: + """Create a request. + + :param identity: Identity of the user creating the request. + :param data: Data of the request. + :param request_type: Type of the request. + :param receiver: Receiver of the request. If unfilled, a default receiver from workflow is used. + :param creator: Creator of the request. + :param topic: Topic of the request. + :param expires_at: Expiration date of the request. + :param uow: Unit of work. + :param expand: Expand the response. + :param args: Additional arguments. + :param kwargs: Additional keyword arguments. + """ type_ = current_request_type_registry.lookup(request_type, quiet=True) if not type_: raise UnknownRequestType(request_type) @@ -57,12 +95,23 @@ def create( ) return result - def read(self, identity, id_, expand=False): + def read(self, identity: Identity, id_: str, expand: bool = False) -> RequestItem: + """Retrieve a request.""" api_request = super().read(identity, id_, expand) return api_request @unit_of_work() - def update(self, identity, id_, data, revision_id=None, uow=None, expand=False): + def update( + self, + identity: Identity, + id_: str, + data: dict, + revision_id: int | None = None, + uow: UnitOfWork | None = None, + expand: bool = False, + ) -> RequestItem: + """Update a request.""" + assert uow is not None result = super().update( identity, id_, data, revision_id=revision_id, uow=uow, expand=expand ) diff --git a/oarepo_requests/services/permissions/__init__.py b/oarepo_requests/services/permissions/__init__.py index e69de29b..b4967f78 100644 --- a/oarepo_requests/services/permissions/__init__.py +++ b/oarepo_requests/services/permissions/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Permissions for requests service.""" diff --git a/oarepo_requests/services/permissions/generators.py b/oarepo_requests/services/permissions/generators.py deleted file mode 100644 index cefe46c0..00000000 --- a/oarepo_requests/services/permissions/generators.py +++ /dev/null @@ -1,212 +0,0 @@ -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.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 -from sqlalchemy.exc import NoResultFound - -from oarepo_requests.errors import MissingTopicError -from oarepo_requests.services.permissions.identity import request_active - - -class RequestActive(Generator): - def needs(self, **kwargs): - return [request_active] - - def query_filter(self, identity=None, **kwargs): - return dsl.Q("match_none") - - -class IfRequestType(ConditionalGenerator): - def __init__(self, request_types, then_): - super().__init__(then_, else_=[]) - if not isinstance(request_types, (list, tuple)): - request_types = [request_types] - self.request_types = request_types - - 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_ - super().__init__(then_, else_=else_) - if not isinstance(event_types, (list, tuple)): - event_types = [event_types] - self.event_types = event_types - - def _condition(self, event_type, **kwargs): - return event_type.type_id in self.event_types - - -try: - from oarepo_workflows import WorkflowPermission - from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError - from oarepo_workflows.proxies import current_oarepo_workflows - - class RequestPolicyWorkflowCreators(WorkflowPermission): - # - - def _getter(self, **kwargs): - raise NotImplementedError() - - def _kwargs_parser(self, **kwargs): - return kwargs - - # return empty needs on MissingTopicError - # match None in query filter - # excludes empty needs - def needs(self, **kwargs): - try: - kwargs = self._kwargs_parser(**kwargs) - workflow_request = self._getter(**kwargs) - return workflow_request.needs(**kwargs) - except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): - return [] - - def excludes(self, **kwargs): - try: - kwargs = self._kwargs_parser(**kwargs) - workflow_request = self._getter(**kwargs) - return workflow_request.excludes(**kwargs) - except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): - return [] - - # not tested - def query_filter(self, record=None, request_type=None, **kwargs): - try: - workflow_request = current_oarepo_workflows.get_workflow( - record - ).requests()[request_type.type_id] - return workflow_request.query_filters( - request_type=request_type, record=record, **kwargs - ) - except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): - return dsl.Q("match_none") - - class RequestCreatorsFromWorkflow(RequestPolicyWorkflowCreators): - def _getter(self, **kwargs): - request_type = kwargs["request_type"] - if "record" not in kwargs: - raise MissingTopicError( - "Topic not found in request permissions generator arguments, can't get workflow." - ) - record = kwargs["record"] - return current_oarepo_workflows.get_workflow(record).requests()[ - request_type.type_id - ] - - class EventCreatorsFromWorkflow(RequestPolicyWorkflowCreators): - def _kwargs_parser(self, **kwargs): - try: - record = kwargs[ - "request" - ].topic.resolve() # publish tries to resolve deleted draft - except: - raise MissingTopicError( - "Topic not found in request event permissions generator arguments, can't get workflow." - ) - kwargs["record"] = record - return kwargs - - def _getter(self, **kwargs): - if "record" not in kwargs: - return None - event_type = kwargs["event_type"] - request = kwargs["request"] - record = kwargs["record"] - return ( - current_oarepo_workflows.get_workflow(record) - .requests()[request.type.type_id] - .allowed_events[event_type.type_id] - ) - -except ImportError: - pass - - -class IfRequestedBy(RecipientGeneratorMixin, ConditionalGenerator): - def __init__(self, requesters, then_, else_): - super().__init__(then_, else_) - if not isinstance(requesters, (list, tuple)): - requesters = [requesters] - self.requesters = requesters - - def _condition(self, *, request_type, creator, **kwargs): - """Condition to choose generators set.""" - # get needs - if isinstance(creator, Identity): - needs = creator.provides - else: - if not isinstance(creator, EntityProxy): - creator = ResolverRegistry.reference_entity(creator) - needs = creator.get_needs() - - for condition in self.requesters: - condition_needs = set( - condition.needs(request_type=request_type, creator=creator, **kwargs) - ) - condition_excludes = set( - condition.excludes(request_type=request_type, creator=creator, **kwargs) - ) - - if not condition_needs.intersection(needs): - continue - if condition_excludes and condition_excludes.intersection(needs): - continue - return True - return False - - def reference_receivers(self, record=None, request_type=None, **kwargs): - ret = [] - for gen in self._generators(record=record, request_type=request_type, **kwargs): - if isinstance(gen, RecipientGeneratorMixin): - ret.extend( - gen.reference_receivers( - record=record, request_type=request_type, **kwargs - ) - ) - return ret - - def query_filter(self, **kwargs): - """Search filters.""" - raise NotImplementedError( - "Please use IfRequestedBy only in recipients, not elsewhere." - ) - - -class IfNoNewVersionDraft(ConditionalGenerator): - def __init__(self, then_, else_=None): - else_ = [] if else_ is None else else_ - super().__init__(then_, else_=else_) - - def _condition(self, record, **kwargs): - return not record.is_draft and not record.versions.next_draft_id - - -class IfNoEditDraft(ConditionalGenerator): - def __init__(self, then_, else_=None): - else_ = [] if else_ is None else else_ - super().__init__(then_, else_=else_) - - def _condition(self, record, **kwargs): - if record.is_draft: - return False - records_service = get_record_service_for_record(record) - try: - records_service.config.draft_cls.pid.resolve( - record["id"] - ) # by edit - has the same id as parent record - # i'm not sure what open unpublished means - return False - except NoResultFound: - return True diff --git a/oarepo_requests/services/permissions/generators/__init__.py b/oarepo_requests/services/permissions/generators/__init__.py new file mode 100644 index 00000000..fc966713 --- /dev/null +++ b/oarepo_requests/services/permissions/generators/__init__.py @@ -0,0 +1,30 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Permission generators.""" + +from .active import RequestActive +from .conditional import ( + IfEventOnRequestType, + IfEventType, + IfNoEditDraft, + IfNoNewVersionDraft, + IfRequestedBy, + IfRequestType, + IfRequestTypeBase, +) + +__all__ = ( + "RequestActive", + "IfEventOnRequestType", + "IfRequestType", + "IfEventType", + "IfRequestedBy", + "IfRequestTypeBase", + "IfNoEditDraft", + "IfNoNewVersionDraft", +) diff --git a/oarepo_requests/services/permissions/generators/active.py b/oarepo_requests/services/permissions/generators/active.py new file mode 100644 index 00000000..93559154 --- /dev/null +++ b/oarepo_requests/services/permissions/generators/active.py @@ -0,0 +1,36 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Generator is triggered when workflow action is being performed.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from invenio_records_permissions.generators import Generator +from opensearch_dsl.query import Query + +from oarepo_requests.services.permissions.identity import request_active + +if TYPE_CHECKING: + from flask_principal import Identity, Need + + +class RequestActive(Generator): + """A generator that requires that a request is being handled. + + This is useful for example when a caller identity should have greater permissions + when calling an action from within a request. + """ + + def needs(self, **context: Any) -> list[Need]: + """Return the needs required for the action.""" + return [request_active] + + def query_filter(self, identity: Identity = None, **context: Any) -> Query: + """Return the query filter for the action.""" + return Query("match_none") diff --git a/oarepo_requests/services/permissions/generators/conditional.py b/oarepo_requests/services/permissions/generators/conditional.py new file mode 100644 index 00000000..999f0ca2 --- /dev/null +++ b/oarepo_requests/services/permissions/generators/conditional.py @@ -0,0 +1,187 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Conditional generators for needs based on request type, event type, and requester role.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +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.resolvers.registry import ResolverRegistry +from oarepo_runtime.datastreams.utils import get_record_service_for_record +from oarepo_workflows.requests import RecipientGeneratorMixin +from oarepo_workflows.requests.generators import ( + IfEventType as WorkflowIfEventType, +) +from oarepo_workflows.requests.generators import ( + IfRequestType as WorkflowIfRequestType, +) +from oarepo_workflows.requests.generators import ( + IfRequestTypeBase, +) +from sqlalchemy.exc import NoResultFound +from typing_extensions import deprecated + +if TYPE_CHECKING: + from invenio_records_resources.records import Record + from invenio_requests.customizations import RequestType + from invenio_requests.records import Request + from opensearch_dsl.query import Query + + from oarepo_requests.typing import EntityReference + + +@deprecated("Use oarepo_workflows.requests.generators.IfEventType instead.") +class IfEventType(WorkflowIfEventType): + """Conditional generator that generates needs based on the event type. + + This class is deprecated. Use oarepo_workflows.requests.generators.IfEventType instead. + """ + + +@deprecated("Use oarepo_workflows.requests.generators.IfRequestType instead.") +class IfRequestType(WorkflowIfRequestType): + """Conditional generator that generates needs based on the request type. + + This class is deprecated. Use oarepo_workflows.requests.generators.IfRequestType instead. + """ + + +class IfEventOnRequestType(IfRequestTypeBase): + """Not sure what this is for as it seems not to be used at all.""" + + def _condition(self, request: Request, **kwargs: Any) -> bool: + return request.type.type_id in self.request_types + + +class IfRequestedBy(RecipientGeneratorMixin, ConditionalGenerator): + """Conditional generator that generates needs when a request is made by a given requester role.""" + + def __init__( + self, + requesters: list[Generator] | tuple[Generator] | Generator, + then_: list[Generator], + else_: list[Generator], + ) -> None: + """Initialize the generator.""" + super().__init__(then_, else_) + if not isinstance(requesters, (list, tuple)): + requesters = [requesters] + self.requesters = requesters + + def _condition( + self, + *, + request_type: RequestType, + creator: Identity | EntityProxy | Any, + **kwargs: Any, + ) -> bool: + """Condition to choose generators set.""" + # get needs + if isinstance(creator, Identity): + needs = creator.provides + else: + if not isinstance(creator, EntityProxy): + creator = ResolverRegistry.reference_entity(creator) + needs = creator.get_needs() + + for condition in self.requesters: + condition_needs = set( + condition.needs(request_type=request_type, creator=creator, **kwargs) + ) + condition_excludes = set( + condition.excludes(request_type=request_type, creator=creator, **kwargs) + ) + + if not condition_needs.intersection(needs): + continue + if condition_excludes and condition_excludes.intersection(needs): + continue + return True + return False + + def reference_receivers( + self, + record: Record | None = None, + request_type: RequestType | None = None, + **context: Any, + ) -> list[EntityReference]: # pragma: no cover + """Return the reference receiver(s) of the request. + + This call requires the context to contain at least "record" and "request_type" + + Must return a list of dictionary serialization of the receivers. + + Might return empty list or None to indicate that the generator does not + provide any receivers. + """ + ret = [] + for gen in self._generators( + record=record, request_type=request_type, **context + ): + if isinstance(gen, RecipientGeneratorMixin): + ret.extend( + gen.reference_receivers( + record=record, request_type=request_type, **context + ) + ) + return ret + + def query_filter(self, **context: Any) -> Query: + """Search filters.""" + raise NotImplementedError( + "Please use IfRequestedBy only in recipients, not elsewhere." + ) + + +class IfNoNewVersionDraft(ConditionalGenerator): + """Generator that checks if the record has no new version draft.""" + + def __init__( + self, then_: list[Generator], else_: list[Generator] | None = None + ) -> None: + """Initialize the generator.""" + else_ = [] if else_ is None else else_ + super().__init__(then_, else_=else_) + + def _condition(self, record: Record, **kwargs: Any) -> bool: + if hasattr(record, "is_draft"): + is_draft = record.is_draft + else: + return False + if hasattr(record, "versions"): + next_draft_id = record.versions.next_draft_id + else: + return False + return not is_draft and not next_draft_id + + +class IfNoEditDraft(ConditionalGenerator): + """Generator that checks if the record has no edit draft.""" + + def __init__( + self, then_: list[Generator], else_: list[Generator] | None = None + ) -> None: + """Initialize the generator.""" + else_ = [] if else_ is None else else_ + super().__init__(then_, else_=else_) + + def _condition(self, record: Record, **kwargs: Any) -> bool: + if getattr(record, "is_draft", False): + return False + records_service = get_record_service_for_record(record) + try: + records_service.config.draft_cls.pid.resolve( + record["id"] + ) # by edit - has the same id as parent record + # I'm not sure what open unpublished means + return False + except NoResultFound: + return True diff --git a/oarepo_requests/services/permissions/identity.py b/oarepo_requests/services/permissions/identity.py index d7c13715..7e138caf 100644 --- a/oarepo_requests/services/permissions/identity.py +++ b/oarepo_requests/services/permissions/identity.py @@ -1,3 +1,13 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request needs.""" + from invenio_access.permissions import SystemRoleNeed request_active = SystemRoleNeed("request") +"""Need that is added to identity whenever a request is being handled (inside the 'accept' action).""" diff --git a/oarepo_requests/services/permissions/requester.py b/oarepo_requests/services/permissions/requester.py index e6f09b0c..e79e5ada 100644 --- a/oarepo_requests/services/permissions/requester.py +++ b/oarepo_requests/services/permissions/requester.py @@ -1,27 +1,62 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""State change notifier.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_access.permissions import system_identity -from invenio_records_resources.services.uow import RecordCommitOp, unit_of_work +from invenio_records_resources.services.uow import ( + RecordCommitOp, + UnitOfWork, + unit_of_work, +) from invenio_requests.customizations.actions import RequestActions from invenio_requests.errors import CannotExecuteActionError from invenio_requests.proxies import current_requests_service from invenio_requests.resolvers.registry import ResolverRegistry from oarepo_workflows.proxies import current_oarepo_workflows -from oarepo_workflows.services.permissions.identity import auto_request_need +from oarepo_workflows.requests.generators import auto_request_need from oarepo_requests.proxies import current_oarepo_requests_service +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records import Record + +# TODO: move this to a more appropriate place + @unit_of_work() def auto_request_state_change_notifier( - identity, record, prev_value, value, uow=None, **kwargs -): - for request_type_id, workflow_request in ( - current_oarepo_workflows.get_workflow(record).requests().items() - ): - needs = workflow_request.needs( + identity: Identity, + record: Record, + prev_state: str, + new_state: str, + uow: UnitOfWork | None = None, + **kwargs: Any, +) -> None: + """Create requests that should be created automatically on state change. + + For each of the WorkflowRequest definition in the workflow of the record, + take the needs from the generators of possible creators. If any of those + needs is an auto_request_need, create a request for it automatically. + """ + assert uow is not None + + record_workflow = current_oarepo_workflows.get_workflow(record) + for request_type_id, workflow_request in record_workflow.requests().items(): + needs = workflow_request.requester_generator.needs( request_type=request_type_id, record=record, **kwargs ) if auto_request_need in needs: - data = kwargs["data"] if "data" in kwargs else {} + data = kwargs.get("data", {}) creator_ref = ResolverRegistry.reference_identity(identity) request_item = current_oarepo_requests_service.create( system_identity, diff --git a/oarepo_requests/services/permissions/workflow_policies.py b/oarepo_requests/services/permissions/workflow_policies.py index cb40cb42..9dc9da78 100644 --- a/oarepo_requests/services/permissions/workflow_policies.py +++ b/oarepo_requests/services/permissions/workflow_policies.py @@ -1,23 +1,27 @@ -from invenio_records_permissions.generators import SystemProcess -from invenio_requests.customizations.event_types import CommentEventType, LogEventType -from invenio_requests.services.generators import Creator, Receiver -from invenio_requests.services.permissions import ( - PermissionPolicy as InvenioRequestsPermissionPolicy, -) -from oarepo_workflows import DefaultWorkflowPermissions - -from oarepo_requests.services.permissions.generators import ( - EventCreatorsFromWorkflow, - IfEventType, - IfRequestType, - RequestActive, - RequestCreatorsFromWorkflow, +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Permissions for requests based on workflows.""" + +from oarepo_workflows.requests.permissions import ( + # this is for backward compatibility ... + CreatorsFromWorkflowRequestsPermissionPolicy, ) +from oarepo_workflows.services.permissions import DefaultWorkflowPermissions + +from oarepo_requests.services.permissions.generators.active import RequestActive class RequestBasedWorkflowPermissions(DefaultWorkflowPermissions): - """ - Base class for workflow permissions, subclass from it and put the result to Workflow constructor. + """Base class for workflow permissions, subclass from it and put the result to Workflow constructor. + + This permission adds a special generator RequestActive() to the default permissions. + Whenever the request is in `accept` action, the RequestActive generator matches. + Example: class MyWorkflowPermissions(RequestBasedWorkflowPermissions): can_read = [AnyUser()] @@ -27,6 +31,7 @@ class MyWorkflowPermissions(RequestBasedWorkflowPermissions): permission_policy_cls = MyWorkflowPermissions, ... ) } + """ can_delete = DefaultWorkflowPermissions.can_delete + [RequestActive()] @@ -35,19 +40,7 @@ class MyWorkflowPermissions(RequestBasedWorkflowPermissions): can_new_version = [RequestActive()] -class CreatorsFromWorkflowRequestsPermissionPolicy(InvenioRequestsPermissionPolicy): - can_create = [ - SystemProcess(), - RequestCreatorsFromWorkflow(), - IfRequestType( - ["community-invitation"], InvenioRequestsPermissionPolicy.can_create - ), - ] - - can_create_comment = [ - SystemProcess(), - IfEventType( - [LogEventType.type_id, CommentEventType.type_id], [Creator(), Receiver()] - ), - EventCreatorsFromWorkflow(), - ] +__all__ = ( + "RequestBasedWorkflowPermissions", + "CreatorsFromWorkflowRequestsPermissionPolicy", +) diff --git a/oarepo_requests/services/record/__init__.py b/oarepo_requests/services/record/__init__.py index e69de29b..19b214d1 100644 --- a/oarepo_requests/services/record/__init__.py +++ b/oarepo_requests/services/record/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Service layer for request on /model//requests endpoint.""" diff --git a/oarepo_requests/services/record/service.py b/oarepo_requests/services/record/service.py index 0e90da97..6555ca95 100644 --- a/oarepo_requests/services/record/service.py +++ b/oarepo_requests/services/record/service.py @@ -1,49 +1,86 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Record requests service.""" + +from __future__ import annotations + from types import SimpleNamespace +from typing import TYPE_CHECKING, Any from invenio_records_resources.services.uow import unit_of_work from invenio_search.engine import dsl from oarepo_requests.proxies import current_oarepo_requests -from oarepo_requests.utils import get_type_id_for_record_cls +from oarepo_requests.utils import get_entity_key_for_record_cls + +if TYPE_CHECKING: + from datetime import datetime + + from flask_principal import Identity + from invenio_records_resources.records.api import Record + from invenio_records_resources.services import ServiceConfig + from invenio_records_resources.services.records.results import ( + RecordItem, + RecordList, + ) + from invenio_records_resources.services.records.service import RecordService + from invenio_records_resources.services.uow import UnitOfWork + from opensearch_dsl.query import Query + + from oarepo_requests.services.oarepo.service import OARepoRequestsService class RecordRequestsService: - def __init__(self, record_service, oarepo_requests_service): + """Service for record requests.""" + + def __init__( + self, + record_service: RecordService, + oarepo_requests_service: OARepoRequestsService, + ) -> None: + """Initialize the service.""" self.record_service = record_service self.oarepo_requests_service = oarepo_requests_service # so api doesn't fall apart @property - def config(self): - return SimpleNamespace(service_id=self.service_id) + def config(self) -> ServiceConfig: + """Return a dummy config.""" + return SimpleNamespace(service_id=self.service_id) # type: ignore @property - def service_id(self): + def service_id(self) -> str: + """Return the service ID.""" return f"{self.record_service.config.service_id}_requests" @property - def record_cls(self): - """Factory for creating a record class.""" + def record_cls(self) -> type[Record]: + """Return factory for creating a record class.""" return self.record_service.config.record_cls @property - def requests_service(self): + def requests_service(self) -> OARepoRequestsService: """Factory for creating a record class.""" return current_oarepo_requests.requests_service # from invenio_rdm_records.services.requests.service.RecordRequestsService def search_requests_for_record( self, - identity, - record_id, - params=None, - search_preference=None, - expand=False, - extra_filter=None, - **kwargs, - ): + identity: Identity, + record_id: str, + params: dict[str, Any] | None = None, + search_preference: Any | None = None, + expand: bool = False, + extra_filter: Query | None = None, + **kwargs: Any, + ) -> RecordList: """Search for record's requests.""" - record = self.record_cls.pid.resolve(record_id) + record = self.record_cls.pid.resolve(record_id) # type: ignore self.record_service.require_permission(identity, "read", record=record) search_filter = dsl.query.Bool( @@ -52,7 +89,7 @@ def search_requests_for_record( dsl.Q( "term", **{ - f"topic.{get_type_id_for_record_cls(self.record_cls)}": record_id + f"topic.{get_entity_key_for_record_cls(self.record_cls)}": record_id }, ), ], @@ -72,15 +109,16 @@ def search_requests_for_record( @unit_of_work() def create( self, - identity, - data, - request_type, - topic_id, - expires_at=None, - uow=None, - expand=False, - ): - record = self.record_cls.pid.resolve(topic_id) + identity: Identity, + data: dict[str, Any], + request_type: str, + topic_id: str, + expires_at: datetime | None = None, + uow: UnitOfWork | None = None, + expand: bool = False, + ) -> RecordItem: + """Create a request for a record.""" + record = self.record_cls.pid.resolve(topic_id) # type: ignore return self.oarepo_requests_service.create( identity=identity, data=data, diff --git a/oarepo_requests/services/record/types/__init__.py b/oarepo_requests/services/record/types/__init__.py index e69de29b..d3a0c882 100644 --- a/oarepo_requests/services/record/types/__init__.py +++ b/oarepo_requests/services/record/types/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Record request type service.""" diff --git a/oarepo_requests/services/record/types/service.py b/oarepo_requests/services/record/types/service.py index f28e4ead..a04a4d84 100644 --- a/oarepo_requests/services/record/types/service.py +++ b/oarepo_requests/services/record/types/service.py @@ -1,47 +1,76 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request types service.""" + +from __future__ import annotations + from types import SimpleNamespace +from typing import TYPE_CHECKING from invenio_records_resources.services import LinksTemplate from invenio_records_resources.services.base.links import Link from oarepo_requests.services.results import ( RequestTypesList, - allowed_user_request_types, ) from oarepo_requests.services.schema import RequestTypeSchema +from oarepo_requests.utils import allowed_request_types_for_record + +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records.api import Record + from invenio_records_resources.services import ServiceConfig + from invenio_records_resources.services.records.service import RecordService + + from oarepo_requests.services.oarepo.service import OARepoRequestsService class RecordRequestTypesService: - def __init__(self, record_service, oarepo_requests_service): + """Service for request types of a record (that is, applicable request types).""" + + def __init__( + self, + record_service: RecordService, + oarepo_requests_service: OARepoRequestsService, + ) -> None: + """Initialize the service.""" self.record_service = record_service self.oarepo_requests_service = oarepo_requests_service # so api doesn't fall apart @property - def config(self): - return SimpleNamespace(service_id=self.service_id) + def config(self) -> ServiceConfig: + """Return a dummy config.""" + return SimpleNamespace(service_id=self.service_id) # type: ignore @property - def service_id(self): + def service_id(self) -> str: + """Return the service ID.""" return f"{self.record_service.config.service_id}_request_types" @property - def record_cls(self): - """Factory for creating a record class.""" + def record_cls(self) -> type[Record]: + """Return factory for creating a record class.""" return self.record_service.config.record_cls - """ - @property - def requests_service(self): - return current_oarepo_requests.requests_service - """ - - def get_applicable_request_types(self, identity, record_id): - record = self.record_cls.pid.resolve(record_id) + def get_applicable_request_types_for_published_record( + self, identity: Identity, record_id: str + ) -> RequestTypesList: + """Get applicable request types for a record given by persistent identifier.""" + record = self.record_cls.pid.resolve(record_id) # type: ignore return self._get_applicable_request_types(identity, record) - def _get_applicable_request_types(self, identity, record): + def _get_applicable_request_types( + self, identity: Identity, record: Record + ) -> RequestTypesList: + """Get applicable request types for a record.""" self.record_service.require_permission(identity, "read", record=record) - allowed_request_types = allowed_user_request_types(identity, record) + allowed_request_types = allowed_request_types_for_record(identity, record) return RequestTypesList( service=self.record_service, identity=identity, diff --git a/oarepo_requests/services/results.py b/oarepo_requests/services/results.py index 0724571f..777eef98 100644 --- a/oarepo_requests/services/results.py +++ b/oarepo_requests/services/results.py @@ -1,38 +1,60 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Results components for requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterator, cast + from invenio_records_resources.services import LinksTemplate from invenio_records_resources.services.errors import PermissionDeniedError from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_runtime.services.results import RecordList, ResultsComponent +from oarepo_requests.services.draft.service import DraftRecordRequestsService from oarepo_requests.services.schema import RequestTypeSchema from oarepo_requests.utils import ( allowed_request_types_for_record, get_requests_service_for_records_service, ) +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records.api import Record + from invenio_requests.customizations.request_types import RequestType + class RequestTypesComponent(ResultsComponent): - def update_data(self, identity, record, projection, expand): + """Component for expanding request types.""" + + def update_data( + self, identity: Identity, record: Record, projection: dict, expand: bool + ) -> None: + """Expand request types if requested.""" if not expand: return - allowed_request_types = allowed_user_request_types(identity, record) + allowed_request_types = allowed_request_types_for_record(identity, record) request_types_list = serialize_request_types( allowed_request_types, identity, record ) projection["expanded"]["request_types"] = request_types_list -def allowed_user_request_types(identity, record): - allowed_request_types = allowed_request_types_for_record(record) - allowed_request_types = { - request_type_name: request_type - for request_type_name, request_type in allowed_request_types.items() - if hasattr(request_type, "is_applicable_to") - and request_type.is_applicable_to(identity, record) - } - return allowed_request_types +def serialize_request_types( + request_types: dict[str, RequestType], identity: Identity, record: Record +) -> list[dict]: + """Serialize request types. - -def serialize_request_types(request_types, identity, record): + :param request_types: Request types to serialize. + :param identity: Identity of the user. + :param record: Record for which the request types are serialized. + :return: List of serialized request types. + """ request_types_list = [] for request_type in request_types.values(): request_types_list.append( @@ -41,14 +63,27 @@ def serialize_request_types(request_types, identity, record): return request_types_list -def serialize_request_type(request_type, identity, record): +def serialize_request_type( + request_type: RequestType, identity: Identity, record: Record +) -> dict: + """Serialize a request type. + + :param request_type: Request type to serialize. + :param identity: Identity of the caller. + :param record: Record for which the request type is serialized. + """ return RequestTypeSchema(context={"identity": identity, "record": record}).dump( request_type ) class RequestsComponent(ResultsComponent): - def update_data(self, identity, record, projection, expand): + """Component for expanding requests on a record.""" + + def update_data( + self, identity: Identity, record: Record, projection: dict, expand: bool + ) -> None: + """Expand requests if requested.""" if not expand: return @@ -56,7 +91,7 @@ def update_data(self, identity, record, projection, expand): get_record_service_for_record(record) ) reader = ( - service.search_requests_for_draft + cast(DraftRecordRequestsService, service).search_requests_for_draft if getattr(record, "is_draft", False) else service.search_requests_for_record ) @@ -68,15 +103,20 @@ def update_data(self, identity, record, projection, expand): class RequestTypesListDict(dict): + """List of request types dictionary with additional topic.""" + topic = None class RequestTypesList(RecordList): - def __init__(self, *args, record=None, **kwargs): + """An in-memory list of request types compatible with opensearch record list.""" + + def __init__(self, *args: Any, record: Record | None = None, **kwargs: Any) -> None: + """Initialize the list of request types.""" self._record = record super().__init__(*args, **kwargs) - def to_dict(self): + def to_dict(self) -> dict: """Return result as a dictionary.""" hits = list(self.hits) @@ -102,7 +142,7 @@ def to_dict(self): return res @property - def hits(self): + def hits(self) -> Iterator[dict]: """Iterator over the hits.""" for hit in self._results: # Project the record @@ -119,5 +159,6 @@ def hits(self): yield projection @property - def total(self): + def total(self) -> int: + """Total number of hits.""" return len(self._results) diff --git a/oarepo_requests/services/schema.py b/oarepo_requests/services/schema.py index 5d8f3ac8..51bba187 100644 --- a/oarepo_requests/services/schema.py +++ b/oarepo_requests/services/schema.py @@ -1,3 +1,14 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Enhancements to the request schema.""" + +from typing import Any + import marshmallow as ma from invenio_records_resources.services import ConditionalLink from invenio_records_resources.services.base.links import Link, LinksTemplate @@ -7,19 +18,24 @@ from oarepo_runtime.records import is_published_record -def get_links_schema(): +def get_links_schema() -> ma.fields.Dict: + """Get links schema.""" return ma.fields.Dict( keys=ma.fields.String() ) # value is either string or dict of strings (for actions) class RequestTypeSchema(ma.Schema): + """Request type schema.""" + type_id = ma.fields.String() + """Type ID of the request type.""" + links = get_links_schema() - # links = Links() + """Links to the request type.""" @ma.post_dump - def create_link(self, data, **kwargs): + def _create_link(self, data: dict, **kwargs: Any) -> dict: if "links" in data: return data if "record" not in self.context: @@ -42,10 +58,14 @@ def create_link(self, data, **kwargs): return data -class NoneReceiverGenericRequestSchema(GenericRequestSchema): +class NoReceiverAllowedGenericRequestSchema(GenericRequestSchema): + """A mixin that allows serialization of requests without a receiver.""" + receiver = fields.Dict(allow_none=True) class RequestsSchemaMixin: - requests = ma.fields.List(ma.fields.Nested(NoneReceiverGenericRequestSchema)) + """A mixin that allows serialization of requests together with their request type.""" + + requests = ma.fields.List(ma.fields.Nested(NoReceiverAllowedGenericRequestSchema)) request_types = ma.fields.List(ma.fields.Nested(RequestTypeSchema)) diff --git a/oarepo_requests/services/ui_schema.py b/oarepo_requests/services/ui_schema.py index 4ffb8c23..85543983 100644 --- a/oarepo_requests/services/ui_schema.py +++ b/oarepo_requests/services/ui_schema.py @@ -1,4 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""UI schemas for requests.""" + +from __future__ import annotations + from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast import marshmallow as ma from invenio_pidstore.errors import PersistentIdentifierError, PIDDeletedError @@ -15,29 +27,42 @@ from oarepo_requests.resolvers.ui import resolve from oarepo_requests.services.schema import ( - NoneReceiverGenericRequestSchema, + NoReceiverAllowedGenericRequestSchema, RequestTypeSchema, get_links_schema, ) +if TYPE_CHECKING: + from invenio_requests.customizations.request_types import RequestType + from invenio_requests.records.api import RequestEvent + class UIReferenceSchema(ma.Schema): + """UI schema for references.""" + reference = ma.fields.Dict(validate=validate.Length(equal=1)) - # reference = ma.fields.Dict(ReferenceString) + """Reference to the entity.""" + type = ma.fields.String() + """Type of the entity.""" + label = ma.fields.String() + """Label of the entity.""" + links = get_links_schema() + """Links to the entity.""" @ma.pre_dump - def create_reference(self, data, **kwargs): + def _create_reference(self, data: Any, **kwargs: Any) -> dict | None: if data: return dict(reference=data) + return None @ma.post_dump - def dereference(self, data, **kwargs): + def _dereference(self, data: dict, **kwargs: Any) -> dict[str, Any]: if "resolved" not in self.context: try: - return resolve(self.context["identity"], data["reference"]) + return cast(dict, resolve(self.context["identity"], data["reference"])) except PIDDeletedError: return {**data, "status": "removed"} except PersistentIdentifierError: @@ -47,27 +72,47 @@ def dereference(self, data, **kwargs): class UIRequestSchemaMixin: + """Mixin for UI request schemas.""" + created = LocalizedDateTime(dump_only=True) + """Creation date of the request.""" + updated = LocalizedDateTime(dump_only=True) + """Update date of the request.""" name = ma.fields.String() + """Name of the request.""" + description = ma.fields.String() + """Description of the request.""" stateful_name = ma.fields.String(dump_only=True) + """Stateful name of the request, as given by the request type.""" + stateful_description = ma.fields.String(dump_only=True) + """Stateful description of the request, as given by the request type.""" created_by = ma.fields.Nested(UIReferenceSchema) + """Creator of the request.""" + receiver = ma.fields.Nested(UIReferenceSchema) + """Receiver of the request.""" + topic = ma.fields.Nested(UIReferenceSchema) + """Topic of the request.""" links = get_links_schema() + """Links to the request.""" payload = ma.fields.Raw() + """Extra payload of the request.""" status_code = ma.fields.String() + """Status code of the request.""" @ma.pre_dump - def add_type_details(self, data, **kwargs): + def _add_type_details(self, data: dict, **kwargs: Any) -> dict: + """Add details taken from the request type to the serialized request.""" type = data["type"] type_obj = current_request_type_registry.lookup(type, quiet=True) if hasattr(type_obj, "description"): @@ -81,7 +126,9 @@ def add_type_details(self, data, **kwargs): return data - def _get_stateful_labels(self, type_obj, data): + def _get_stateful_labels( + self, type_obj: RequestType, data: dict + ) -> tuple[str | None, str | None]: stateful_name = None stateful_description = None try: @@ -89,14 +136,14 @@ def _get_stateful_labels(self, type_obj, data): if topic: if hasattr(type_obj, "stateful_name"): stateful_name = type_obj.stateful_name( - identity=self.context["identity"], + identity=self.context["identity"], # type: ignore topic=topic, # not very nice, but we need to pass the request object to the stateful_name function request=SimpleNamespace(**data), ) if hasattr(type_obj, "stateful_description"): stateful_description = type_obj.stateful_description( - identity=self.context["identity"], + identity=self.context["identity"], # type: ignore topic=topic, # not very nice, but we need to pass the request object to the stateful_description function request=SimpleNamespace(**data), @@ -107,30 +154,49 @@ def _get_stateful_labels(self, type_obj, data): return stateful_name, stateful_description @ma.pre_dump - def process_status(self, data, **kwargs): + def _process_status(self, data: dict, **kwargs: Any) -> dict: data["status_code"] = data["status"] data["status"] = _(data["status"].capitalize()) return data -class UIBaseRequestSchema(UIRequestSchemaMixin, NoneReceiverGenericRequestSchema): - """""" +class UIBaseRequestSchema(UIRequestSchemaMixin, NoReceiverAllowedGenericRequestSchema): + """Base schema for oarepo requests.""" class UIRequestTypeSchema(RequestTypeSchema): + """UI schema for request types.""" + name = ma.fields.String() + """Name of the request type.""" + description = ma.fields.String() + """Description of the request type.""" + fast_approve = ma.fields.Boolean() + """Whether the request type can be fast approved.""" stateful_name = ma.fields.String(dump_only=True) + """Stateful name of the request type.""" + stateful_description = ma.fields.String(dump_only=True) + """Stateful description of the request type.""" dangerous = ma.fields.Boolean(dump_only=True) + """Whether the request type is dangerous (for example, delete stuff).""" + editable = ma.fields.Boolean(dump_only=True) + """Whether the request type is editable. + + Editable requests are not automatically submitted, they are kept in open state + until the user decides to submit them.""" + has_form = ma.fields.Boolean(dump_only=True) + """Whether the request type has a form.""" @ma.post_dump - def add_type_details(self, data, **kwargs): + def _add_type_details(self, data: dict, **kwargs: Any) -> dict: + """Serialize details from request type.""" type = data["type_id"] type_obj = current_request_type_registry.lookup(type, quiet=True) if hasattr(type_obj, "description"): @@ -139,8 +205,8 @@ def add_type_details(self, data, **kwargs): data["name"] = type_obj.name if hasattr(type_obj, "dangerous"): data["dangerous"] = type_obj.dangerous - if hasattr(type_obj, "editable"): - data["editable"] = type_obj.editable + if hasattr(type_obj, "is_editable"): + data["editable"] = type_obj.is_editable if hasattr(type_obj, "has_form"): data["has_form"] = type_obj.has_form @@ -156,12 +222,17 @@ def add_type_details(self, data, **kwargs): class UIRequestsSerializationMixin(ma.Schema): - @ma.post_dump() - def add_request_types(self, data, **kwargs): + """Mixin for serialization of record that adds information from request type.""" + + @ma.post_dump(pass_original=True) + def _add_request_types( + self, data: dict, original_data: dict, **kwargs: Any + ) -> dict: + """If the expansion is requested, add UI form of request types and requests to the serialized record.""" expanded = data.get("expanded", {}) if not expanded: return data - context = {**self.context, "topic": data} + context = {**self.context, "topic": original_data} if "request_types" in expanded: expanded["request_types"] = UIRequestTypeSchema(context=context).dump( expanded["request_types"], many=True @@ -174,15 +245,27 @@ def add_request_types(self, data, **kwargs): class UIBaseRequestEventSchema(BaseRecordSchema): + """Base schema for request events.""" + created = LocalizedDateTime(dump_only=True) + """Creation date of the event.""" + updated = LocalizedDateTime(dump_only=True) + """Update date of the event.""" type = EventTypeMarshmallowField(dump_only=True) + """Type of the event.""" + created_by = ma.fields.Nested(UIReferenceSchema) + """Creator of the event.""" + permissions = ma.fields.Method("get_permissions", dump_only=True) + """Permissions to act on the event.""" + payload = ma.fields.Raw() + """Payload of the event.""" - def get_permissions(self, obj): + def get_permissions(self, obj: RequestEvent) -> dict: """Return permissions to act on comments or empty dict.""" type = self.get_attribute(obj, "type", None) is_comment = type == CommentEventType diff --git a/oarepo_requests/translations/_only_for_translations.py b/oarepo_requests/translations/_only_for_translations.py index a8b581ba..62e29c78 100644 --- a/oarepo_requests/translations/_only_for_translations.py +++ b/oarepo_requests/translations/_only_for_translations.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from oarepo_runtime.i18n import lazy_gettext as _ _("Create Request") diff --git a/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo b/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo index a32d8aad..01d79aa4 100644 Binary files a/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo and b/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo differ diff --git a/oarepo_requests/translations/cs/LC_MESSAGES/messages.po b/oarepo_requests/translations/cs/LC_MESSAGES/messages.po index b6299329..89ccdca9 100644 --- a/oarepo_requests/translations/cs/LC_MESSAGES/messages.po +++ b/oarepo_requests/translations/cs/LC_MESSAGES/messages.po @@ -656,3 +656,15 @@ msgstr "Ponechat soubory." #: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:51 msgid "Keep files in the new version?" msgstr "Ponechat soubory v nové verzi?" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:101 +msgid "You do not have permission to delete the draft." +msgstr "Nemáte právo smazat rozpracovaný záznam." + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:106 +msgid "You do not have permission to delete the record." +msgstr "Nemáte právo smazat záznam." + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:161 +msgid "You do not have permission to update the record." +msgstr "Nemáte právo zažádat o aktualizaci záznamu." diff --git a/oarepo_requests/translations/en/LC_MESSAGES/messages.po b/oarepo_requests/translations/en/LC_MESSAGES/messages.po index a8f45f27..f13e89af 100644 --- a/oarepo_requests/translations/en/LC_MESSAGES/messages.po +++ b/oarepo_requests/translations/en/LC_MESSAGES/messages.po @@ -625,5 +625,17 @@ msgstr "" msgid "Keep files." msgstr "" +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:101 +msgid "You do not have permission to delete the draft." +msgstr "" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:106 +msgid "You do not have permission to delete the record." +msgstr "" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:161 +msgid "You do not have permission to update the record." +msgstr "" + #~ msgid "No status" #~ msgstr "" diff --git a/oarepo_requests/translations/messages.mo b/oarepo_requests/translations/messages.mo index 22c6262f..7965e2b4 100644 Binary files a/oarepo_requests/translations/messages.mo and b/oarepo_requests/translations/messages.mo differ diff --git a/oarepo_requests/translations/messages.pot b/oarepo_requests/translations/messages.pot index b6fdccc4..6cbadeb1 100644 --- a/oarepo_requests/translations/messages.pot +++ b/oarepo_requests/translations/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-11-15 12:36+0100\n" +"POT-Creation-Date: 2024-11-21 12:37+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,404 +17,416 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.15.0\n" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:125 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:163 msgid "Submitted" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:126 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:164 msgid "Expired" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:127 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:165 msgid "Accepted" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:128 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:166 msgid "Declined" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:129 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:167 msgid "Cancelled" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:131 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:169 msgid "Request status" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/invenio_patches.py:137 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/invenio_patches.py:175 msgid "Type" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/delete_published_record.py:9 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/delete_published_record.py:30 msgid "Permanently delete" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/delete_published_record.py:20 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/delete_published_record.py:52 msgid "Keep the record" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/generic.py:65 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:21 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/generic.py:138 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:28 msgid "Submit" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/generic.py:70 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:17 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/generic.py:144 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:24 msgid "Decline" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/generic.py:75 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:15 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/generic.py:150 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:22 msgid "Accept" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/publish_draft.py:36 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/publish_draft.py:59 msgid "Publish" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/actions/publish_draft.py:58 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/actions/publish_draft.py:92 msgid "Return for correction" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/resolvers/ui.py:148 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/resolvers/ui.py:333 msgid "System user" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/services/ui_schema.py:112 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/services/ui_schema.py:159 msgid "status" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:3 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:10 msgid "Create Request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:4 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:11 msgid "Open dialog for request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:5 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:12 msgid "My Requests" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:6 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:13 msgid "Requests to Approve" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:7 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:14 msgid "Are you sure?" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:8 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:15 msgid "Cancel" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:9 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:16 msgid "OK" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:10 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:17 msgid "Create request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:11 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:18 msgid "Submit request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:12 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:19 msgid "Delete request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:13 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:20 msgid "Delete" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:14 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:21 msgid "Accept request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:16 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:23 msgid "Decline request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:18 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:25 msgid "Create and submit request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:19 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:26 msgid "Create and submit" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:20 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:27 msgid "Error sending request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:22 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:29 msgid "Save drafted request" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:23 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:30 msgid "Save" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:24 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:31 msgid "Create" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:25 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:32 msgid "Creator" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:26 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:33 msgid "Receiver" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:27 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:34 msgid "Request type" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:28 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:35 msgid "Created" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:29 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:36 msgid "Timeline" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:30 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:37 msgid "Submit event" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:31 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:38 msgid "No requests to show" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:32 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:39 msgid "api.requests" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/translations/_only_for_translations.py:33 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/translations/_only_for_translations.py:40 msgid "api.applicable-requests" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:12 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:34 msgid "Delete draft" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:24 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:46 msgid "Request deletion of draft" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:33 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:38 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:63 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:68 msgid "Request draft deletion" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:36 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:66 msgid "Draft deletion requested" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:43 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:81 msgid "Click to permanently delete the draft." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:46 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:84 msgid "Request permission to delete the draft." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:50 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:88 msgid "" "Permission to delete draft requested. You will be notified about the " "decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:55 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:93 msgid "" "You have been asked to approve the request to permanently delete the draft. " "You can approve or reject the request." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:59 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:97 msgid "Permission to delete draft (including files) requested. " msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_draft.py:62 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:100 msgid "Submit request to get permission to delete the draft." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:16 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_draft.py:101 +msgid "You do not have permission to delete the draft." +msgstr "" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:38 msgid "Delete record" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:29 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:51 msgid "Request deletion of published record" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:38 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:43 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:68 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:73 msgid "Request record deletion" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:41 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:71 msgid "Record deletion requested" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:48 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:86 msgid "Click to permanently delete the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:51 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:89 msgid "Request permission to delete the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:55 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:93 msgid "" "Permission to delete record requested. You will be notified about the " "decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:60 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:98 msgid "" "You have been asked to approve the request to permanently delete the record." " You can approve or reject the request." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:64 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:102 msgid "Permission to delete record (including files) requested. " msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/delete_published_record.py:67 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:105 msgid "Submit request to get permission to delete the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:19 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/delete_published_record.py:106 +msgid "You do not have permission to delete the record." +msgstr "" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:45 msgid "Edit record" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:39 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:65 msgid "Request re-opening of published record" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:65 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:70 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:122 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:127 msgid "Request edit access" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:68 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:125 msgid "Edit access requested" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:75 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:140 msgid "Click to start editing the metadata of the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:78 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:95 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:143 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:160 msgid "" "Request edit access to the record. You will be notified about the decision " "by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:85 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:150 msgid "" "Edit access requested. You will be notified about the decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:90 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:155 msgid "You have been requested to grant edit access to the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/edit_record.py:93 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/edit_record.py:158 msgid "Edit access requested." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:21 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:40 msgid "New Version" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:42 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:61 msgid "Request requesting creation of new version of a published record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:50 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:69 msgid "Keep files." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:51 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:70 msgid "Keep files in the new version?" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:79 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:84 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:120 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:125 msgid "Request new version access" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:82 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:123 msgid "New version access requested" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:89 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:138 msgid "Click to start creating a new version of the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:92 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:141 msgid "" "Request permission to update record (including files). You will be notified " "about the decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:99 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:148 msgid "" "Permission to update record (including files) requested. You will be " "notified about the decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:104 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:153 msgid "" "You have been asked to approve the request to update the record. You can " "approve or reject the request." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:108 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:157 msgid "Permission to update record (including files) requested. " msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/new_version.py:111 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:160 msgid "Submit request to get edit access to the record." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:25 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:85 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/new_version.py:161 +msgid "You do not have permission to update the record." +msgstr "" + +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:46 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:128 msgid "Publish draft" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:42 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:63 msgid "Resource version" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:43 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:64 msgid "Write down the version (first, second…)." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:58 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:79 msgid "Request publishing of a draft" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:87 -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:92 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:130 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:135 msgid "Submit for review" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:90 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:133 msgid "Submitted for review" msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:97 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:148 msgid "" "Click to immediately publish the draft. The draft will be a subject to " "embargo as requested in the side panel. Note: The action is irreversible." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:104 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:155 msgid "" "By submitting the draft for review you are requesting the publication of the" " draft. The draft will become locked and no further changes will be possible" @@ -422,33 +434,33 @@ msgid "" "decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:112 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:163 msgid "" "The draft has been submitted for review. It is now locked and no further " "changes are possible. You will be notified about the decision by email." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:118 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:169 msgid "" "The draft has been submitted for review. You can now accept or decline the " "request." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:122 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:173 msgid "The draft has been submitted for review." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:125 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:176 msgid "" "Submit for review. After submitting the draft for review, it will be locked " "and no further modifications will be possible." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/types/publish_draft.py:129 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/types/publish_draft.py:180 msgid "Request not yet submitted." msgstr "" -#: /home/ron/prace/oarepo-requests-alt/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja:3 +#: /Users/m/w/cesnet/oarepo-requests/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja:3 msgid "Request" msgstr "" diff --git a/oarepo_requests/types/__init__.py b/oarepo_requests/types/__init__.py index cc7c30f0..27316423 100644 --- a/oarepo_requests/types/__init__.py +++ b/oarepo_requests/types/__init__.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request types defined in oarepo-requests.""" + from .delete_published_record import DeletePublishedRecordRequestType from .edit_record import EditPublishedRecordRequestType from .generic import NonDuplicableOARepoRequestType diff --git a/oarepo_requests/types/delete_draft.py b/oarepo_requests/types/delete_draft.py index 0f1c6f6b..680f0380 100644 --- a/oarepo_requests/types/delete_draft.py +++ b/oarepo_requests/types/delete_draft.py @@ -1,21 +1,43 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request for deleting draft records.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + 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 ..utils import classproperty, is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType from .ref_types import ModelRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + class DeleteDraftRequestType(NonDuplicableOARepoRequestType): + """Request type for deleting draft records.""" + type_id = "delete_draft" name = _("Delete draft") dangerous = True - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "accept": DeleteDraftAcceptAction, @@ -26,7 +48,15 @@ def available_actions(cls): allowed_topic_ref_types = ModelRefTypes(published=False, draft=True) @override - def stateful_name(self, identity, *, topic, request=None, **kwargs): + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful name of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return self.name if not request: @@ -38,7 +68,15 @@ def stateful_name(self, identity, *, topic, request=None, **kwargs): return _("Request draft deletion") @override - def stateful_description(self, identity, *, topic, request=None, **kwargs): + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful description of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _("Click to permanently delete the draft.") @@ -60,3 +98,4 @@ def stateful_description(self, identity, *, topic, request=None, **kwargs): case _: if request_identity_matches(request.created_by, identity): return _("Submit request to get permission to delete the draft.") + return _("You do not have permission to delete the draft.") diff --git a/oarepo_requests/types/delete_published_record.py b/oarepo_requests/types/delete_published_record.py index 437db83a..3d7bce5b 100644 --- a/oarepo_requests/types/delete_published_record.py +++ b/oarepo_requests/types/delete_published_record.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request for deleting published record.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from oarepo_runtime.i18n import lazy_gettext as _ from typing_extensions import override @@ -6,20 +19,29 @@ DeletePublishedRecordDeclineAction, ) -from ..utils import is_auto_approved, request_identity_matches +from ..utils import classproperty, is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType from .ref_types import ModelRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + class DeletePublishedRecordRequestType(NonDuplicableOARepoRequestType): + """Request type for requesting deletion of a published record.""" + type_id = "delete_published_record" name = _("Delete record") dangerous = True - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "accept": DeletePublishedRecordAcceptAction, @@ -31,7 +53,15 @@ def available_actions(cls): allowed_topic_ref_types = ModelRefTypes(published=True, draft=False) @override - def stateful_name(self, identity, *, topic, request=None, **kwargs): + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful name of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return self.name if not request: @@ -43,7 +73,15 @@ def stateful_name(self, identity, *, topic, request=None, **kwargs): return _("Request record deletion") @override - def stateful_description(self, identity, *, topic, request=None, **kwargs): + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful description of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _("Click to permanently delete the record.") @@ -65,3 +103,4 @@ def stateful_description(self, identity, *, topic, request=None, **kwargs): case _: if request_identity_matches(request.created_by, identity): return _("Submit request to get permission to delete the record.") + return _("You do not have permission to delete the record.") diff --git a/oarepo_requests/types/edit_record.py b/oarepo_requests/types/edit_record.py index 9ecbf3f9..ce48ca82 100644 --- a/oarepo_requests/types/edit_record.py +++ b/oarepo_requests/types/edit_record.py @@ -1,7 +1,18 @@ -from typing import Dict +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""EditPublishedRecordRequestType.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import marshmallow as ma -from invenio_records_resources.services.uow import RecordCommitOp +from invenio_records_resources.services.uow import RecordCommitOp, UnitOfWork from invenio_requests.proxies import current_requests_service from invenio_requests.records.api import Request from oarepo_runtime.i18n import lazy_gettext as _ @@ -9,12 +20,27 @@ from oarepo_requests.actions.edit_topic import EditTopicAcceptAction -from ..utils import is_auto_approved, request_identity_matches +from ..utils import classproperty, is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType from .ref_types import ModelRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + + from oarepo_requests.typing import EntityReference + class EditPublishedRecordRequestType(NonDuplicableOARepoRequestType): + """Request type for requesting edit of a published record. + + This request type is used to request edit access to a published record. This access + is restricted to the metadata of the record, not to the files. + """ + type_id = "edit_published_record" name = _("Edit record") payload_schema = { @@ -28,9 +54,9 @@ class EditPublishedRecordRequestType(NonDuplicableOARepoRequestType): ), } - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "accept": EditTopicAcceptAction, @@ -41,24 +67,55 @@ def available_actions(cls): allowed_topic_ref_types = ModelRefTypes(published=True, draft=True) @classmethod - def is_applicable_to(cls, identity, topic, *args, **kwargs): + def is_applicable_to( + cls, identity: Identity, topic: Record, *args: Any, **kwargs: Any + ) -> bool: + """Check if the request type is applicable to the topic.""" if topic.is_draft: return False return super().is_applicable_to(identity, topic, *args, **kwargs) - def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): + def can_create( + self, + identity: Identity, + data: dict, + receiver: EntityReference, + topic: Record, + creator: EntityReference, + *args: Any, + **kwargs: Any, + ) -> None: + """Check if the request can be created. + + :param identity: identity of the caller + :param data: data of the request + :param receiver: receiver of the request + :param topic: topic of the request + :param creator: creator of the request + :param args: additional arguments + :param kwargs: additional keyword arguments + """ if topic.is_draft: raise ValueError( "Trying to create edit request on draft record" ) # todo - if we want the active topic thing, we have to allow published as allowed topic and have to check this somewhere else super().can_create(identity, data, receiver, topic, creator, *args, **kwargs) - def topic_change(self, request: Request, new_topic: Dict, uow): - setattr(request, "topic", new_topic) + def topic_change(self, request: Request, new_topic: dict, uow: UnitOfWork) -> None: + """Change the topic of the request.""" + request.topic = new_topic uow.register(RecordCommitOp(request, indexer=current_requests_service.indexer)) @override - def stateful_name(self, identity, *, topic, request=None, **kwargs): + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful name of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return self.name if not request: @@ -70,7 +127,15 @@ def stateful_name(self, identity, *, topic, request=None, **kwargs): return _("Request edit access") @override - def stateful_description(self, identity, *, topic, request=None, **kwargs): + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful description of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _("Click to start editing the metadata of the record.") diff --git a/oarepo_requests/types/events/__init__.py b/oarepo_requests/types/events/__init__.py index ff8f8034..a2e6cacb 100644 --- a/oarepo_requests/types/events/__init__.py +++ b/oarepo_requests/types/events/__init__.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request events.""" + from .topic_delete import TopicDeleteEventType from .topic_update import TopicUpdateEventType diff --git a/oarepo_requests/types/events/topic_delete.py b/oarepo_requests/types/events/topic_delete.py index 9e669cfd..0f42cd70 100644 --- a/oarepo_requests/types/events/topic_delete.py +++ b/oarepo_requests/types/events/topic_delete.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Topic delete event type.""" + from invenio_requests.customizations.event_types import EventType from marshmallow import fields diff --git a/oarepo_requests/types/events/topic_update.py b/oarepo_requests/types/events/topic_update.py index 48038245..d5cf44fe 100644 --- a/oarepo_requests/types/events/topic_update.py +++ b/oarepo_requests/types/events/topic_update.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Topic update event type.""" + from invenio_requests.customizations.event_types import EventType from marshmallow import fields diff --git a/oarepo_requests/types/events/validation.py b/oarepo_requests/types/events/validation.py index 72b28823..f6734516 100644 --- a/oarepo_requests/types/events/validation.py +++ b/oarepo_requests/types/events/validation.py @@ -1,4 +1,15 @@ -def _serialized_topic_validator(value): +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Validation of event types.""" + + +def _serialized_topic_validator(value: str) -> str: + """Validate the serialized topic. It must be a string with model and id separated by a single dot.""" if len(value.split(".")) != 2: raise ValueError( "Serialized topic must be a string with model and id separated by a single dot." diff --git a/oarepo_requests/types/generic.py b/oarepo_requests/types/generic.py index bb5a8297..c2df4024 100644 --- a/oarepo_requests/types/generic.py +++ b/oarepo_requests/types/generic.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Base request type for OARepo requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from invenio_access.permissions import system_identity from invenio_records_resources.services.errors import PermissionDeniedError from invenio_requests.customizations import RequestType @@ -5,7 +18,7 @@ from invenio_requests.proxies import current_requests_service from oarepo_requests.errors import OpenRequestAlreadyExists -from oarepo_requests.utils import open_request_exists +from oarepo_requests.utils import classproperty, open_request_exists from ..actions.generic import ( OARepoAcceptAction, @@ -15,43 +28,89 @@ ) from .ref_types import ModelRefTypes, ReceiverRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_records_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + + from oarepo_requests.typing import EntityReference + class OARepoRequestType(RequestType): + """Base request type for OARepo requests.""" + description = None dangerous = False - def on_topic_delete(self, request, topic): + def on_topic_delete(self, request: Request, topic: Record) -> None: + """Cancel the request when the topic is deleted. + + :param request: the request + :param topic: the topic + """ current_requests_service.execute_action(system_identity, request.id, "cancel") - @classmethod - @property - def available_statuses(cls): + @classproperty[dict[str, RequestState]] + def available_statuses(cls) -> dict[str, RequestState]: + """Return available statuses for the request type. + + The status (open, closed, undefined) are used for request filtering. + """ return {**super().available_statuses, "created": RequestState.OPEN} - @classmethod - @property - def has_form(cls): + @classproperty[bool] + def has_form(cls) -> bool: + """Return whether the request type has a form.""" return hasattr(cls, "form") - @classmethod - @property - def editable(cls): - return cls.has_form + editable: bool | None = None + """Whether the request type can be edited multiple times before it is submitted.""" + + @classproperty[bool] + def is_editable(cls) -> bool: + """Return whether the request type is editable.""" + if cls.editable is not None: + return cls.editable + return cls.has_form # noqa + + def can_create( + self, + identity: Identity, + data: dict, + receiver: EntityReference, + topic: Record, + creator: EntityReference, + *args: Any, + **kwargs: Any, + ) -> None: + """Check if the request can be created. - def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): + :param identity: identity of the caller + :param data: data of the request + :param receiver: receiver of the request + :param topic: topic of the request + :param creator: creator of the request + :param args: additional arguments + :param kwargs: additional keyword arguments + """ current_requests_service.require_permission( identity, "create", record=topic, request_type=self, **kwargs ) @classmethod - def is_applicable_to(cls, identity, topic, *args, **kwargs): - """ - used for checking whether there is any situation where the client can create a request of this type - it's different to just using can create with no receiver and data because that checks specifically - for situation without them while this method is used to check whether there is a possible situation - a user might create this request - eg. for the purpose of serializing a link on associated record + def is_applicable_to( + cls, identity: Identity, topic: Record, *args: Any, **kwargs: Any + ) -> bool: + """Check if the request type is applicable to the topic. + + Used for checking whether there is any situation where the client can create + a request of this type it's different to just using can create with no receiver + and data because that checks specifically for situation without them while this + method is used to check whether there is a possible situation a user might create + this request eg. for the purpose of serializing a link on associated record """ try: current_requests_service.require_permission( @@ -64,9 +123,9 @@ def is_applicable_to(cls, identity, topic, *args, **kwargs): allowed_topic_ref_types = ModelRefTypes() allowed_receiver_ref_types = ReceiverRefTypes() - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "submit": OARepoSubmitAction, @@ -75,34 +134,74 @@ def available_actions(cls): "cancel": OARepoCancelAction, } - def stateful_name(self, *, identity, topic, request=None, **kwargs): - """ - Returns the name of the request that reflects its current state. + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the name of the request that reflects its current state. :param identity: identity of the caller :param request: the request + :param topic: resolved request's topic """ return self.name - def stateful_description(self, *, identity, topic, request=None, **kwargs): - """ - Returns the description of the request that reflects its current state. + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the description of the request that reflects its current state. :param identity: identity of the caller :param request: the request + :param topic: resolved request's topic """ return self.description -# can be simulated by switching state to a one which does not allow create class NonDuplicableOARepoRequestType(OARepoRequestType): - def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): + """Base request type for OARepo requests that cannot be duplicated. + + This means that on a single topic there can be only one open request of this type. + """ + + def can_create( + self, + identity: Identity, + data: dict, + receiver: EntityReference, + topic: Record, + creator: EntityReference, + *args: Any, + **kwargs: Any, + ) -> None: + """Check if the request can be created. + + :param identity: identity of the caller + :param data: data of the request + :param receiver: receiver of the request + :param topic: topic of the request + :param creator: creator of the request + :param args: additional arguments + :param kwargs: additional keyword arguments + """ if open_request_exists(topic, self.type_id): raise OpenRequestAlreadyExists(self, topic) super().can_create(identity, data, receiver, topic, creator, *args, **kwargs) @classmethod - def is_applicable_to(cls, identity, topic, *args, **kwargs): + def is_applicable_to( + cls, identity: Identity, topic: Record, *args: Any, **kwargs: Any + ) -> bool: + """Check if the request type is applicable to the topic.""" if open_request_exists(topic, cls.type_id): return False return super().is_applicable_to(identity, topic, *args, **kwargs) diff --git a/oarepo_requests/types/new_version.py b/oarepo_requests/types/new_version.py index c24363ba..6a5b2d3d 100644 --- a/oarepo_requests/types/new_version.py +++ b/oarepo_requests/types/new_version.py @@ -1,22 +1,41 @@ -from typing import Dict +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request type for requesting new version of a published record.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import marshmallow as ma -from invenio_records_resources.services.uow import RecordCommitOp +from invenio_records_resources.services.uow import RecordCommitOp, UnitOfWork from invenio_requests.proxies import current_requests_service -from invenio_requests.records.api import Request from marshmallow.validate import OneOf from oarepo_runtime.i18n import lazy_gettext as _ from typing_extensions import override from ..actions.new_version import NewVersionAcceptAction -from ..utils import is_auto_approved, request_identity_matches +from ..utils import classproperty, is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType from .ref_types import ModelRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + + from oarepo_requests.typing import EntityReference + + +class NewVersionRequestType(NonDuplicableOARepoRequestType): + """Request type for requesting new version of a published record.""" -class NewVersionRequestType( - NonDuplicableOARepoRequestType -): # NewVersionFromPublishedRecord? or just new_version type_id = "new_version" name = _("New Version") payload_schema = { @@ -31,9 +50,9 @@ class NewVersionRequestType( "keep_files": ma.fields.String(validate=OneOf(["true", "false"])), } - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "accept": NewVersionAcceptAction, @@ -55,24 +74,46 @@ def available_actions(cls): } @classmethod - def is_applicable_to(cls, identity, topic, *args, **kwargs): + def is_applicable_to( + cls, identity: Identity, topic: Record, *args: Any, **kwargs: Any + ) -> bool: + """Check if the request type is applicable to the topic.""" if topic.is_draft: return False return super().is_applicable_to(identity, topic, *args, **kwargs) - def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): + def can_create( + self, + identity: Identity, + data: dict, + receiver: EntityReference, + topic: Record, + creator: EntityReference, + *args: Any, + **kwargs: Any, + ) -> None: + """Check if the request can be created.""" if topic.is_draft: raise ValueError( "Trying to create new version request on draft record" ) # todo - if we want the active topic thing, we have to allow published as allowed topic and have to check this somewhere else super().can_create(identity, data, receiver, topic, creator, *args, **kwargs) - def topic_change(self, request: Request, new_topic: Dict, uow): - setattr(request, "topic", new_topic) + def topic_change(self, request: Request, new_topic: dict, uow: UnitOfWork) -> None: + """Change the topic of the request.""" + request.topic = new_topic uow.register(RecordCommitOp(request, indexer=current_requests_service.indexer)) @override - def stateful_name(self, identity, *, topic, request=None, **kwargs): + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful name of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return self.name if not request: @@ -84,7 +125,15 @@ def stateful_name(self, identity, *, topic, request=None, **kwargs): return _("Request new version access") @override - def stateful_description(self, identity, *, topic, request=None, **kwargs): + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful description of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _("Click to start creating a new version of the record.") @@ -109,3 +158,4 @@ def stateful_description(self, identity, *, topic, request=None, **kwargs): case _: if request_identity_matches(request.created_by, identity): return _("Submit request to get edit access to the record.") + return _("You do not have permission to update the record.") diff --git a/oarepo_requests/types/publish_draft.py b/oarepo_requests/types/publish_draft.py index a5ba6f45..3d9ba41c 100644 --- a/oarepo_requests/types/publish_draft.py +++ b/oarepo_requests/types/publish_draft.py @@ -1,10 +1,20 @@ -from typing import Dict +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Publish draft request type.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import marshmallow as ma from invenio_access.permissions import system_identity -from invenio_records_resources.services.uow import RecordCommitOp +from invenio_records_resources.services.uow import RecordCommitOp, UnitOfWork from invenio_requests.proxies import current_requests_service -from invenio_requests.records.api import Request from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_runtime.i18n import lazy_gettext as _ from typing_extensions import override @@ -15,12 +25,23 @@ PublishDraftSubmitAction, ) -from ..utils import is_auto_approved, request_identity_matches +from ..utils import classproperty, is_auto_approved, request_identity_matches from .generic import NonDuplicableOARepoRequestType from .ref_types import ModelRefTypes +if TYPE_CHECKING: + from flask_babel.speaklater import LazyString + from flask_principal import Identity + from invenio_drafts_resources.records import Record + from invenio_requests.customizations.actions import RequestAction + from invenio_requests.records.api import Request + + from oarepo_requests.typing import EntityReference + class PublishDraftRequestType(NonDuplicableOARepoRequestType): + """Publish draft request type.""" + type_id = "publish_draft" name = _("Publish draft") payload_schema = { @@ -45,9 +66,9 @@ class PublishDraftRequestType(NonDuplicableOARepoRequestType): }, } - @classmethod - @property - def available_actions(cls): + @classproperty + def available_actions(cls) -> dict[str, type[RequestAction]]: + """Return available actions for the request type.""" return { **super().available_actions, "submit": PublishDraftSubmitAction, @@ -59,9 +80,19 @@ def available_actions(cls): receiver_can_be_none = True allowed_topic_ref_types = ModelRefTypes(published=True, draft=True) - editable = False + editable = False # type: ignore - def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): + def can_create( + self, + identity: Identity, + data: dict, + receiver: EntityReference, + topic: Record, + creator: EntityReference, + *args: Any, + **kwargs: Any, + ) -> None: + """Check if the request can be created.""" if not topic.is_draft: raise ValueError("Trying to create publish request on published record") super().can_create(identity, data, receiver, topic, creator, *args, **kwargs) @@ -69,18 +100,30 @@ def can_create(self, identity, data, receiver, topic, creator, *args, **kwargs): topic_service.validate_draft(system_identity, topic["id"]) @classmethod - def is_applicable_to(cls, identity, topic, *args, **kwargs): + def is_applicable_to( + cls, identity: Identity, topic: Record, *args: Any, **kwargs: Any + ) -> bool: + """Check if the request type is applicable to the topic.""" if not topic.is_draft: return False super_ = super().is_applicable_to(identity, topic, *args, **kwargs) return super_ - def topic_change(self, request: Request, new_topic: Dict, uow): - setattr(request, "topic", new_topic) + def topic_change(self, request: Request, new_topic: dict, uow: UnitOfWork) -> None: + """Change the topic of the request.""" + request.topic = new_topic uow.register(RecordCommitOp(request, indexer=current_requests_service.indexer)) @override - def stateful_name(self, identity, *, topic, request=None, **kwargs): + def stateful_name( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful name of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _("Publish draft") if not request: @@ -92,7 +135,15 @@ def stateful_name(self, identity, *, topic, request=None, **kwargs): return _("Submit for review") @override - def stateful_description(self, identity, *, topic, request=None, **kwargs): + def stateful_description( + self, + identity: Identity, + *, + topic: Record, + request: Request | None = None, + **kwargs: Any, + ) -> str | LazyString: + """Return the stateful description of the request.""" if is_auto_approved(self, identity=identity, topic=topic): return _( "Click to immediately publish the draft. " diff --git a/oarepo_requests/types/ref_types.py b/oarepo_requests/types/ref_types.py index 7cb2559b..1d4e8358 100644 --- a/oarepo_requests/types/ref_types.py +++ b/oarepo_requests/types/ref_types.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Classes that define allowed reference types for the topic and receiver references.""" + +from __future__ import annotations + +from typing import Self + from invenio_records_resources.references import RecordResolver from invenio_requests.proxies import current_requests @@ -5,35 +18,34 @@ class ModelRefTypes: - """ - This class is used to define the allowed reference types for the topic reference. + """This class is used to define the allowed reference types for the topic reference. + The list of ref types is taken from the configuration (configuration key REQUESTS_ALLOWED_TOPICS). """ - def __init__(self, published=False, draft=False): + def __init__(self, published: bool = False, draft: bool = False) -> None: + """Initialize the class.""" self.published = published self.draft = draft - def __get__(self, obj, owner): + def __get__(self, obj: Self, owner: type[Self]) -> list[str]: """Property getter, returns the list of allowed reference types.""" ret = [] for ref_type in current_requests.entity_resolvers_registry: if not isinstance(ref_type, RecordResolver): continue - is_draft = getattr(ref_type.record_cls, "is_draft", False) - if self.published and not is_draft: - ret.append(ref_type.type_key) - elif self.draft and is_draft: + is_draft: bool = getattr(ref_type.record_cls, "is_draft", False) + if self.published and not is_draft or self.draft and is_draft: ret.append(ref_type.type_key) return ret class ReceiverRefTypes: - """ - This class is used to define the allowed reference types for the receiver reference. + """This class is used to define the allowed reference types for the receiver reference. + The list of ref types is taken from the configuration (configuration key REQUESTS_ALLOWED_RECEIVERS). """ - def __get__(self, obj, owner): + def __get__(self, obj: Self, owner: type[Self]) -> list[str]: """Property getter, returns the list of allowed reference types.""" return current_oarepo_requests.allowed_receiver_ref_types diff --git a/oarepo_requests/typing.py b/oarepo_requests/typing.py new file mode 100644 index 00000000..46b98dd6 --- /dev/null +++ b/oarepo_requests/typing.py @@ -0,0 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Base types for requests.""" + +type EntityReference = dict[str, str] diff --git a/oarepo_requests/ui/__init__.py b/oarepo_requests/ui/__init__.py index e69de29b..03d2215b 100644 --- a/oarepo_requests/ui/__init__.py +++ b/oarepo_requests/ui/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Requests UI pages and components.""" diff --git a/oarepo_requests/ui/components/__init__.py b/oarepo_requests/ui/components/__init__.py index 9613bc5f..422ad0ae 100644 --- a/oarepo_requests/ui/components/__init__.py +++ b/oarepo_requests/ui/components/__init__.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""UI components.""" + from oarepo_requests.ui.components.action_labels import ActionLabelsComponent from oarepo_requests.ui.components.custom_fields import ( FormConfigCustomFieldsComponent, diff --git a/oarepo_requests/ui/components/action_labels.py b/oarepo_requests/ui/components/action_labels.py index a5f9c04b..c220bf69 100644 --- a/oarepo_requests/ui/components/action_labels.py +++ b/oarepo_requests/ui/components/action_labels.py @@ -1,15 +1,43 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Component for adding action labels (button labels) to the request form config.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from oarepo_ui.resources.components import UIResourceComponent +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_requests.customizations import RequestType + class ActionLabelsComponent(UIResourceComponent): - def form_config(self, *, identity, view_args, form_config, **kwargs): - type_ = view_args.get("request_type") + """Component for adding action labels (button labels) to the request form config.""" + + def form_config( + self, + *, + identity: Identity, + view_args: dict[str, Any], + form_config: dict, + **kwargs: Any, + ) -> None: + """Add action labels to the form config.""" + type_: RequestType = view_args.get("request_type") action_labels = {} - for action_type, action in type_.available_actions.items(): - if hasattr(action, "stateful_name"): - name = action.stateful_name(identity, **kwargs) - else: - name = action_type.capitalize() - action_labels[action_type] = name + if type_: + for action_type, action in type_.available_actions.items(): + if hasattr(action, "stateful_name"): + name = action.stateful_name(identity, **kwargs) + else: + name = action_type.capitalize() + action_labels[action_type] = name form_config["action_labels"] = action_labels diff --git a/oarepo_requests/ui/components/custom_fields.py b/oarepo_requests/ui/components/custom_fields.py index ed307886..16354172 100644 --- a/oarepo_requests/ui/components/custom_fields.py +++ b/oarepo_requests/ui/components/custom_fields.py @@ -1,10 +1,34 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Request custom fields component.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from oarepo_ui.resources.components import UIResourceComponent +from oarepo_requests.types.generic import OARepoRequestType + +if TYPE_CHECKING: + from invenio_requests.customizations import RequestType + class FormConfigCustomFieldsComponent(UIResourceComponent): - def form_config(self, *, view_args, form_config, **kwargs): + """Component for adding custom fields to request's form config.""" + + def form_config( + self, *, view_args: dict[str, Any], form_config: dict, **kwargs: Any + ) -> None: + """Add custom fields to the form config.""" type_ = view_args.get("request_type") - form = getattr(type_, "form", None) + # ignore the type as we are checking for alternatives below + form: dict | list = getattr(type_, "form", None) # type: ignore if not form: return @@ -31,15 +55,21 @@ def form_config(self, *, view_args, form_config, **kwargs): class FormConfigRequestTypePropertiesComponent(UIResourceComponent): - def form_config(self, *, view_args, form_config, **kwargs): - type_ = view_args.get("request_type") + """Component for adding request type properties to request's form config.""" + + def form_config( + self, *, view_args: dict[str, Any], form_config: dict, **kwargs: Any + ) -> None: + """Add request type properties to the form config (dangerous, editable, has_form).""" + type_: RequestType = view_args.get("request_type") request_type_properties = {} - if hasattr(type_, "dangerous"): - request_type_properties["dangerous"] = type_.dangerous - if hasattr(type_, "editable"): - request_type_properties["editable"] = type_.editable - if hasattr(type_, "has_form"): - request_type_properties["has_form"] = type_.has_form + if type_ and isinstance(type_, OARepoRequestType): + if hasattr(type_, "dangerous"): + request_type_properties["dangerous"] = type_.dangerous + if hasattr(type_, "is_editable"): + request_type_properties["editable"] = type_.is_editable + if hasattr(type_, "has_form"): + request_type_properties["has_form"] = type_.has_form form_config["request_type_properties"] = request_type_properties diff --git a/oarepo_requests/ui/config.py b/oarepo_requests/ui/config.py index a42c888b..587139ad 100644 --- a/oarepo_requests/ui/config.py +++ b/oarepo_requests/ui/config.py @@ -1,5 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Config for the UI resources.""" + +from __future__ import annotations + import inspect -import typing +from typing import TYPE_CHECKING, Any, Mapping import marshmallow as ma from flask import current_app @@ -23,24 +34,34 @@ FormConfigRequestTypePropertiesComponent, ) +if TYPE_CHECKING: + from flask_resources.serializers.base import BaseSerializer + from invenio_records_resources.records import Record + from invenio_requests.customizations.request_types import RequestType + -def _get_custom_fields_ui_config(key, **kwargs): +def _get_custom_fields_ui_config(key: str, **kwargs: Any) -> list[dict]: return current_app.config.get(f"{key}_UI", []) class RequestTypeSchema(ma.fields.Str): + """Schema that makes sure that the request type is a valid request type.""" + def _deserialize( self, - value: typing.Any, + value: Any, attr: str | None, - data: typing.Mapping[str, typing.Any] | None, - **kwargs, - ): + data: Mapping[str, Any] | None, + **kwargs: Any, + ) -> RequestType: + """Deserialize the value and check if it is a valid request type.""" ret = super()._deserialize(value, attr, data, **kwargs) return current_request_type_registry.lookup(ret, quiet=True) class RequestsFormConfigResourceConfig(FormConfigResourceConfig): + """Config for the requests form config resource.""" + url_prefix = "/requests" blueprint_name = "oarepo_requests_form_config" components = [ @@ -56,6 +77,8 @@ class RequestsFormConfigResourceConfig(FormConfigResourceConfig): class RequestUIResourceConfig(UIResourceConfig): + """Config for request detail page.""" + url_prefix = "/requests" api_service = "requests" blueprint_name = "oarepo_requests_ui" @@ -83,21 +106,27 @@ class RequestUIResourceConfig(UIResourceConfig): request_view_args = {"pid_value": ma.fields.Str()} @property - def ui_serializer(self): + def ui_serializer(self) -> BaseSerializer: + """Return the UI serializer for the request.""" return obj_or_import_string(self.ui_serializer_class)() - def custom_fields(self, **kwargs): + def custom_fields(self, **kwargs: Any) -> dict: + """Get the custom fields for the request.""" api_service = current_service_registry.get(self.api_service) - # get the record class - record_class = getattr(api_service, "record_cls", None) - ui = [] + + ui: list[dict] = [] ret = { "ui": ui, } + + # get the record class + if not hasattr(api_service, "record_cls"): + return ret + record_class: type[Record] = api_service.record_cls if not record_class: return ret # try to get custom fields from the record - for fld_name, fld in sorted(inspect.getmembers(record_class)): + for _fld_name, fld in sorted(inspect.getmembers(record_class)): if isinstance(fld, InlinedCustomFields): prefix = "" elif isinstance(fld, CustomFields): diff --git a/oarepo_requests/ui/resource.py b/oarepo_requests/ui/resource.py index e51b57d5..948d5b91 100644 --- a/oarepo_requests/ui/resource.py +++ b/oarepo_requests/ui/resource.py @@ -1,4 +1,16 @@ -from flask import g +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""UI resource for requests - python part.""" + +from typing import Any + +from flask import Response, g +from flask_principal import Identity from flask_resources import resource_requestctx, route from invenio_records_resources.proxies import current_service_registry from invenio_records_resources.resources.records.resource import ( @@ -6,6 +18,8 @@ request_view_args, ) from invenio_records_resources.services import LinksTemplate +from invenio_requests.records.api import Request +from invenio_requests.services import RequestsService from oarepo_ui.proxies import current_oarepo_ui from oarepo_ui.resources.resource import UIResource from oarepo_ui.resources.templating.data import FieldData @@ -13,8 +27,11 @@ from oarepo_requests.ui.config import RequestUIResourceConfig -def make_links_absolute(links, api_prefix): - # make links absolute +def make_links_absolute(links: dict[str, str | Any], api_prefix: str) -> None: + """Convert links to at least absolute paths. + + If the links are already absolute, do nothing with them. If not, prepend /api{api_prefix} to them. + """ for k, v in list(links.items()): if not isinstance(v, str): continue @@ -24,13 +41,17 @@ def make_links_absolute(links, api_prefix): class RequestUIResource(UIResource): + """UI resource for request instances.""" + config: RequestUIResourceConfig + """UI resource configuration.""" @property - def api_service(self): + def api_service(self) -> RequestsService: + """Get the API service for this UI resource.""" return current_service_registry.get(self.config.api_service) - def create_url_rules(self): + def create_url_rules(self) -> list[dict]: """Create the URL rules for the record resource.""" routes = [] route_config = self.config.routes @@ -38,20 +59,30 @@ def create_url_rules(self): routes.append(route("GET", route_url, getattr(self, route_name))) return routes - def expand_detail_links(self, identity, record): - """Get links for this result item.""" + def expand_detail_links( + self, identity: Identity, request: Request + ) -> dict[str, str]: + """Get links for this result item. + + :param identity: Identity of the caller + :param request: Request to get the links for + """ tpl = LinksTemplate( self.config.ui_links_item, {"url_prefix": self.config.url_prefix} ) - return tpl.expand(identity, record) + return tpl.expand(identity, request) + + def _get_custom_fields(self, **kwargs: Any) -> dict: + """Get configuration of the custom fields. - def _get_custom_fields(self, **kwargs): + Note: request type is taken from the context. + """ return self.config.custom_fields(identity=g.identity, **kwargs) @request_read_args @request_view_args - def detail(self): - """Returns item detail page.""" + def detail(self) -> Response: + """Return item detail page.""" api_record = self.api_service.read( g.identity, resource_requestctx.view_args["pid_value"] ) @@ -66,7 +97,7 @@ def detail(self): record = self.config.ui_serializer.dump_obj(api_record.to_dict()) record.setdefault("links", {}) - ui_links = self.expand_detail_links(identity=g.identity, record=api_record) + ui_links = self.expand_detail_links(identity=g.identity, request=api_record) record["links"].update( { @@ -76,10 +107,10 @@ def detail(self): make_links_absolute(record["links"], self.config.url_prefix) - extra_context = dict() + extra_context: dict[str, Any] = {} # TODO: this needs to be reimplemented in: # https://linear.app/ducesnet/issue/BE-346/on-request-detail-page-generate-form-config-for-the-comment-stream - form_config = {} + form_config: dict[str, Any] = {} self.run_components( "form_config", @@ -127,28 +158,36 @@ def detail(self): ) @property - def ui_model(self): + def ui_model(self) -> dict[str, Any]: + """Return the ui model for requests.""" return current_oarepo_ui.ui_models.get( self.config.api_service.replace("-", "_"), {} ) def get_jinjax_macro( self, - template_type, - identity=None, - args=None, - view_args=None, - default_macro=None, - ): - """ - Returns which jinjax macro (name of the macro, including optional namespace in the form of "namespace.Macro") - should be used for rendering the template. + template_type: str, + *, + identity: Identity | None = None, + args: dict[str, Any] | None = None, + view_args: dict[str, Any] | None = None, + default_macro: str | None = None, + ) -> str: + """Return which jinjax macro should be used for rendering the template. + + :return name of the macro, including optional namespace in the form of "namespace.Macro". """ if default_macro: return self.config.templates.get(template_type, default_macro) return self.config.templates[template_type] - def tombstone(self, error, *args, **kwargs): + def tombstone(self, error: Exception, *args: Any, **kwargs: Any) -> Response: + """Render tombstone page for a deleted request. + + :param error: Exception that caused the tombstone page to be rendered (like PIDDeletedError). + :param args: Additional arguments. + :param kwargs: Additional keyword arguments. + """ return current_oarepo_ui.catalog.render( self.get_jinjax_macro( "tombstone", @@ -158,7 +197,8 @@ def tombstone(self, error, *args, **kwargs): pid=getattr(error, "pid_value", None) or getattr(error, "pid", None), ) - def not_found(self, error, *args, **kwargs): + def not_found(self, error: Exception, *args: Any, **kwargs: Any) -> Response: + """Render not found page for a request that never existed.""" return current_oarepo_ui.catalog.render( self.get_jinjax_macro( "not_found", @@ -168,7 +208,10 @@ def not_found(self, error, *args, **kwargs): pid=getattr(error, "pid_value", None) or getattr(error, "pid", None), ) - def permission_denied(self, error, *args, **kwargs): + def permission_denied( + self, error: Exception, *args: Any, **kwargs: Any + ) -> Response: + """Render permission denied page for a request that the user does not have access to.""" return current_oarepo_ui.catalog.render( self.get_jinjax_macro( "permission_denied", diff --git a/oarepo_requests/ui/theme/__init__.py b/oarepo_requests/ui/theme/__init__.py index e69de29b..efa5040f 100644 --- a/oarepo_requests/ui/theme/__init__.py +++ b/oarepo_requests/ui/theme/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""UI semantic-ui theme for the module.""" diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/translations/oarepo_requests_ui/messages/cs/LC_MESSAGES/translations.json b/oarepo_requests/ui/theme/assets/semantic-ui/translations/oarepo_requests_ui/messages/cs/LC_MESSAGES/translations.json index 76da48ab..70d570dc 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/translations/oarepo_requests_ui/messages/cs/LC_MESSAGES/translations.json +++ b/oarepo_requests/ui/theme/assets/semantic-ui/translations/oarepo_requests_ui/messages/cs/LC_MESSAGES/translations.json @@ -1 +1 @@ -{"status":"Stav","Create Request":"Vytvořit žádost","Open dialog for request":"Otevřít dialogové okno pro žádost","My Requests":"Moje žádosti","Requests to Approve":"Žádosti ke schválení","Are you sure?":"Jste si jistí?","Cancel":"Zrušit","OK":"OK","Create request":"Vytvořit žádost","Submit request":"Odeslat žádost","Delete request":"Smazat žádost","Delete":"Smazat","Accept request":"Přijmout žádost","Accept":"Přijmout","Decline request":"Zamítnout žádost","Decline":"Zamítnout","Create and submit request":"Vytvořit a odeslat žádost","Create and submit":"Vytvořit a odeslat","Error sending request":"Chyba při odesílání žádosti","Submit":"Odeslat","Save drafted request":"Uložit koncept žádosti","Save":"Uložit","Create":"Vytvořit","Creator":"Tvůrce","Receiver":"Příjemce","Request type":"Typ žádosti","Created":"Vytvořeno","Timeline":"Časová osa","Submit event":"Odeslat událost","No requests to show":"Žádné žádosti k zobrazení","api.requests":"API pro žádosti záznamu","Request deletion of published record":"Žádost o smazání zveřejněného záznamu","Request re-opening of published record":"Žádost o znovuotevření zveřejněného záznamu","Request publishing of a draft":"Žádost o kontrolu a zveřejnění konceptu","No status":"Beze stavu","Cannot send request. Please try again later.":"Nemohu poslat žádost. Prosím, zkuste později.","Cancel request":"Zrušit žádost","Submitted":"Odeslána","Expired":"Expirována","Accepted":"Přijata","Declined":"Zamítnuta","Cancelled":"Zrušena","Status":"Stav","Type":"Typ","Loading request types":"Načítám typy žádostí","Error loading request types":"Chyba při načítání typů žádostí","No new requests to create":"Nejsou dostupné žádné žádosti","Requests":"Žádosti","Error loading requests":"Chyba při načítání žádostí","Loading requests":"Načítám žádosti","Delete record":"Smazat záznam","Edit record":"Upravit záznam","Publish draft":"Publikovat","Close":"Zavřít","Request":"Žádost","Pending":"Schvalována","request":"žádost","Error while submitting comment.":"Chyba při odesílání komentáře.","Add comment":"Přidat komentář","optional":"nepovinné","Your comment here...":"Váš komentář...","Comment was not created successfully.":"Komentář nebyl úspěšně vytvořen.","Comment":"Komentář","Error while submitting the comment":"Chyba při odesílání komentáře","Back to requests":"Zpět na žádosti","Record":"Záznam","preview":"náhled","to top":"na začátek stránky","Loading timeline...":"Načítám časovou osu...","Error while fetching timeline events":"Chyba při načítání událostí časové osy","commented":"komnetoval","icon":"ikona","this request":"tato žádost","Comment must be at least 1 character long.":"Komentář musí být alespoň 1 znak dlouhý.","Invalid format.":"Neplatný formát.","Request status":"Stav žádosti","Request record deletion":"Zažádat smazání záznamu","Record deletion requested":"Zažádáno smazání záznamu","Click to permanently delete the record.":"Klikněte pro trvalé smazání záznamu.","Request permission to delete the record.":"Zažádat o povolení smazat záznam.","Permission to delete record requested. You will be notified about the decision by email.":"Zažádáno o povolení k smazání záznamu. O rozhodnutí budete informováni e-mailem.","You have been asked to approve the request to permanently delete the record. You can approve or reject the request.":"Byl jste požádán o schválení žádosti o trvalé smazání záznamu. Můžete žádost schválit nebo zamítnout.","Permission to delete record (including files) requested. ":"Zažádáno o povolení ke smazání záznamu (včetně souborů).","Submit request to get permission to delete the record.":"Odeslat žádost o povolení ke smazání záznamu.","Request edit access":"Žádost o úpravu záznamu","Edit access requested":"Zažádáno o úpravu záznamu","Click to start editing the metadata of the record.":"Klikněte pro začátek úprav metadat záznamu.","Request edit access to the record. You will be notified about the decision by email.":"Zažádat o úpravu záznamu. O rozhodnutí budete informováni e-mailem.","Edit access requested. You will be notified about the decision by email.":"Zažádáno o úpravu záznamu. O rozhodnutí budete informováni e-mailem.","You have been requested to grant edit access to the record.":"Byl jste požádán o udělení práva na upravení záznamu.","Edit access requested.":"Zažádáno o úpravu záznamu.","New Version":"Nová verze","Request requesting creation of new version of a published record.":"Žádost o vytvoření nové verze zveřejněného záznamu.","Request new version access":"Zažádat o vytvoření nové verzi","New version access requested":"Zažádáno o vytvoření nové verze","Click to start creating a new version of the record.":"Kliněte pro začátek vytváření nové verze záznamu.","Request permission to update record (including files). You will be notified about the decision by email.":"Zažádat o povolení aktualizace záznamu (včetně souborů). O rozhodnutí budete informováni e-mailem.","Permission to update record (including files) requested. You will be notified about the decision by email.":"Zažádáno o povolení aktualizace záznamu (včetně souborů). O rozhodnutí budete informováni e-mailem.","You have been asked to approve the request to update the record. You can approve or reject the request.":"Byl jste požádán o schválení žádosti o aktualizaci záznamu. Můžete žádost schválit nebo zamítnout.","Permission to update record (including files) requested. ":"Zažádáno o povolení aktualizace záznamu (včetně souborů).","Submit request to get edit access to the record.":"Odeslat žádost o povolení aktualizace záznamu (včetně souborů).","Resource version":"Verze záznamu","Write down the version (first, second…).":"Napište verzi (první, druhá…).","Submit for review":"Odeslat ke kontrole","Submitted for review":"Odesláno ke kontrole","Click to immediately publish the draft. The draft will be a subject to embargo as requested in the side panel. Note: The action is irreversible.":"Klikněte pro okamžité zveřejnění konceptu. Bylo-li zvoleno, záznam může podléhat embargu. Upozornění: Tuto akci není možno vzít zpět.","By submitting the draft for review you are requesting the publication of the draft. The draft will become locked and no further changes will be possible until the request is accepted or declined. You will be notified about the decision by email.":"Odesláním konceptu ke kontrole žádáte o zveřejnění konceptu. Koncept bude uzamčen a další změny nebudou možné, dokud nebude žádost přijata nebo zamítnuta. O rozhodnutí budete informováni e-mailem.","The draft has been submitted for review. It is now locked and no further changes are possible. You will be notified about the decision by email.":"Koncept byl odeslán ke kontrole. Nyní je uzamčen a další změny nejsou možné. O rozhodnutí budete informováni e-mailem.","The draft has been submitted for review. You can now accept or decline the request.":"Koncept Vám byl odeslán ke kontrole. Žádost můžete přijmout nebo zamítnout.","The draft has been submitted for review.":"Koncept byl odeslán ke kontrole.","Submit for review. After submitting the draft for review, it will be locked and no further modifications will be possible.":"Odeslat ke kontrole. Po odeslání konceptu ke kontrole bude záznam uzamčen a další změny nebudou možné.","Request not yet submitted.":"Žádost zatím nebyla odeslána.","Delete draft":"Smazat koncept","Request draft deletion":"Zažádat o smazání konceptu","Draft deletion requested":"Zažádáno o smazání konceptu","Click to permanently delete the draft.":"Klikněte pro trvalé smazání konceptu.","Request permission to delete the draft.":"Zažádat o povolení ke smazání konceptu.","Permission to delete draft requested. You will be notified about the decision by email.":"Zažádáno o povolení ke smazání konceptu. O rozhodnutí budete informováni emailem.","You have been asked to approve the request to permanently delete the draft. You can approve or reject the request.":"Byli jste požádáni o schválení žádosti o trvalé smazání konceptu. Můžete žádost schválit nebo odmítnout.","Permission to delete draft (including files) requested. ":"Zažádáno o povolení ke smazání konceptu (včetně souborů).","Submit request to get permission to delete the draft.":"Odeslat žádost o povolení ke smazání konceptu.","Request not created successfully. Please try again in a moment.":"Žádost nebyla úspěšně vytvořena. Zkuste to prosím za chvíli znovu.","Form fields could not be fetched.":"Formulářová pole nebyla načtena.","Request details":"Podrobnosti žádosti","Topic":"Téma","Request topic":"Téma žádosti","Are you sure you want to proceed? Once the action is completed, it will not be possible to undo it.":"Jste si jisti, že chcete pokračovat? Po dokončení akce ji nebude možné vrátit zpět.","Are you sure you wish to":"Jste si jisti, že chcete","Proceed":"Pokračovat","Are you sure you wish to proceed? After this request is accepted, it will not be possible to reverse the action.":"Jste si jisti, že chcete pokračovat? Po přijetí této žádosti nebude možné akci zvrátit.","The request could not be created due to validation errors. Please correct the errors and try again.":"Žádost nemohla být vytvořena kvůli validačním chybám. Prosím opravte chyby a zkuste to znovu.","The action could not be executed. Please try again in a moment.":"Akce nebyla úspěšně provedena. Zkuste to prosím za chvíli znovu.","Record has validation errors. Redirecting to form...":"Záznam obsahuje validační chyby. Přesměrování na formulář...","Comment was not submitted successfully.":"Komentář nebyl úspěšně odeslán.","Leave comment":"Zanechat komentář","Request deletion of draft":"Zažádat o smazání konceptu","It is highly recommended to provide an explanation for the rejection of the request. Note that it is always possible to provide explanation later on the request timeline.":"Důrazně doporučujeme poskytnout vysvětlení zamítnutí žádosti. Všimněte si, že je vždy možné poskytnout vysvětlení později v rámci časové osy žádosti.","Record preview":"Náhled záznamu","This action is irreversible. Are you sure you wish to accept this request?":"Tato akce je nevratná. Opravdu si přejete tuto žádost přijmout?","requestCreated":"{{creatorLabel}} vytvořil(a) tuto žádost","requestSubmitted":"{{creatorLabel}} odeslal(a) tuto žádost","requestCancelled":"{{creatorLabel}} zrušil(a) tuto žádost","requestAccepted":"{{creatorLabel}} přijal(a) tuto žádost","requestDeclined":"{{creatorLabel}} odmítl(a) tuto žádost","Request expired.":"Žádost vypršela","requestDeleted":"{{creatorLabel}} smazal(a) tuto žádost","requestCommented":"{{creatorLabel}} komentoval(a)","Permanently delete":"Trvale smazat","Keep the record":"Ponechat záznam","Publish":"Publikovat","Return for correction":"Vrátit k opravě","api.applicable-requests":"API pro vytváření žádostí","System user":"Systémový uživatel"} \ No newline at end of file +{"status":"Stav","Create Request":"Vytvořit žádost","Open dialog for request":"Otevřít dialogové okno pro žádost","My Requests":"Moje žádosti","Requests to Approve":"Žádosti ke schválení","Are you sure?":"Jste si jistí?","Cancel":"Zrušit","OK":"OK","Create request":"Vytvořit žádost","Submit request":"Odeslat žádost","Delete request":"Smazat žádost","Delete":"Smazat","Accept request":"Přijmout žádost","Accept":"Přijmout","Decline request":"Zamítnout žádost","Decline":"Zamítnout","Create and submit request":"Vytvořit a odeslat žádost","Create and submit":"Vytvořit a odeslat","Error sending request":"Chyba při odesílání žádosti","Submit":"Odeslat","Save drafted request":"Uložit koncept žádosti","Save":"Uložit","Create":"Vytvořit","Creator":"Tvůrce","Receiver":"Příjemce","Request type":"Typ žádosti","Created":"Vytvořeno","Timeline":"Časová osa","Submit event":"Odeslat událost","No requests to show":"Žádné žádosti k zobrazení","api.requests":"API pro žádosti záznamu","Request deletion of published record":"Žádost o smazání zveřejněného záznamu","Request re-opening of published record":"Žádost o znovuotevření zveřejněného záznamu","Request publishing of a draft":"Žádost o kontrolu a zveřejnění konceptu","No status":"Beze stavu","Cannot send request. Please try again later.":"Nemohu poslat žádost. Prosím, zkuste později.","Cancel request":"Zrušit žádost","Submitted":"Odeslána","Expired":"Expirována","Accepted":"Přijata","Declined":"Zamítnuta","Cancelled":"Zrušena","Status":"Stav","Type":"Typ","Loading request types":"Načítám typy žádostí","Error loading request types":"Chyba při načítání typů žádostí","No new requests to create":"Nejsou dostupné žádné žádosti","Requests":"Žádosti","Error loading requests":"Chyba při načítání žádostí","Loading requests":"Načítám žádosti","Delete record":"Smazat záznam","Edit record":"Upravit záznam","Publish draft":"Publikovat","Close":"Zavřít","Request":"Žádost","Pending":"Schvalována","request":"žádost","Error while submitting comment.":"Chyba při odesílání komentáře.","Add comment":"Přidat komentář","optional":"nepovinné","Your comment here...":"Váš komentář...","Comment was not created successfully.":"Komentář nebyl úspěšně vytvořen.","Comment":"Komentář","Error while submitting the comment":"Chyba při odesílání komentáře","Back to requests":"Zpět na žádosti","Record":"Záznam","preview":"náhled","to top":"na začátek stránky","Loading timeline...":"Načítám časovou osu...","Error while fetching timeline events":"Chyba při načítání událostí časové osy","commented":"komnetoval","icon":"ikona","this request":"tato žádost","Comment must be at least 1 character long.":"Komentář musí být alespoň 1 znak dlouhý.","Invalid format.":"Neplatný formát.","Request status":"Stav žádosti","Request record deletion":"Zažádat smazání záznamu","Record deletion requested":"Zažádáno smazání záznamu","Click to permanently delete the record.":"Klikněte pro trvalé smazání záznamu.","Request permission to delete the record.":"Zažádat o povolení smazat záznam.","Permission to delete record requested. You will be notified about the decision by email.":"Zažádáno o povolení k smazání záznamu. O rozhodnutí budete informováni e-mailem.","You have been asked to approve the request to permanently delete the record. You can approve or reject the request.":"Byl jste požádán o schválení žádosti o trvalé smazání záznamu. Můžete žádost schválit nebo zamítnout.","Permission to delete record (including files) requested. ":"Zažádáno o povolení ke smazání záznamu (včetně souborů).","Submit request to get permission to delete the record.":"Odeslat žádost o povolení ke smazání záznamu.","Request edit access":"Žádost o úpravu záznamu","Edit access requested":"Zažádáno o úpravu záznamu","Click to start editing the metadata of the record.":"Klikněte pro začátek úprav metadat záznamu.","Request edit access to the record. You will be notified about the decision by email.":"Zažádat o úpravu záznamu. O rozhodnutí budete informováni e-mailem.","Edit access requested. You will be notified about the decision by email.":"Zažádáno o úpravu záznamu. O rozhodnutí budete informováni e-mailem.","You have been requested to grant edit access to the record.":"Byl jste požádán o udělení práva na upravení záznamu.","Edit access requested.":"Zažádáno o úpravu záznamu.","New Version":"Nová verze","Request requesting creation of new version of a published record.":"Žádost o vytvoření nové verze zveřejněného záznamu.","Request new version access":"Zažádat o vytvoření nové verzi","New version access requested":"Zažádáno o vytvoření nové verze","Click to start creating a new version of the record.":"Kliněte pro začátek vytváření nové verze záznamu.","Request permission to update record (including files). You will be notified about the decision by email.":"Zažádat o povolení aktualizace záznamu (včetně souborů). O rozhodnutí budete informováni e-mailem.","Permission to update record (including files) requested. You will be notified about the decision by email.":"Zažádáno o povolení aktualizace záznamu (včetně souborů). O rozhodnutí budete informováni e-mailem.","You have been asked to approve the request to update the record. You can approve or reject the request.":"Byl jste požádán o schválení žádosti o aktualizaci záznamu. Můžete žádost schválit nebo zamítnout.","Permission to update record (including files) requested. ":"Zažádáno o povolení aktualizace záznamu (včetně souborů).","Submit request to get edit access to the record.":"Odeslat žádost o povolení aktualizace záznamu (včetně souborů).","Resource version":"Verze záznamu","Write down the version (first, second…).":"Napište verzi (první, druhá…).","Submit for review":"Odeslat ke kontrole","Submitted for review":"Odesláno ke kontrole","Click to immediately publish the draft. The draft will be a subject to embargo as requested in the side panel. Note: The action is irreversible.":"Klikněte pro okamžité zveřejnění konceptu. Bylo-li zvoleno, záznam může podléhat embargu. Upozornění: Tuto akci není možno vzít zpět.","By submitting the draft for review you are requesting the publication of the draft. The draft will become locked and no further changes will be possible until the request is accepted or declined. You will be notified about the decision by email.":"Odesláním konceptu ke kontrole žádáte o zveřejnění konceptu. Koncept bude uzamčen a další změny nebudou možné, dokud nebude žádost přijata nebo zamítnuta. O rozhodnutí budete informováni e-mailem.","The draft has been submitted for review. It is now locked and no further changes are possible. You will be notified about the decision by email.":"Koncept byl odeslán ke kontrole. Nyní je uzamčen a další změny nejsou možné. O rozhodnutí budete informováni e-mailem.","The draft has been submitted for review. You can now accept or decline the request.":"Koncept Vám byl odeslán ke kontrole. Žádost můžete přijmout nebo zamítnout.","The draft has been submitted for review.":"Koncept byl odeslán ke kontrole.","Submit for review. After submitting the draft for review, it will be locked and no further modifications will be possible.":"Odeslat ke kontrole. Po odeslání konceptu ke kontrole bude záznam uzamčen a další změny nebudou možné.","Request not yet submitted.":"Žádost zatím nebyla odeslána.","Delete draft":"Smazat koncept","Request draft deletion":"Zažádat o smazání konceptu","Draft deletion requested":"Zažádáno o smazání konceptu","Click to permanently delete the draft.":"Klikněte pro trvalé smazání konceptu.","Request permission to delete the draft.":"Zažádat o povolení ke smazání konceptu.","Permission to delete draft requested. You will be notified about the decision by email.":"Zažádáno o povolení ke smazání konceptu. O rozhodnutí budete informováni emailem.","You have been asked to approve the request to permanently delete the draft. You can approve or reject the request.":"Byli jste požádáni o schválení žádosti o trvalé smazání konceptu. Můžete žádost schválit nebo odmítnout.","Permission to delete draft (including files) requested. ":"Zažádáno o povolení ke smazání konceptu (včetně souborů).","Submit request to get permission to delete the draft.":"Odeslat žádost o povolení ke smazání konceptu.","Request not created successfully. Please try again in a moment.":"Žádost nebyla úspěšně vytvořena. Zkuste to prosím za chvíli znovu.","Form fields could not be fetched.":"Formulářová pole nebyla načtena.","Request details":"Podrobnosti žádosti","Topic":"Téma","Request topic":"Téma žádosti","Are you sure you want to proceed? Once the action is completed, it will not be possible to undo it.":"Jste si jisti, že chcete pokračovat? Po dokončení akce ji nebude možné vrátit zpět.","Are you sure you wish to":"Jste si jisti, že chcete","Proceed":"Pokračovat","Are you sure you wish to proceed? After this request is accepted, it will not be possible to reverse the action.":"Jste si jisti, že chcete pokračovat? Po přijetí této žádosti nebude možné akci zvrátit.","The request could not be created due to validation errors. Please correct the errors and try again.":"Žádost nemohla být vytvořena kvůli validačním chybám. Prosím opravte chyby a zkuste to znovu.","The action could not be executed. Please try again in a moment.":"Akce nebyla úspěšně provedena. Zkuste to prosím za chvíli znovu.","Record has validation errors. Redirecting to form...":"Záznam obsahuje validační chyby. Přesměrování na formulář...","Comment was not submitted successfully.":"Komentář nebyl úspěšně odeslán.","Leave comment":"Zanechat komentář","Request deletion of draft":"Zažádat o smazání konceptu","It is highly recommended to provide an explanation for the rejection of the request. Note that it is always possible to provide explanation later on the request timeline.":"Důrazně doporučujeme poskytnout vysvětlení zamítnutí žádosti. Všimněte si, že je vždy možné poskytnout vysvětlení později v rámci časové osy žádosti.","Record preview":"Náhled záznamu","This action is irreversible. Are you sure you wish to accept this request?":"Tato akce je nevratná. Opravdu si přejete tuto žádost přijmout?","requestCreated":"{{creatorLabel}} vytvořil(a) tuto žádost","requestSubmitted":"{{creatorLabel}} odeslal(a) tuto žádost","requestCancelled":"{{creatorLabel}} zrušil(a) tuto žádost","requestAccepted":"{{creatorLabel}} přijal(a) tuto žádost","requestDeclined":"{{creatorLabel}} odmítl(a) tuto žádost","Request expired.":"Žádost vypršela","requestDeleted":"{{creatorLabel}} smazal(a) tuto žádost","requestCommented":"{{creatorLabel}} komentoval(a)","Permanently delete":"Trvale smazat","Keep the record":"Ponechat záznam","Publish":"Publikovat","Return for correction":"Vrátit k opravě","api.applicable-requests":"API pro vytváření žádostí","System user":"Systémový uživatel","Keep files.":"Ponechat soubory.","Keep files in the new version?":"Ponechat soubory v nové verzi?","You do not have permission to delete the draft.":"Nemáte právo smazat rozpracovaný záznam.","You do not have permission to delete the record.":"Nemáte právo smazat záznam.","You do not have permission to update the record.":"Nemáte právo zažádat o aktualizaci záznamu."} \ No newline at end of file diff --git a/oarepo_requests/ui/theme/webpack.py b/oarepo_requests/ui/theme/webpack.py index f3c53494..92d26d48 100644 --- a/oarepo_requests/ui/theme/webpack.py +++ b/oarepo_requests/ui/theme/webpack.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Webpack entry points for the UI components of the module (requests components and dialogs).""" + from invenio_assets.webpack import WebpackThemeBundle theme = WebpackThemeBundle( diff --git a/oarepo_requests/ui/views.py b/oarepo_requests/ui/views.py index 228202c9..ca6a946f 100644 --- a/oarepo_requests/ui/views.py +++ b/oarepo_requests/ui/views.py @@ -1,3 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Views for the UI (pages and form config for requests).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from oarepo_ui.resources.resource import FormConfigResource from oarepo_requests.ui.config import ( @@ -6,12 +19,15 @@ ) from oarepo_requests.ui.resource import RequestUIResource +if TYPE_CHECKING: + from flask import Blueprint, Flask + -def create_blueprint(app): +def create_blueprint(app: Flask) -> Blueprint: """Register blueprint for this resource.""" return RequestUIResource(RequestUIResourceConfig()).as_blueprint() -def create_requests_form_config_blueprint(app): - """Register blueprint for form config resource""" +def create_requests_form_config_blueprint(app: Flask) -> Blueprint: + """Register blueprint for form config resource.""" return FormConfigResource(RequestsFormConfigResourceConfig()).as_blueprint() diff --git a/oarepo_requests/utils.py b/oarepo_requests/utils.py index da453308..14ab0515 100644 --- a/oarepo_requests/utils.py +++ b/oarepo_requests/utils.py @@ -1,4 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Utility functions for the requests module.""" + +from __future__ import annotations + import copy +from typing import TYPE_CHECKING, Any, Callable from invenio_access.permissions import system_identity from invenio_pidstore.errors import PersistentIdentifierError @@ -9,87 +21,132 @@ ) from invenio_requests.resolvers.registry import ResolverRegistry from invenio_search.engine import dsl +from oarepo_workflows import ( + AutoApprove, + Workflow, + WorkflowRequest, + WorkflowRequestPolicy, +) +from oarepo_workflows.errors import MissingWorkflowError +from oarepo_workflows.proxies import current_oarepo_workflows -try: - from oarepo_workflows import AutoApprove, Workflow, WorkflowRequest - from oarepo_workflows.errors import MissingWorkflowError - from oarepo_workflows.proxies import current_oarepo_workflows -except ImportError: - current_oarepo_workflows = None - Workflow = None - WorkflowRequest = None - AutoApprove = None - MissingWorkflowError = None - - -def allowed_request_types_for_record(record): - if current_oarepo_workflows: - try: - workflow_requests = current_oarepo_workflows.get_workflow(record).requests() - except MissingWorkflowError: - # workflow not defined on the record, probably not a workflow-enabled record - # so returning all matching request types - # TODO: is this correct? - workflow_requests = None - else: - workflow_requests = None - - request_types = current_request_type_registry._registered_types - ret = {} +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records import Record + from invenio_records_resources.services import RecordService + from invenio_requests.customizations.request_types import RequestType + from opensearch_dsl.query import Query + + from oarepo_requests.typing import EntityReference + + from .services.record.service import RecordRequestsService + + +class classproperty[T]: + """Class property decorator as decorator chaining for declaring class properties was deprecated in python 3.11.""" + + def __init__(self, func: Callable): + """Initialize the class property.""" + self.fget = func + + def __get__(self, instance: Any, owner: Any) -> T: + """Get the value of the class property.""" + return self.fget(owner) + + +def allowed_request_types_for_record( + identity: Identity, record: Record +) -> dict[str, RequestType]: + """Return allowed request types for the record. + + If there is a workflow defined on the record, only request types allowed by the workflow are returned. + + :param identity: Identity of the user. Only the request types for which user can create a request are returned. + :param record: Record to get allowed request types for. + :return: Dict of request types allowed for the record. + """ + workflow_requests: WorkflowRequestPolicy | None try: - record_ref = list(ResolverRegistry.reference_entity(record).keys())[0] - except: - # log? - return ret - for request_name, request_type in request_types.items(): - allowed_type_keys = set( - request_type.allowed_topic_ref_types - ) # allowed topic types does not work correctly - delete published allows community and documents_file_draft - if record_ref in allowed_type_keys: - if not workflow_requests or hasattr(workflow_requests, request_name): - ret[request_name] = request_type + workflow_requests = current_oarepo_workflows.get_workflow(record).requests() + return { + type_id: wr.request_type + for (type_id, wr) in workflow_requests.applicable_workflow_requests( + identity, record=record + ) + } + except MissingWorkflowError: + # workflow not defined on the record, probably not a workflow-enabled record + # so returning all matching request types + pass + + record_ref = list(ResolverRegistry.reference_entity(record).keys())[0] + + ret = {} + for rt in current_request_type_registry: + if record_ref in rt.allowed_topic_ref_types: + ret[rt.type_id] = rt + return ret -def _reference_query_term(term, reference): +def create_query_term_for_reference( + field_name: str, reference: EntityReference +) -> Query: + """Create an opensearch query term for the reference. + + :param field_name: Field name to search in (can be "topic", "receiver", ...). + :param reference: Reference to search for. + :return: Opensearch query term. + """ return dsl.Q( - "term", **{f"{term}.{list(reference.keys())[0]}": list(reference.values())[0]} + "term", + **{f"{field_name}.{list(reference.keys())[0]}": list(reference.values())[0]}, ) def search_requests_filter( - type_id, - topic_reference=None, - receiver_reference=None, - creator_reference=None, - is_open=False, - add_filter=None, - or_filter=None, -): + type_id: str, + topic_reference: dict | None = None, + receiver_reference: dict | None = None, + creator_reference: dict | None = None, + is_open: bool | None = None, +) -> Query: + """Create a search filter for requests of a given request type. + + :param type_id: Request type id. + :param topic_reference: Reference to the topic, optional + :param receiver_reference: Reference to the receiver, optional + :param creator_reference: Reference to the creator, optional + :param is_open: Whether the request is open or closed. If not set, both open and closed requests are returned. + """ must = [ dsl.Q("term", **{"type": type_id}), - dsl.Q("term", **{"is_open": is_open}), ] + if is_open is not None: + must.append(dsl.Q("term", **{"is_open": is_open})) if receiver_reference: - must.append(_reference_query_term("receiver", receiver_reference)) + must.append(create_query_term_for_reference("receiver", receiver_reference)) if creator_reference: - must.append(_reference_query_term("creator", creator_reference)) + must.append(create_query_term_for_reference("creator", creator_reference)) if topic_reference: - must.append(_reference_query_term("topic", topic_reference)) + must.append(create_query_term_for_reference("topic", topic_reference)) extra_filter = dsl.query.Bool( "must", must=must, ) - if add_filter: - extra_filter &= add_filter - if or_filter: - extra_filter |= or_filter return extra_filter -def open_request_exists(topic_or_reference, type_id): +def open_request_exists( + topic_or_reference: Record | EntityReference, type_id: str +) -> bool: + """Check if there is an open request of a given type for the topic. + + :param topic_or_reference: Topic record or reference to the record in the form {"datasets": "id"}. + :param type_id: Request type id. + """ topic_reference = ResolverRegistry.reference_entity(topic_or_reference, raise_=True) base_filter = search_requests_filter( type_id=type_id, topic_reference=topic_reference, is_open=True @@ -100,51 +157,87 @@ def open_request_exists(topic_or_reference, type_id): return bool(list(results)) -# TODO these things are related and possibly could be approached in a less convoluted manner? For example, global model->services map would help -def resolve_reference_dict(reference_dict): - topic_resolver = None - for resolver in ResolverRegistry.get_registered_resolvers(): - try: - if resolver.matches_reference_dict(reference_dict): - topic_resolver = resolver - break - except ValueError: - # Value error ignored from matches_reference_dict - pass - obj = topic_resolver.get_entity_proxy(reference_dict).resolve() - return obj - - -def get_matching_service_for_refdict(reference_dict): +def resolve_reference_dict(reference_dict: EntityReference) -> Record: + """Resolve the reference dict to the entity (such as Record, User, ...).""" + return ResolverRegistry.resolve_entity_proxy(reference_dict).resolve() + + +def get_matching_service_for_refdict( + reference_dict: EntityReference, +) -> RecordService | None: + """Get the service that is responsible for entities matching the reference dict. + + :param reference_dict: Reference dict in the form {"datasets": "id"}. + :return: Service that is responsible for the entity or None if the entity does not have an associated service + """ for resolver in ResolverRegistry.get_registered_resolvers(): if resolver.matches_reference_dict(reference_dict): return current_service_registry.get(resolver._service_id) return None -def get_type_id_for_record_cls(record_cls): +def get_entity_key_for_record_cls(record_cls: type[Record]) -> str: + """Get the entity type id for the record_cls. + + :param record_cls: Record class. + :return: Entity type id + """ for resolver in ResolverRegistry.get_registered_resolvers(): if hasattr(resolver, "record_cls") and resolver.record_cls == record_cls: return resolver.type_id - return None + raise AttributeError( + f"Record class {record_cls} does not have a registered entity resolver." + ) -def get_requests_service_for_records_service(records_service): +def get_requests_service_for_records_service( + records_service: RecordService, +) -> RecordRequestsService: + """Get the requests service for the records service. + + :param records_service: Records service. + :return: Requests service for the records service. + """ return current_service_registry.get(f"{records_service.config.service_id}_requests") -def stringify_first_val(dct): +def stringify_first_val[T](dct: T) -> T: + """Convert the top-level value in a dictionary to string. + + Does nothing if the value is not a dictionary. + + :param dct: Dictionary to convert (or a value of any other type). + :return dct with the top-level value converted to string. + """ if isinstance(dct, dict): for k, v in dct.items(): dct[k] = str(v) return dct -def reference_to_tuple(reference): - return (list(reference.keys())[0], list(reference.values())[0]) +def reference_to_tuple(reference: EntityReference) -> tuple[str, str]: + """Convert the reference dict to a tuple. + + :param reference: Reference dict in the form {"datasets": "id"}. + :return: Tuple in the form ("datasets", "id"). + """ + return next(iter(reference.items())) + +# TODO: consider moving to oarepo-workflows +def get_receiver_for_request_type( + request_type: RequestType, identity: Identity, topic: Record +) -> EntityReference | None: + """Get the default receiver for the request type, identity and topic. + This call gets the workflow from the topic, looks up the request inside the workflow + and evaluates workflow recipients for the request and topic and returns them. + If the request has no matching receiver, None is returned. -def get_receiver_for_request_type(request_type, identity, topic): + :param request_type: Request type. + :param identity: Identity of the caller who wants to create a request of this type + :param topic: Topic record for the request + :return: Receiver for the request type from workflow or None if no receiver + """ if not topic: return None @@ -158,7 +251,7 @@ def get_receiver_for_request_type(request_type, identity, topic): except KeyError: return None - receivers = workflow_request.reference_receivers( + receivers = workflow_request.recipient_entity_reference( identity=identity, record=topic, request_type=request_type, creator=identity ) if not receivers: @@ -167,23 +260,48 @@ def get_receiver_for_request_type(request_type, identity, topic): return receivers -def is_auto_approved(request_type, *, identity=None, topic=None, receiver=None): +# TODO: consider moving to oarepo-workflows +def is_auto_approved( + request_type: RequestType, + *, + identity: Identity, + topic: Record, +) -> bool: + """Check if the request should be auto-approved. + + If identity creates a request of the given request type on the given topic, + the function checks if the request should be auto-approved. + """ if not current_oarepo_workflows: return False - if not receiver: - receiver = get_receiver_for_request_type( - request_type=request_type, identity=identity, topic=topic - ) + receiver = get_receiver_for_request_type( + request_type=request_type, identity=identity, topic=topic + ) - return receiver and ( - isinstance(receiver, AutoApprove) - or isinstance(receiver, dict) - and receiver.get("auto_approve") + return bool( + receiver + and ( + isinstance(receiver, AutoApprove) + or isinstance(receiver, dict) + and receiver.get("auto_approve") + ) ) -def request_identity_matches(entity_reference, identity): +def request_identity_matches( + entity_reference: EntityReference, identity: Identity +) -> bool: + """Check if the identity matches the entity reference. + + Identity matches the entity reference if the needs provided by the entity reference + intersect with the needs provided by the identity. For example, if the entity reference + provides [CommunityRoleNeed(comm_id, 'curator'), ActionNeed("administration")] and the + identity provides [CommunityRoleNeed(comm_id, 'curator')], the function returns True. + + :param entity_reference: Entity reference in the form {"datasets": "id"}. + :param identity: Identity to check. + """ if not entity_reference: return False @@ -194,9 +312,11 @@ def request_identity_matches(entity_reference, identity): return bool(identity.provides.intersection(needs)) except PersistentIdentifierError: return False + return False -def merge_resource_configs(config_to_merge_in, original_config): +def merge_resource_configs[T](config_to_merge_in: T, original_config: Any) -> T: + """Merge resource configurations.""" actual_config = copy.deepcopy(config_to_merge_in) original_keys = {x for x in dir(original_config) if not x.startswith("_")} merge_in_keys = { diff --git a/oarepo_requests/views/__init__.py b/oarepo_requests/views/__init__.py index e69de29b..4488fdd7 100644 --- a/oarepo_requests/views/__init__.py +++ b/oarepo_requests/views/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Views.""" diff --git a/oarepo_requests/views/api.py b/oarepo_requests/views/api.py index b2c573c3..dfc0fa10 100644 --- a/oarepo_requests/views/api.py +++ b/oarepo_requests/views/api.py @@ -1,4 +1,21 @@ -def create_oarepo_requests(app): +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""API views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flask import Blueprint, Flask + + +def create_oarepo_requests(app: Flask) -> Blueprint: """Create requests blueprint.""" ext = app.extensions["oarepo-requests"] blueprint = ext.requests_resource.as_blueprint() @@ -6,21 +23,12 @@ def create_oarepo_requests(app): from oarepo_requests.invenio_patches import override_invenio_requests_config blueprint.record_once(override_invenio_requests_config) - blueprint.record_once(register_autoapprove_entity_resolver) return blueprint -def register_autoapprove_entity_resolver(state): - from oarepo_requests.resolvers.autoapprove import AutoApproveResolver - - app = state.app - requests = app.extensions["invenio-requests"] - requests.entity_resolvers_registry.register_type(AutoApproveResolver()) - - -def create_oarepo_requests_events(app): - """Create requests blueprint.""" +def create_oarepo_requests_events(app: Flask) -> Blueprint: + """Create events blueprint.""" ext = app.extensions["oarepo-requests"] blueprint = ext.request_events_resource.as_blueprint() return blueprint diff --git a/oarepo_requests/views/app.py b/oarepo_requests/views/app.py index eb6c299c..1803bd23 100644 --- a/oarepo_requests/views/app.py +++ b/oarepo_requests/views/app.py @@ -1,21 +1,36 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Blueprints for the app and events views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import Blueprint +if TYPE_CHECKING: + from flask import Flask -def create_app_blueprint(app): - blueprint = Blueprint("oarepo_requests_app", __name__, url_prefix="/requests/") - blueprint.record_once(register_autoapprove_entity_resolver) - return blueprint +def create_app_blueprint(app: Flask) -> Blueprint: + """Create a blueprint for the requests endpoint. -def register_autoapprove_entity_resolver(state): - from oarepo_requests.resolvers.autoapprove import AutoApproveResolver + :param app: Flask application + """ + blueprint = Blueprint("oarepo_requests_app", __name__, url_prefix="/requests/") + return blueprint - app = state.app - requests = app.extensions["invenio-requests"] - requests.entity_resolvers_registry.register_type(AutoApproveResolver()) +def create_app_events_blueprint(app: Flask) -> Blueprint: + """Create a blueprint for the requests events endpoint. -def create_app_events_blueprint(app): + :param app: Flask application + """ blueprint = Blueprint( "oarepo_requests_events_app", __name__, url_prefix="/requests/" ) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..e0da3684 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,30 @@ +exclude = [ + "tests" +] + +[lint] +extend-select = [ + "UP", # pyupgrade + "D", # pydocstyle + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "TCH", # type checking + "ANN", # annotations + "DOC", # docstrings +] + +ignore = [ + "ANN101", # Missing type annotation for self in method + "ANN102", # Missing type annotation for cls in classmethod + "ANN204", # Missing return type annotation in __init__ method + "ANN401", # we are using Any in kwargs, so ignore those + "UP007", # Imho a: Optional[int] = None is more readable than a: (int | None) = None for kwargs + + "D203", # 1 blank line required before class docstring (we use D211) + "D213", # Multi-line docstring summary should start at the second line - we use D212 (starting on the same line) + "D404", # First word of the docstring should not be This +] + +[lint.flake8-annotations] +mypy-init-return = true diff --git a/run-tests.sh b/run-tests.sh index c147ce30..d28d3108 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -2,6 +2,7 @@ set -e OAREPO_VERSION=${OAREPO_VERSION:-12} +PYTHON="${PYTHON:-python3.12}" MODEL="thesis" @@ -15,7 +16,7 @@ if test -d $BUILDER_VENV ; then rm -rf $BUILDER_VENV fi -python3 -m venv $BUILDER_VENV +"${PYTHON}" -m venv $BUILDER_VENV . $BUILDER_VENV/bin/activate pip install -U setuptools pip wheel pip install -U oarepo-model-builder \ @@ -37,13 +38,14 @@ MODEL_VENV=".venv-tests" if test -d $MODEL_VENV; then rm -rf $MODEL_VENV fi -python3 -m venv $MODEL_VENV +"${PYTHON}" -m venv $MODEL_VENV . $MODEL_VENV/bin/activate pip install -U setuptools pip wheel pip install "oarepo[tests]==$OAREPO_VERSION.*" pip install -e "./$BUILD_TEST_DIR/${MODEL}" -pip install oarepo-ui -pip install deepdiff + +# local development +# pip install --config-settings editable_mode=compat -e ../oarepo-workflows # Check if we can import all the sources find oarepo_requests -name '*.py' | grep -v '__init__.py' | sed 's/.py$//' | tr '/' '.' | sort -u | while read MODULE ; do diff --git a/setup.cfg b/setup.cfg index 4bb9dc3f..25bf5ca3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = oarepo-requests -version = 2.2.11 +version = 2.3.0 description = authors = Ronald Krist readme = README.md @@ -9,10 +9,11 @@ long_description_content_type = text/markdown [options] -python = >=3.9 +python = >=3.12 install_requires = invenio-requests oarepo-runtime + oarepo-workflows packages = find: include_package_data = True @@ -27,7 +28,8 @@ exclude = [options.extras_require] tests = - oarepo-workflows + deepdiff + oarepo-ui [options.entry_points] invenio_base.api_apps = @@ -52,8 +54,6 @@ invenio_requests.types = 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 -invenio_requests.entity_resolvers = - auto_approve = oarepo_requests.resolvers.autoapprove:AutoApproveResolver oarepo_workflows.state_changed_notifiers = auto-requester = oarepo_requests.services.permissions.requester:auto_request_state_change_notifier invenio_base.finalize_app = diff --git a/setup.py b/setup.py index 60684932..b0e600af 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Setup file for the package.""" + from setuptools import setup setup() diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..638694e0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# diff --git a/tests/conftest.py b/tests/conftest.py index 3aa173f8..840c4ed6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import copy import os from io import BytesIO @@ -37,8 +44,9 @@ WorkflowTransitions, ) from oarepo_workflows.base import Workflow -from oarepo_workflows.requests import RecipientGeneratorMixin from oarepo_workflows.requests.events import WorkflowEvent +from oarepo_workflows.requests.generators import RecipientGeneratorMixin + from thesis.proxies import current_service from thesis.records.api import ThesisDraft @@ -48,10 +56,10 @@ OARepoSubmitAction, ) from oarepo_requests.receiver import default_workflow_receiver_function -from oarepo_requests.services.permissions.generators import ( - IfNoEditDraft, - IfNoNewVersionDraft, +from oarepo_requests.services.permissions.generators.conditional import ( IfRequestedBy, + IfNoNewVersionDraft, + IfNoEditDraft, ) from oarepo_requests.services.permissions.workflow_policies import ( RequestBasedWorkflowPermissions, @@ -110,7 +118,10 @@ class DefaultRequests(WorkflowRequestPolicy): ), ) delete_draft = WorkflowRequest( - requesters=[IfInState("draft", [RecordOwners()])], + requesters=[ + IfInState("draft", [RecordOwners()]), + IfInState("publishing", [RecordOwners()]), + ], recipients=[AutoApprove()], transitions=WorkflowTransitions(), ) diff --git a/tests/test_requests/__init__.py b/tests/test_requests/__init__.py index e69de29b..638694e0 100644 --- a/tests/test_requests/__init__.py +++ b/tests/test_requests/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# 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 f18139fc..d762c12f 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 @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from flask import current_app from thesis.ext import ThesisExt from thesis.records.api import ThesisDraft, ThesisRecord @@ -24,7 +31,7 @@ def test_allowed_request_types_on_draft_service( thesis_requests_service = thesis_ext.service_record_request_types allowed_request_types = ( - thesis_requests_service.get_applicable_request_types_for_draft( + thesis_requests_service.get_applicable_request_types_for_draft_record( creator.identity, draft1.json["id"] ) ) @@ -137,6 +144,7 @@ def test_allowed_request_types_on_published_resource( publish_request_data_function, create_draft_via_resource, search_clear, + app, ): creator = users[0] receiver = users[1] @@ -156,6 +164,7 @@ def test_allowed_request_types_on_published_resource( allowed_request_types = creator_client.get( link_api2testclient(applicable_requests_link) ) + assert allowed_request_types.status_code == 200 assert sorted( allowed_request_types.json["hits"]["hits"], key=lambda x: x["type_id"] ) == [ diff --git a/tests/test_requests/test_cascade_events.py b/tests/test_requests/test_cascade_events.py index 23d06bd9..54931ccc 100644 --- a/tests/test_requests/test_cascade_events.py +++ b/tests/test_requests/test_cascade_events.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from invenio_requests.records.api import RequestEvent from oarepo_requests.types.events import TopicDeleteEventType diff --git a/tests/test_requests/test_conditional_recipient.py b/tests/test_requests/test_conditional_recipient.py index d595c802..b2a22a46 100644 --- a/tests/test_requests/test_conditional_recipient.py +++ b/tests/test_requests/test_conditional_recipient.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# def test_conditional_receiver_creator_matches( logged_client, users, diff --git a/tests/test_requests/test_create_conditions.py b/tests/test_requests/test_create_conditions.py index 83a7b7d6..71932f47 100644 --- a/tests/test_requests/test_create_conditions.py +++ b/tests/test_requests/test_create_conditions.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import pytest from oarepo_requests.errors import OpenRequestAlreadyExists diff --git a/tests/test_requests/test_create_inmodel.py b/tests/test_requests/test_create_inmodel.py index 31d4c304..eed0c307 100644 --- a/tests/test_requests/test_create_inmodel.py +++ b/tests/test_requests/test_create_inmodel.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisRecord from tests.test_requests.utils import link_api2testclient diff --git a/tests/test_requests/test_delete.py b/tests/test_requests/test_delete.py index 6255af91..dd6efa9e 100644 --- a/tests/test_requests/test_delete.py +++ b/tests/test_requests/test_delete.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisDraft, ThesisRecord from .utils import link_api2testclient diff --git a/tests/test_requests/test_edit.py b/tests/test_requests/test_edit.py index 7dce6aea..31e1baa3 100644 --- a/tests/test_requests/test_edit.py +++ b/tests/test_requests/test_edit.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisDraft, ThesisRecord from tests.test_requests.utils import link_api2testclient @@ -48,9 +55,7 @@ def test_edit_autoaccept( # edit action worked? search = creator_client.get( f'user{urls["BASE_URL"]}', - ).json[ - "hits" - ]["hits"] + ).json["hits"]["hits"] assert len(search) == 1 assert search[0]["links"]["self"].endswith("/draft") assert search[0]["id"] == id_ diff --git a/tests/test_requests/test_expand.py b/tests/test_requests/test_expand.py index 04595b72..c50e9345 100644 --- a/tests/test_requests/test_expand.py +++ b/tests/test_requests/test_expand.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from tests.test_requests.test_create_inmodel import pick_request_type from tests.test_requests.utils import link_api2testclient @@ -35,3 +42,30 @@ def test_requests_field( assert "requests" not in record.json.get("expanded", {}) assert "requests" in expanded_record.json["expanded"] + + +def test_autoaccept_receiver( + vocab_cf, + logged_client, + users, + urls, + edit_record_data_function, + record_factory, + search_clear, +): + creator = users[0] + creator_client = logged_client(creator) + + record1 = record_factory(creator.identity) + id_ = record1["id"] + resp_request_create = creator_client.post( + urls["BASE_URL_REQUESTS"], + json=edit_record_data_function(record1["id"]), + ) + resp_request_submit = creator_client.post( + link_api2testclient(resp_request_create.json["links"]["actions"]["submit"]), + ) + request = creator_client.get( + f'{urls["BASE_URL_REQUESTS"]}{resp_request_create.json["id"]}?expand=true' + ).json + assert request["expanded"]["receiver"] == {"auto_approve": "true"} diff --git a/tests/test_requests/test_extended.py b/tests/test_requests/test_extended.py index c11853fd..28c51163 100644 --- a/tests/test_requests/test_extended.py +++ b/tests/test_requests/test_extended.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from invenio_requests.records.api import RequestEvent from thesis.records.api import ThesisDraft diff --git a/tests/test_requests/test_index_refresh.py b/tests/test_requests/test_index_refresh.py index 0e916054..a00af7b9 100644 --- a/tests/test_requests/test_index_refresh.py +++ b/tests/test_requests/test_index_refresh.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from tests.test_requests.utils import link_api2testclient diff --git a/tests/test_requests/test_new_version.py b/tests/test_requests/test_new_version.py index 86bbb05f..1328ddd4 100644 --- a/tests/test_requests/test_new_version.py +++ b/tests/test_requests/test_new_version.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisDraft, ThesisRecord from tests.test_requests.utils import link_api2testclient @@ -46,9 +53,7 @@ def test_new_version_autoaccept( # new_version action worked? search = creator_client.get( f'user{urls["BASE_URL"]}', - ).json[ - "hits" - ]["hits"] + ).json["hits"]["hits"] assert len(search) == 2 assert search[0]["id"] != search[1]["id"] assert search[0]["parent"]["id"] == search[1]["parent"]["id"] diff --git a/tests/test_requests/test_param_interpreters.py b/tests/test_requests/test_param_interpreters.py index 247ef19c..7add533c 100644 --- a/tests/test_requests/test_param_interpreters.py +++ b/tests/test_requests/test_param_interpreters.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from tests.test_requests.utils import link_api2testclient diff --git a/tests/test_requests/test_payload_schema.py b/tests/test_requests/test_payload_schema.py index 4f32559b..88eddfeb 100644 --- a/tests/test_requests/test_payload_schema.py +++ b/tests/test_requests/test_payload_schema.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from oarepo_requests.types import PublishDraftRequestType diff --git a/tests/test_requests/test_publish.py b/tests/test_requests/test_publish.py index ae892b61..fc33dd97 100644 --- a/tests/test_requests/test_publish.py +++ b/tests/test_requests/test_publish.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import copy from thesis.records.api import ThesisDraft, ThesisRecord diff --git a/tests/test_requests/test_record_requests.py b/tests/test_requests/test_record_requests.py index 28eb2fd9..e21e078d 100644 --- a/tests/test_requests/test_record_requests.py +++ b/tests/test_requests/test_record_requests.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisDraft, ThesisRecord from .utils import link_api2testclient diff --git a/tests/test_requests/test_timeline.py b/tests/test_requests/test_timeline.py index 9be13a67..0b50ea20 100644 --- a/tests/test_requests/test_timeline.py +++ b/tests/test_requests/test_timeline.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from invenio_requests.records.api import RequestEvent from tests.test_requests.test_create_inmodel import pick_request_type diff --git a/tests/test_requests/test_topic_resolve.py b/tests/test_requests/test_topic_resolve.py index efb6c783..84cf9728 100644 --- a/tests/test_requests/test_topic_resolve.py +++ b/tests/test_requests/test_topic_resolve.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import json from invenio_access.permissions import system_identity diff --git a/tests/test_requests/test_topic_update.py b/tests/test_requests/test_topic_update.py index 5342b5b7..102b48fe 100644 --- a/tests/test_requests/test_topic_update.py +++ b/tests/test_requests/test_topic_update.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from thesis.records.api import ThesisDraft from .utils import link_api2testclient diff --git a/tests/test_requests/test_ui_serialialization.py b/tests/test_requests/test_ui_serialialization.py index f3362876..a10b5624 100644 --- a/tests/test_requests/test_ui_serialialization.py +++ b/tests/test_requests/test_ui_serialialization.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import copy from pprint import pprint @@ -131,51 +138,71 @@ def test_resolver_fallback( app.config["ENTITY_REFERENCE_UI_RESOLVERS"] = { "fallback": FallbackEntityReferenceUIResolver("fallback"), } + try: + creator = users[0] + creator_client = logged_client(creator) - creator = users[0] - creator_client = logged_client(creator) + draft1 = create_draft_via_resource(creator_client) + draft_id = draft1.json["id"] + ThesisRecord.index.refresh() + ThesisDraft.index.refresh() - draft1 = create_draft_via_resource(creator_client) - draft_id = draft1.json["id"] - ThesisRecord.index.refresh() - ThesisDraft.index.refresh() + resp_request_create = creator_client.post( + urls["BASE_URL_REQUESTS"], + json=publish_request_data_function(draft1.json["id"]), + headers={"Accept": "application/vnd.inveniordm.v1+json"}, + ) + assert resp_request_create.json["stateful_name"] == "Submit for review" + assert ( + resp_request_create.json["stateful_description"] + == "Submit for review. After submitting the draft for review, it will be locked and no further modifications will be possible." + ) - resp_request_create = creator_client.post( - urls["BASE_URL_REQUESTS"], - json=publish_request_data_function(draft1.json["id"]), - headers={"Accept": "application/vnd.inveniordm.v1+json"}, - ) - assert resp_request_create.json["stateful_name"] == "Submit for review" - assert ( - resp_request_create.json["stateful_description"] - == "Submit for review. After submitting the draft for review, it will be locked and no further modifications will be possible." - ) + resp_request_submit = creator_client.post( + link_api2testclient(resp_request_create.json["links"]["actions"]["submit"]), + headers={"Accept": "application/vnd.inveniordm.v1+json"}, + ) + assert resp_request_submit.json["stateful_name"] == "Submitted for review" + assert ( + resp_request_submit.json["stateful_description"] + == "The draft has been submitted for review. It is now locked and no further changes are possible. You will be notified about the decision by email." + ) - resp_request_submit = creator_client.post( - link_api2testclient(resp_request_create.json["links"]["actions"]["submit"]), - headers={"Accept": "application/vnd.inveniordm.v1+json"}, - ) - assert resp_request_submit.json["stateful_name"] == "Submitted for review" - assert ( - resp_request_submit.json["stateful_description"] - == "The draft has been submitted for review. It is now locked and no further changes are possible. You will be notified about the decision by email." - ) + ui_record = creator_client.get( + f"{urls['BASE_URL']}{draft_id}/draft?expand=true", + headers={"Accept": "application/vnd.inveniordm.v1+json"}, + ).json + expected_result = ui_serialization_result( + draft_id, ui_record["expanded"]["requests"][0]["id"] + ) + expected_result["created_by"]["label"] = ( + f"id: {creator.id}" # the user resolver uses name or email as label, the fallback doesn't know what to use + ) + expected_created_by = {**expected_result["created_by"]} + actual_created_by = {**ui_record["expanded"]["requests"][0]["created_by"]} - ui_record = creator_client.get( - f"{urls['BASE_URL']}{draft_id}/draft?expand=true", - headers={"Accept": "application/vnd.inveniordm.v1+json"}, - ).json - expected_result = ui_serialization_result( - draft_id, ui_record["expanded"]["requests"][0]["id"] - ) - expected_result["created_by"][ - "label" - ] = f"id: {creator.id}" # the user resolver uses name or email as label, the fallback doesn't know what to use - assert is_valid_subdict( - expected_result, - ui_record["expanded"]["requests"][0], - ) - app.config["ENTITY_REFERENCE_UI_RESOLVERS"] = config_restore + expected_topic = {**expected_result["topic"]} + actual_topic = {**ui_record["expanded"]["requests"][0]["topic"]} + + expected_receiver = {**expected_result["receiver"]} + actual_receiver = {**ui_record["expanded"]["requests"][0]["receiver"]} + + expected_created_by.pop("links", None) + actual_created_by.pop("links", None) + + expected_topic.pop("links", None) + actual_topic.pop("links", None) + + assert expected_topic.pop("label") == actual_topic.pop("label") + + expected_receiver.pop("links", None) + actual_receiver.pop("links", None) + + assert expected_created_by == actual_created_by + assert expected_topic == actual_topic + assert expected_receiver == actual_receiver + finally: + app.config["ENTITY_REFERENCE_UI_RESOLVERS"] = config_restore def test_role( @@ -189,7 +216,6 @@ def test_role( create_draft_via_resource, search_clear, ): - config_restore = app.config["OAREPO_REQUESTS_DEFAULT_RECEIVER"] def current_receiver(record=None, request_type=None, **kwargs): diff --git a/tests/test_requests/test_workflows.py b/tests/test_requests/test_workflows.py index de5e749d..60c283e7 100644 --- a/tests/test_requests/test_workflows.py +++ b/tests/test_requests/test_workflows.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import pytest from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp from invenio_records_resources.services.errors import PermissionDeniedError diff --git a/tests/test_requests/utils.py b/tests/test_requests/utils.py index 8865c966..1ca4e4c8 100644 --- a/tests/test_requests/utils.py +++ b/tests/test_requests/utils.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from collections import defaultdict diff --git a/tests/test_ui/__init__.py b/tests/test_ui/__init__.py index e69de29b..638694e0 100644 --- a/tests/test_ui/__init__.py +++ b/tests/test_ui/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# diff --git a/tests/test_ui/conftest.py b/tests/test_ui/conftest.py index 3189e3e7..d72f3f27 100644 --- a/tests/test_ui/conftest.py +++ b/tests/test_ui/conftest.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# import os import shutil import sys diff --git a/tests/test_ui/model.py b/tests/test_ui/model.py index 5f08f96a..4be29d19 100644 --- a/tests/test_ui/model.py +++ b/tests/test_ui/model.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-requests is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# from flask import g from flask_principal import PermissionDenied from oarepo_ui.resources import ( @@ -30,7 +37,6 @@ class ModelUIResourceConfig(RecordsUIResourceConfig): class ModelUIResource(RecordsUIResource): - def _get_record(self, resource_requestctx, allow_draft=False): try: if allow_draft: diff --git a/tests/test_ui/test_ui_resource.py b/tests/test_ui/test_ui_resource.py index aa2c6fd2..94f75df8 100644 --- a/tests/test_ui/test_ui_resource.py +++ b/tests/test_ui/test_ui_resource.py @@ -94,6 +94,7 @@ def test_request_detail_page( assert c.status_code == 200 print(c.text) + def test_form_config(app, client, record_ui_resource, fake_manifest): with client.get("/requests/configs/publish_draft") as c: assert c.json == {