diff --git a/.copyright.tmpl b/.copyright.tmpl new file mode 100644 index 0000000..6186113 --- /dev/null +++ b/.copyright.tmpl @@ -0,0 +1,5 @@ +Copyright (C) 2024 CESNET z.s.p.o. + +oarepo-workflows 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 0000000..ba18d3c --- /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 3417990..3375403 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 0000000..336aa08 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +disable_error_code = import-untyped, import-not-found diff --git a/oarepo_workflows/__init__.py b/oarepo_workflows/__init__.py index 5d973e1..7c5aaf6 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -1,14 +1,25 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Support any workflows on Invenio record.""" + +from __future__ import annotations + from oarepo_workflows.services.permissions import ( - AutoApprove, - AutoRequest, - DefaultWorkflowPermissions, + FromRecordWorkflow, IfInState, WorkflowPermission, - WorkflowPermissionPolicy, + WorkflowRecordPermissionPolicy, ) from .base import Workflow from .requests import ( + AutoApprove, + AutoRequest, WorkflowRequest, WorkflowRequestEscalation, WorkflowRequestPolicy, @@ -19,12 +30,12 @@ "IfInState", "Workflow", "WorkflowPermission", - "DefaultWorkflowPermissions", - "WorkflowPermissionPolicy", + "WorkflowRecordPermissionPolicy", "WorkflowRequestPolicy", "WorkflowRequest", + "WorkflowRequestEscalation", "WorkflowTransitions", + "FromRecordWorkflow", "AutoApprove", "AutoRequest", - "WorkflowRequestEscalation", ) diff --git a/oarepo_workflows/base.py b/oarepo_workflows/base.py index ad38159..063e74e 100644 --- a/oarepo_workflows/base.py +++ b/oarepo_workflows/base.py @@ -1,29 +1,119 @@ -import dataclasses -from typing import Type +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Support any workflows on Invenio record.""" + +from __future__ import annotations -from flask_babel import LazyString +import dataclasses +from typing import TYPE_CHECKING, Any, Protocol -from . import WorkflowPermissionPolicy +from . import WorkflowRecordPermissionPolicy from .requests import WorkflowRequestPolicy from .services.permissions import DefaultWorkflowPermissions +if TYPE_CHECKING: + from flask_babel import LazyString + from flask_principal import Identity + from invenio_records_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + @dataclasses.dataclass class Workflow: + """A workflow definition.""" + label: str | LazyString - permission_policy_cls: Type[DefaultWorkflowPermissions] - request_policy_cls: Type[WorkflowRequestPolicy] = WorkflowRequestPolicy + """A human-readable label for the workflow.""" - def permissions(self, action, **over): - """Return permission policy for this workflow applicable to the given action.""" + permission_policy_cls: type[DefaultWorkflowPermissions] + """A permission policy class that handles permissions on records that are governed by this workflow.""" + + request_policy_cls: type[WorkflowRequestPolicy] = WorkflowRequestPolicy + """A request policy class that defines which requests can be applied to records governed by this workflow.""" + + def permissions(self, action: str, **over: Any) -> DefaultWorkflowPermissions: + """Return permission policy for this workflow applicable to the given action. + + :param action: action for which permission is asked + """ return self.permission_policy_cls(action, **over) - def requests(self): + def requests(self) -> WorkflowRequestPolicy: """Return instance of request policy for this workflow.""" return self.request_policy_cls() - def __post_init__(self): - assert not issubclass(self.permission_policy_cls, WorkflowPermissionPolicy) - assert issubclass(self.permission_policy_cls, DefaultWorkflowPermissions) + def __post_init__(self) -> None: + """Check that the classes are subclasses of the expected classes. + This is just a sanity check to raise an error as soon as possible. + """ + assert not issubclass( + self.permission_policy_cls, WorkflowRecordPermissionPolicy + ) + assert issubclass(self.permission_policy_cls, DefaultWorkflowPermissions) assert issubclass(self.request_policy_cls, WorkflowRequestPolicy) + + +class StateChangedNotifier(Protocol): + """A protocol for a state change notifier. + + State changed notifier is a callable that is called when a state of a record changes, + for example as a result of a workflow transition. + """ + + def __call__( + self, + identity: Identity, + record: Record, + previous_state: str, + new_state: str, + *args: Any, + uow: UnitOfWork, + **kwargs: Any, + ): + """Notify about a state change. + + :param identity: identity of the user who initiated the state change + :param record: record whose state changed + :param previous_state: previous state of the record + :param new_state: new state of the record + :param args: additional arguments + :param uow: unit of work + :param kwargs: additional keyword arguments + """ + ... + + +class WorkflowChangeNotifier(Protocol): + """A protocol for a workflow change notifier. + + Workflow changed notifier is a callable that is called + when a workflow of a record changes. + """ + + def __call__( + self, + identity: Identity, + record: Record, + previous_workflow_id: str, + new_workflow_id: str, + *args: Any, + uow: UnitOfWork, + **kwargs: Any, + ): + """Notify about a workflow change. + + :param identity: identity of the user who initiated the workflow change + :param record: record whose workflow changed + :param previous_workflow_id: previous workflow of the record + :param new_workflow_id: new workflow of the record + :param args: additional arguments + :param uow: unit of work + :param kwargs: additional keyword arguments + """ + ... diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index dc8d46a..bf54d07 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -1,9 +1,107 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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-workflows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + from marshmallow import ValidationError +if TYPE_CHECKING: + from invenio_records_resources.records import Record + + +def _get_id_from_record(record: Record | dict) -> str: + """Get the id from a record. + + :param record: A record or a dict representing a record. + :return str: The id of the record. + """ + # community record doesn't have id in dict form, only uuid + try: + if "id" in record: + return str(record["id"]) + except TypeError: + pass + if hasattr(record, "id"): + return str(record.id) + return str(record) + + +def _format_record(record: Record | dict) -> str: + """Format a record for error messages. + + :param record: A record or a dict representing a record. + :return str: A formatted string representing the record. + """ + return f"{type(record).__name__}[{_get_id_from_record(record)}]" + class MissingWorkflowError(ValidationError): - """""" + """Exception raised when a required workflow is missing.""" + + def __init__(self, message: str, record: Record | dict | None = None) -> None: + """Initialize the exception.""" + self.record = record + if record: + super().__init__(f"{message} Used on record {_format_record(record)}") + else: + super().__init__(message) class InvalidWorkflowError(ValidationError): - """""" + """Exception raised when a workflow is invalid.""" + + def __init__( + self, + message: str, + record: Record | dict | None = None, + community_id: Optional[str] = None, + ) -> None: + """Initialize the exception.""" + self.record = record + if record: + super().__init__(f"{message} Used on record {_format_record(record)}") + elif community_id: + super().__init__(f"{message} Used on community {community_id}") + else: + super().__init__(message) + + +class InvalidConfigurationError(Exception): + """Exception raised when a configuration is invalid.""" + + +class EventTypeNotInWorkflow(Exception): + """Exception raised when user tries to create a request with a request type that is not defined in the workflow.""" + + def __init__(self, request_type: str, event_type: str, workflow_code: str) -> None: + """Initialize the exception.""" + self.request_type = request_type + self.workflow = workflow_code + self.event_type = event_type + + @property + def description(self) -> str: + """Exception's description.""" + return f"Event type {self.event_type} is not on request type {self.request_type} in workflow {self.workflow}." + + +class RequestTypeNotInWorkflow(Exception): + """Exception raised when user tries to create a request with a request type that is not defined in the workflow.""" + + def __init__(self, request_type: str, workflow_code: str) -> None: + """Initialize the exception.""" + self.request_type = request_type + self.workflow = workflow_code + + @property + def description(self) -> str: + """Exception's description.""" + return f"Request type {self.request_type} not in workflow {self.workflow}." diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 36ed96d..06c9de9 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -1,23 +1,63 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Flask extension for workflows.""" + +from __future__ import annotations + from functools import cached_property +from typing import TYPE_CHECKING, Any, Optional import importlib_metadata from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp -from invenio_records_resources.services.uow import RecordCommitOp +from invenio_records_resources.services.uow import RecordCommitOp, unit_of_work from oarepo_runtime.datastreams.utils import get_record_service_for_record from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows +from oarepo_workflows.services.auto_approve import ( + AutoApproveEntityService, + AutoApproveEntityServiceConfig, +) + +if TYPE_CHECKING: + from flask import Flask + from flask_principal import Identity + from invenio_drafts_resources.records import ParentRecord, Record + from invenio_records_resources.services.uow import UnitOfWork + + from oarepo_workflows.base import ( + StateChangedNotifier, + Workflow, + WorkflowChangeNotifier, + ) + from oarepo_workflows.records.systemfields.workflow import WithWorkflow + +class OARepoWorkflows: + """OARepo workflows extension.""" -class OARepoWorkflows(object): + def __init__(self, app: Optional[Flask] = None) -> None: + """Initialize the extension. - def __init__(self, app=None): + :param app: Flask application to initialize with. + If not passed here, it can be passed later using init_app method. + """ if app: self.init_config(app) self.init_app(app) + self.init_services() - def init_config(self, app): - """Initialize configuration.""" + # noinspection PyMethodMayBeStatic + def init_config(self, app: Flask) -> None: + """Initialize configuration. + + :param app: Flask application to initialize with. + """ from . import ext_config if "OAREPO_PERMISSIONS_PRESETS" not in app.config: @@ -31,48 +71,108 @@ def init_config(self, app): app.config.setdefault("WORKFLOWS", ext_config.WORKFLOWS) + app.config.setdefault( + "OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS", + ext_config.OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS, + ) + + def init_services(self) -> None: + """Initialize workflow services.""" + # noinspection PyAttributeOutsideInit + self.auto_approve_service = AutoApproveEntityService( + config=AutoApproveEntityServiceConfig() + ) + @cached_property - def state_changed_notifiers(self): + def state_changed_notifiers(self) -> list[StateChangedNotifier]: + """Return a list of state changed notifiers. + + State changed notifiers are callables that are called when a state of a record changes, + for example as a result of a workflow transition. + + They are registered as entry points in the group `oarepo_workflows.state_changed_notifiers`. + """ group_name = "oarepo_workflows.state_changed_notifiers" return [ x.load() for x in importlib_metadata.entry_points().select(group=group_name) ] @cached_property - def workflow_changed_notifiers(self): + def workflow_changed_notifiers(self) -> list[WorkflowChangeNotifier]: + """Return a list of workflow changed notifiers. + + Workflow changed notifiers are callables that are called when a workflow of a record changes. + They are registered as entry points in the group `oarepo_workflows.workflow_changed_notifiers`. + """ group_name = "oarepo_workflows.workflow_changed_notifiers" return [ x.load() for x in importlib_metadata.entry_points().select(group=group_name) ] + @unit_of_work() def set_state( - self, identity, record, value, *args, uow=None, commit=True, **kwargs - ): + self, + identity: Identity, + record: Record, + new_state: str, + *args: Any, + uow: UnitOfWork, + commit: bool = True, + **kwargs: Any, + ) -> None: + """Set a new state on a record. + + :param identity: identity of the user who initiated the state change + :param record: record whose state is being changed + :param new_state: new state to set + :param args: additional arguments + :param uow: unit of work + :param commit: whether to commit the change + :param kwargs: additional keyword arguments + """ previous_value = record.state - record.state = value + record.state = new_state if commit: service = get_record_service_for_record(record) uow.register(RecordCommitOp(record, indexer=service.indexer)) for state_changed_notifier in self.state_changed_notifiers: state_changed_notifier( - identity, record, previous_value, value, *args, uow=uow, **kwargs + identity, record, previous_value, new_state, *args, uow=uow, **kwargs ) + @unit_of_work() def set_workflow( - self, identity, record, new_workflow_id, *args, uow=None, commit=True, **kwargs - ): + self, + identity: Identity, + record: Record, + new_workflow_id: str, + *args: Any, + uow: UnitOfWork, + commit: bool = True, + **kwargs: Any, + ) -> None: + """Set a new workflow on a record. + + :param identity: identity of the user who initiated the workflow change + :param record: record whose workflow is being changed + :param new_workflow_id: new workflow to set + :param args: additional arguments + :param uow: unit of work + :param commit: whether to commit the change + :param kwargs: additional keyword arguments + """ if new_workflow_id not in current_oarepo_workflows.record_workflows: raise InvalidWorkflowError( - f"Workflow {new_workflow_id} does not exist in the configuration." + f"Workflow {new_workflow_id} does not exist in the configuration.", + record=record, ) - previous_value = record.parent.workflow - record.parent.workflow = new_workflow_id + parent = record.parent # noqa for typing: we do not have a better type for record with parent + previous_value = parent.workflow + parent.workflow = new_workflow_id if commit: service = get_record_service_for_record(record) uow.register( - ParentRecordCommitOp( - record.parent, indexer_context=dict(service=service) - ) + ParentRecordCommitOp(parent, indexer_context=dict(service=service)) ) for workflow_changed_notifier in self.workflow_changed_notifiers: workflow_changed_notifier( @@ -85,42 +185,129 @@ def set_workflow( **kwargs, ) - def get_workflow_from_record(self, record, **kwargs): - if hasattr(record, "parent"): - record = record.parent - if hasattr(record, "workflow") and record.workflow: - return record.workflow + # noinspection PyMethodMayBeStatic + def get_workflow_from_record(self, record: Record | ParentRecord) -> str | None: + """Get the workflow from a record. + + :param record: record to get the workflow from. Can pass either a record or its parent. + """ + parent: ParentRecord = record.parent if hasattr(record, "parent") else record + + if hasattr(parent, "workflow") and parent.workflow: + return parent.workflow else: return None @property - def record_workflows(self): + def record_workflows(self) -> dict[str, Workflow]: + """Return a dictionary of available record workflows.""" return self.app.config["WORKFLOWS"] @property - def default_workflow_event_submitters(self): - if "DEFAULT_WORKFLOW_EVENT_SUBMITTERS" in self.app.config: - return self.app.config["DEFAULT_WORKFLOW_EVENT_SUBMITTERS"] - else: - return {} + def default_workflow_events(self) -> dict: + """Return a dictionary of default workflow events. - def _get_id_from_record(self, record): - # community record doesn't have id in dict form, only uuid - return record["id"] if "id" in record else record.id + Default workflow events are those that can be added to any request. + The dictionary is taken from the configuration key `DEFAULT_WORKFLOW_EVENTS`. + """ + return self.app.config.get("DEFAULT_WORKFLOW_EVENTS", {}) + + def get_workflow(self, record: Record | dict) -> Workflow: + """Get the workflow for a record. + + :param record: record to get the workflow for + :raises MissingWorkflowError: if the workflow is not found + :raises InvalidWorkflowError: if the workflow is invalid + """ + if hasattr(record, "parent"): + try: + record_parent: WithWorkflow = record.parent # noqa for typing: we do not have a better type for record with parent + except AttributeError as e: + raise MissingWorkflowError( + "Record does not have a parent attribute, is it a draft-enabled record?", + record=record, + ) from e + try: + workflow_id = record_parent.workflow + except AttributeError as e: + raise MissingWorkflowError( + "Parent record does not have a workflow attribute.", record=record + ) from e + else: + try: + dict_parent: dict = record["parent"] + except KeyError as e: + raise MissingWorkflowError( + "Record does not have a parent attribute.", record=record + ) from e + try: + workflow_id = dict_parent["workflow"] + except KeyError as e: + raise MissingWorkflowError( + "Parent record does not have a workflow attribute.", record=record + ) from e - def get_workflow(self, record): try: - return self.record_workflows[record.parent.workflow] - except AttributeError: - raise MissingWorkflowError( - f"Workflow not found on record {self._get_id_from_record(record)}." - ) - except KeyError: + return self.record_workflows[workflow_id] + except KeyError as e: raise InvalidWorkflowError( - f"Workflow {record.parent.workflow} on record {self._get_id_from_record(record)} doesn't exist." - ) + f"Workflow {workflow_id} doesn't exist in the configuration.", + record=record, + ) from e - def init_app(self, app): + def init_app(self, app: Flask) -> None: """Flask application initialization.""" + # noinspection PyAttributeOutsideInit self.app = app app.extensions["oarepo-workflows"] = self + + +def finalize_app(app: Flask) -> None: + """Finalize the application. + + This function registers the auto-approve service in the records resources registry. + It is called from invenio_base.api_finalize_app entry point. + + :param app: Flask application + """ + records_resources = app.extensions["invenio-records-resources"] + + ext = app.extensions["oarepo-workflows"] + + records_resources.registry.register( + ext.auto_approve_service, + service_id=ext.auto_approve_service.config.service_id, + ) + + if app.config["OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS"]: + patch_request_permissions(app) + + +def patch_request_permissions(app: Flask) -> None: + """Replace invenio request permissions. + + If permissions for requests are the plain invenio permissions, + replace those with workflow-based ones. If user set their own + permissions, keep those intact. + """ + # patch invenio requests + from invenio_requests.services.permissions import ( + PermissionPolicy as OriginalPermissionPolicy, + ) + + with app.app_context(): + from invenio_requests.proxies import current_requests_service + + from oarepo_workflows.requests.permissions import ( + CreatorsFromWorkflowRequestsPermissionPolicy, + ) + + current_permission_policy = app.config.get("REQUESTS_PERMISSION_POLICY") + if current_permission_policy is OriginalPermissionPolicy: + app.config["REQUESTS_PERMISSION_POLICY"] = ( + CreatorsFromWorkflowRequestsPermissionPolicy + ) + assert ( + current_requests_service.config.permission_policy_cls + is CreatorsFromWorkflowRequestsPermissionPolicy + ) diff --git a/oarepo_workflows/ext_config.py b/oarepo_workflows/ext_config.py index 5f70579..c8296ec 100644 --- a/oarepo_workflows/ext_config.py +++ b/oarepo_workflows/ext_config.py @@ -1,7 +1,29 @@ -from oarepo_workflows.services.permissions.policy import WorkflowPermissionPolicy +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 for oarepo-workflows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from oarepo_workflows.services.permissions.record_permission_policy import ( + WorkflowRecordPermissionPolicy, +) + +if TYPE_CHECKING: + from oarepo_workflows import Workflow OAREPO_PERMISSIONS_PRESETS = { - "workflow": WorkflowPermissionPolicy, + "workflow": WorkflowRecordPermissionPolicy, } +"""Permissions presets for oarepo-workflows.""" + +WORKFLOWS: dict[str, Workflow] = {} +"""Configuration of workflows, must be provided by the user inside, for example, invenio.cfg.""" -WORKFLOWS = {} +OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS = True diff --git a/oarepo_workflows/proxies.py b/oarepo_workflows/proxies.py index 323e5f3..221147f 100644 --- a/oarepo_workflows/proxies.py +++ b/oarepo_workflows/proxies.py @@ -1,6 +1,23 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Proxies for accessing the current OARepo workflows extension without bringing dependencies.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import current_app from werkzeug.local import LocalProxy -current_oarepo_workflows = LocalProxy( +if TYPE_CHECKING: + from oarepo_workflows.ext import OARepoWorkflows + +current_oarepo_workflows: OARepoWorkflows = LocalProxy( # type: ignore lambda: current_app.extensions["oarepo-workflows"] ) +"""Proxy to access the current OARepo workflows extension.""" diff --git a/oarepo_workflows/records/__init__.py b/oarepo_workflows/records/__init__.py index e69de29..57960f4 100644 --- a/oarepo_workflows/records/__init__.py +++ b/oarepo_workflows/records/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 layer.""" diff --git a/oarepo_workflows/records/models.py b/oarepo_workflows/records/models.py index b71b183..5c19413 100644 --- a/oarepo_workflows/records/models.py +++ b/oarepo_workflows/records/models.py @@ -1,6 +1,20 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Model mixins.""" + +from __future__ import annotations + from invenio_db import db from sqlalchemy import String class RecordWorkflowParentModelMixin: + """Mixin for a ParentRecord database model that adds a workflow field.""" + workflow = db.Column(String) + """Workflow identifier.""" diff --git a/oarepo_workflows/records/systemfields/__init__.py b/oarepo_workflows/records/systemfields/__init__.py index e69de29..95d503f 100644 --- a/oarepo_workflows/records/systemfields/__init__.py +++ b/oarepo_workflows/records/systemfields/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 layer, system fields.""" + +from .state import RecordStateField +from .workflow import WorkflowField + +__all__ = ( + "RecordStateField", + "WorkflowField", +) diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index 13499aa..2002a09 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -1,29 +1,70 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 system field.""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol, Self, overload + from invenio_records.systemfields.base import SystemField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin +class WithState(Protocol): + """A protocol for a record containing a state field. + + Later on, if typing.Intersection is implemented, + one could use it to have the record correctly typed as + record: Intersection[WithState, Record] + """ + + state: str + """State of the record.""" + + class RecordStateField(MappingSystemFieldMixin, SystemField): - def __init__(self, key="state", initial="draft", config=None): - self._config = config + """State system field.""" + + def __init__(self, key: str = "state", initial: str = "draft") -> None: + """Initialize the state field.""" self._initial = initial super().__init__(key=key) - def post_create(self, record): + def post_create(self, record: WithState) -> None: + """Set the initial state when record is created.""" self.set_dictkey(record, self._initial) - def post_init(self, record, data, model=None, **kwargs): + def post_init( + self, record: WithState, data: dict, model: Optional[Any] = None, **kwargs: Any + ) -> None: + """Set the initial state when record is created.""" if not record.state: self.set_dictkey(record, self._initial) - def __get__(self, record, owner=None): + @overload + def __get__(self, record: None, owner: type | None = None) -> Self: ... + + @overload + def __get__(self, record: WithState, owner: type | None = None) -> str: ... + + def __get__( + self, record: WithState | None, owner: type | None = None + ) -> str | Self: """Get the persistent identifier.""" if record is None: return self return self.get_dictkey(record) - def __set__(self, record, value): + def __set__(self, record: WithState, value: str) -> None: + """Directly set the state of the record.""" self.set_dictkey(record, value) @property - def mapping(self): + def mapping(self) -> dict[str, dict[str, str]]: + """Return the opensearch mapping for the state field.""" return {self.attr_name: {"type": "keyword"}} diff --git a/oarepo_workflows/records/systemfields/workflow.py b/oarepo_workflows/records/systemfields/workflow.py index 4750d2d..3018b10 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.py @@ -1,13 +1,41 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Workflow system field.""" + +from __future__ import annotations + +from typing import Protocol + from invenio_records.systemfields.model import ModelField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin +class WithWorkflow(Protocol): + """A protocol for a record's parent containing a workflow field. + + Later on, if typing.Intersection is implemented, + one could use it to have the record correctly typed as + record: Intersection[WithWorkflow, ParentRecord] + """ + + workflow: str + """Workflow of the record.""" + + class WorkflowField(MappingSystemFieldMixin, ModelField): + """Workflow system field, should be defined on ParentRecord.""" - def __init__(self): + def __init__(self) -> None: + """Initialize the workflow field.""" self._workflow = None # added in db super().__init__(model_field_name="workflow", key="workflow") @property - def mapping(self): + def mapping(self) -> dict[str, dict[str, str]]: + """Elasticsearch mapping.""" return {self.attr_name: {"type": "keyword"}} diff --git a/oarepo_workflows/requests/__init__.py b/oarepo_workflows/requests/__init__.py index 3cb96d2..dab55e1 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -1,15 +1,32 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 layer.""" + +from __future__ import annotations + +from .generators import AutoApprove, AutoRequest, RecipientGeneratorMixin from .policy import ( - RecipientGeneratorMixin, + WorkflowRequestPolicy, +) +from .requests import ( + RecipientEntityReference, WorkflowRequest, WorkflowRequestEscalation, - WorkflowRequestPolicy, WorkflowTransitions, ) __all__ = ( "WorkflowRequestPolicy", + "RecipientEntityReference", "WorkflowRequest", + "WorkflowRequestEscalation", "WorkflowTransitions", + "AutoApprove", + "AutoRequest", "RecipientGeneratorMixin", - "WorkflowRequestEscalation", ) diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index b558dae..ff64c18 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -1,28 +1,36 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Events for workflow requests.""" + +from __future__ import annotations + import dataclasses -from typing import List, Tuple +from functools import cached_property +from typing import TYPE_CHECKING + +from oarepo_workflows.requests.generators.multiple import MultipleGeneratorsGenerator -from invenio_records_permissions.generators import Generator +if TYPE_CHECKING: + from invenio_records_permissions.generators import Generator @dataclasses.dataclass class WorkflowEvent: - submitters: List[Generator] | Tuple[Generator] - - def needs(self, **kwargs): - return { - need for generator in self.submitters for need in generator.needs(**kwargs) - } - - def excludes(self, **kwargs): - return { - exclude - for generator in self.submitters - for exclude in generator.excludes(**kwargs) - } - - def query_filters(self, **kwargs): - return [ - query_filter - for generator in self.submitters - for query_filter in generator.query_filter(**kwargs) - ] + """Class representing a workflow event.""" + + submitters: list[Generator] | tuple[Generator] + """List of submitters to be used for the event. + + The generators supply needs. The user must have at least one of the needs + to be able to create a workflow event. + """ + + @cached_property + def submitter_generator(self) -> Generator: + """Return the requesters as a single requester generator.""" + return MultipleGeneratorsGenerator(self.submitters) diff --git a/oarepo_workflows/requests/generators/__init__.py b/oarepo_workflows/requests/generators/__init__.py new file mode 100644 index 0000000..077732d --- /dev/null +++ b/oarepo_workflows/requests/generators/__init__.py @@ -0,0 +1,25 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Need generators.""" + +from .auto import AutoApprove, AutoRequest, auto_approve_need, auto_request_need +from .conditionals import IfEventType, IfRequestType, IfRequestTypeBase +from .multiple import MultipleGeneratorsGenerator +from .recipient_generator import RecipientGeneratorMixin + +__all__ = ( + "AutoApprove", + "auto_approve_need", + "AutoRequest", + "auto_request_need", + "IfEventType", + "IfRequestType", + "IfRequestTypeBase", + "MultipleGeneratorsGenerator", + "RecipientGeneratorMixin", +) diff --git a/oarepo_workflows/requests/generators/auto.py b/oarepo_workflows/requests/generators/auto.py new file mode 100644 index 0000000..8b73e59 --- /dev/null +++ b/oarepo_workflows/requests/generators/auto.py @@ -0,0 +1,78 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Auto request and auto approve generators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from invenio_access import SystemRoleNeed +from invenio_records_permissions.generators import Generator + +from .recipient_generator import RecipientGeneratorMixin + +if TYPE_CHECKING: + from flask_principal import Need + from invenio_records_resources.records import Record + from invenio_requests.customizations import RequestType + +auto_request_need = SystemRoleNeed("auto_request") +auto_approve_need = SystemRoleNeed("auto_approve") + + +class AutoRequest(Generator): + """Auto request generator. + + This generator is used to automatically create a request + when a record is moved to a specific state. + """ + + def needs(self, **context: Any) -> list[Need]: + """Get needs that signal workflow to automatically create the request.""" + return [auto_request_need] + + +class AutoApprove(RecipientGeneratorMixin, Generator): + """Auto approve generator. + + If the generator is used within recipients of a request, + the request will be automatically approved when the request is submitted. + """ + + def reference_receivers( + self, + record: Optional[Record] = None, + request_type: Optional[RequestType] = None, + **kwargs: Any, + ) -> list[dict[str, str]]: + """Return the reference receiver(s) of the auto-approve request. + + Returning "auto_approve" is a signal to the workflow that the request should be auto-approved. + """ + return [{"auto_approve": "true"}] + + def needs(self, **context: Any) -> list[Need]: + """Get needs that signal workflow to automatically approve the request.""" + raise ValueError( + "Auto-approve generator can not create needs and " + "should be used only in `recipient` section of WorkflowRequest." + ) + + def excludes(self, **context: Any) -> list[Need]: + """Get needs that signal workflow to automatically approve the request.""" + raise ValueError( + "Auto-approve generator can not create needs and " + "should be used only in `recipient` section of WorkflowRequest." + ) + + def query_filter(self, **context: Any) -> list[dict]: + """Get needs that signal workflow to automatically approve the request.""" + raise ValueError( + "Auto-approve generator can not create needs and " + "should be used only in `recipient` section of WorkflowRequest." + ) diff --git a/oarepo_workflows/requests/generators/conditionals.py b/oarepo_workflows/requests/generators/conditionals.py new file mode 100644 index 0000000..ab22884 --- /dev/null +++ b/oarepo_workflows/requests/generators/conditionals.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 based on request and event types.""" + +from __future__ import annotations + +import abc +from typing import TYPE_CHECKING, Any + +from invenio_records_permissions.generators import ConditionalGenerator, Generator + +if TYPE_CHECKING: + from invenio_requests.customizations import EventType, RequestType + + +class IfRequestTypeBase(abc.ABC, ConditionalGenerator): + """Base class for conditional generators that generate needs based on request type.""" + + def __init__( + self, request_types: list[str] | tuple[str] | str, then_: list[Generator] + ) -> None: + """Initialize the generator.""" + super().__init__(then_, else_=[]) + if not isinstance(request_types, (list, tuple)): + request_types = [request_types] + self.request_types = request_types + + +class IfRequestType(IfRequestTypeBase): + """Conditional generator that generates needs when a current request is of a given type.""" + + def _condition(self, request_type: RequestType, **kwargs: Any) -> bool: + return request_type.type_id in self.request_types + + +class IfEventType(ConditionalGenerator): + """Conditional generator that generates needs when a current event is of a given type.""" + + def __init__( + self, + event_types: list[str] | tuple[str] | str, + then_: list[Generator], + else_: list[Generator] | None = None, + ) -> None: + """Initialize the generator.""" + 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: EventType, **kwargs: Any) -> bool: + return event_type.type_id in self.event_types diff --git a/oarepo_workflows/requests/generators/multiple.py b/oarepo_workflows/requests/generators/multiple.py new file mode 100644 index 0000000..353846a --- /dev/null +++ b/oarepo_workflows/requests/generators/multiple.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 that combines multiple generators together with an 'or' operation.""" + +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING, Any, Iterable + +from invenio_records_permissions.generators import Generator + +if TYPE_CHECKING: + from flask_principal import Need + + +@dataclasses.dataclass +class MultipleGeneratorsGenerator(Generator): + """A generator that combines multiple generators with 'or' operation.""" + + generators: list[Generator] | tuple[Generator] + """List of generators to be combined.""" + + def needs(self, **context: Any) -> set[Need]: + """Generate a set of needs from generators that a person needs to have. + + :param context: Context. + :return: Set of needs. + """ + return { + need for generator in self.generators for need in generator.needs(**context) + } + + def excludes(self, **context: Any) -> set[Need]: + """Generate a set of needs that person must not have. + + :param context: Context. + :return: Set of needs. + """ + return { + exclude + for generator in self.generators + for exclude in generator.excludes(**context) + } + + def query_filter(self, **context: Any) -> list[dict]: + """Generate a list of opensearch query filters. + + These filters are used to filter objects. These objects are governed by a policy + containing this generator. + + :param context: Context. + """ + ret: list[dict] = [] + for generator in self.generators: + query_filter = generator.query_filter(**context) + if query_filter: + if isinstance(query_filter, Iterable): + ret.extend(query_filter) + else: + ret.append(query_filter) + return ret diff --git a/oarepo_workflows/requests/generators/recipient_generator.py b/oarepo_workflows/requests/generators/recipient_generator.py new file mode 100644 index 0000000..f64b0f5 --- /dev/null +++ b/oarepo_workflows/requests/generators/recipient_generator.py @@ -0,0 +1,37 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 permission generators that can be used as recipients in WorkflowRequest.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from invenio_records_resources.records import Record + from invenio_requests.customizations import RequestType + + +class RecipientGeneratorMixin: + """Mixin for permission generators that can be used as recipients in WorkflowRequest.""" + + def reference_receivers( + self, + record: Optional[Record] = None, + request_type: Optional[RequestType] = None, + **context: Any, + ) -> list[dict[str, str]]: # 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. + """ + raise NotImplementedError("Implement reference receiver in your code") diff --git a/oarepo_workflows/requests/generators/workflow_based.py b/oarepo_workflows/requests/generators/workflow_based.py new file mode 100644 index 0000000..788635f --- /dev/null +++ b/oarepo_workflows/requests/generators/workflow_based.py @@ -0,0 +1,173 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Generators for needs/excludes/queries based on workflows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, override + +from opensearch_dsl.query import Query + +from oarepo_workflows import FromRecordWorkflow, Workflow, WorkflowRequest +from oarepo_workflows.errors import ( + EventTypeNotInWorkflow, + InvalidWorkflowError, + MissingWorkflowError, + RequestTypeNotInWorkflow, +) +from oarepo_workflows.proxies import current_oarepo_workflows + +if TYPE_CHECKING: + from flask_principal import Need + from invenio_records_permissions.generators import Generator + from invenio_records_resources.records import Record + from invenio_requests.customizations import EventType, RequestType + from invenio_requests.records.api import Request + + +class MissingTopicError(ValueError): + """Raised when the topic is missing in the request generator arguments.""" + + pass + + +def _get_workflow_code_from_workflow(workflow: Workflow) -> str: + """Return the workflow code from the workflow.""" + workflow_code = next( + iter( + k + for k, v in current_oarepo_workflows.record_workflows.items() + if v is workflow + ) + ) + return workflow_code + + +class RequestPolicyWorkflowCreators(FromRecordWorkflow): + """Base class that generates creators from a workflow request.""" + + def _kwargs_parser(self, **kwargs: Any) -> dict[str, Any]: + """Transform the kwargs for subsequent methods.""" + return kwargs + + def _requester_generator(self, **kwargs: Any) -> Generator: + """Return the requesters as a single requester generator.""" + raise NotImplementedError() + + def _get_workflow_request( + self, request_type_id: str, **kwargs: Any + ) -> tuple[Workflow, WorkflowRequest]: + """Return the workflow request from the context.""" + if "record" not in kwargs: + raise MissingTopicError( + "Topic not found in request permissions generator arguments, can't get workflow." + ) + record = kwargs["record"] + workflow = current_oarepo_workflows.get_workflow(record) + workflow_requests = workflow.requests() + try: + workflow_request = workflow_requests[request_type_id] + except KeyError as e: + workflow_code = _get_workflow_code_from_workflow(workflow) + raise RequestTypeNotInWorkflow( + request_type=request_type_id, + workflow_code=workflow_code, + ) from e + return workflow, workflow_request + + # return empty needs on MissingTopicError + # match None in query filter + # excludes empty needs + def needs(self, **context: Any) -> list[Need]: # type: ignore + """Return the needs generated by the workflow permission.""" + try: + context = self._kwargs_parser(**context) + generator = self._requester_generator(**context) + return generator.needs(**context) + except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): + return [] + + def excludes(self, **context: Any) -> list[Need]: + """Return the needs excluded by the workflow permission.""" + try: + context = self._kwargs_parser(**context) + generator = self._requester_generator(**context) + return generator.excludes(**context) + except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): + return [] + + # not tested + def query_filter( + self, record: Record = None, request_type: RequestType = None, **context: Any + ) -> Query: + """Return the query filter generated by the workflow permission.""" + try: + context = self._kwargs_parser(**context) + generator = self._requester_generator( + record=record, request_type=request_type, **context + ) + return generator.query_filter( + record=record, request_type=request_type, **context + ) + except (MissingWorkflowError, InvalidWorkflowError, MissingTopicError): + return Query("match_none") + + +class RequestCreatorsFromWorkflow(RequestPolicyWorkflowCreators): + """Generates creators from a workflow request to be used in the request 'create' permission.""" + + def __init__(self) -> None: + """Initialize the generator.""" + super().__init__(action="create") + + @override + def _requester_generator(self, **kwargs: Any) -> Generator: + request_type: RequestType = kwargs["request_type"] + workflow, workflow_request = self._get_workflow_request( + request_type.type_id, **kwargs + ) + return workflow_request.requester_generator + + +class EventCreatorsFromWorkflow(RequestPolicyWorkflowCreators): + """Generates creators from a workflow request to be used in the event 'create' permission.""" + + def __init__(self) -> None: + """Initialize the generator.""" + super().__init__(action="create") + + @override + def _kwargs_parser(self, **kwargs: Any) -> dict[str, Any]: + request: Request = kwargs["request"] + kwargs.setdefault("request_type", request.type) + try: + kwargs["record"] = request.topic.resolve() + except Exception as e: # noqa TODO: better exception catching here + raise MissingTopicError( + "Topic not found in request event permissions generator arguments, can't get workflow." + ) from e + return kwargs + + @override + def _requester_generator(self, **kwargs: Any) -> Generator: + request_type: RequestType = kwargs["request_type"] + event_type: EventType = kwargs["event_type"] + workflow, workflow_request = self._get_workflow_request( + request_type.type_id, + **kwargs, + ) + try: + workflow_event = workflow_request.allowed_events[event_type.type_id] + except KeyError as e: + workflow_code = _get_workflow_code_from_workflow(workflow) + raise EventTypeNotInWorkflow( + request_type=request_type.type_id, + workflow_code=workflow_code, + event_type=event_type.type_id, + ) from e + return workflow_event.submitter_generator diff --git a/oarepo_workflows/requests/permissions.py b/oarepo_workflows/requests/permissions.py new file mode 100644 index 0000000..d6ef963 --- /dev/null +++ b/oarepo_workflows/requests/permissions.py @@ -0,0 +1,43 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 permission policy that overwrites invenio-requests.""" + +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.requests.generators.conditionals import IfEventType +from oarepo_workflows.requests.generators.workflow_based import ( + EventCreatorsFromWorkflow, + RequestCreatorsFromWorkflow, +) + + +class CreatorsFromWorkflowRequestsPermissionPolicy(InvenioRequestsPermissionPolicy): + """Permissions for requests based on workflows. + + This permission adds a special generator RequestCreatorsFromWorkflow() to the default permissions. + This generator takes a topic, gets the workflow from the topic and returns the generator for + creators defined on the WorkflowRequest. + """ + + can_create = [ + SystemProcess(), + RequestCreatorsFromWorkflow(), + ] + + can_create_comment = [ + SystemProcess(), + IfEventType( + [LogEventType.type_id, CommentEventType.type_id], [Creator(), Receiver()] + ), + EventCreatorsFromWorkflow(), + ] diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index c2495c4..c1f6f9f 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -1,102 +1,35 @@ -import dataclasses -import inspect -from datetime import timedelta -from typing import Dict, List, Optional, Tuple - -from invenio_access.permissions import SystemRoleNeed -from invenio_records_permissions.generators import Generator - -from oarepo_workflows.proxies import current_oarepo_workflows -from oarepo_workflows.requests.events import WorkflowEvent - - -@dataclasses.dataclass -class WorkflowRequest: - requesters: List[Generator] | Tuple[Generator] - recipients: List[Generator] | Tuple[Generator] - events: Dict[str, WorkflowEvent] = dataclasses.field(default_factory=lambda: {}) - transitions: Optional["WorkflowTransitions"] = dataclasses.field( - default_factory=lambda: WorkflowTransitions() - ) - escalations: Optional[List["WorkflowRequestEscalation"]] = None - - def reference_receivers(self, **kwargs): - if not self.recipients: - return None - for generator in self.recipients: - if isinstance(generator, RecipientGeneratorMixin): - ref = generator.reference_receivers(**kwargs) - if ref: - return ref[0] - return None - - def needs(self, **kwargs): - return { - need for generator in self.requesters for need in generator.needs(**kwargs) - } - - def excludes(self, **kwargs): - return { - exclude - for generator in self.requesters - for exclude in generator.excludes(**kwargs) - } - - def query_filters(self, **kwargs): - return [ - query_filter - for generator in self.requesters - for query_filter in generator.query_filter(**kwargs) - ] - - @property - def allowed_events(self): - return current_oarepo_workflows.default_workflow_event_submitters | self.events - - -@dataclasses.dataclass -class WorkflowTransitions: - """ - Transitions for a workflow request. If the request is submitted and submitted is filled, - the record (topic) of the request will be moved to state defined in submitted. - If the request is approved, the record will be moved to state defined in approved. - If the request is rejected, the record will be moved to state defined in rejected. - """ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 workflow policy definition.""" - submitted: Optional[str] = None - accepted: Optional[str] = None - declined: Optional[str] = None - - def __getitem__(self, item): - try: - return getattr(self, item) - except AttributeError: - raise KeyError( - f"Transition {item} not defined in {self.__class__.__name__}" - ) +from __future__ import annotations +from typing import TYPE_CHECKING, Any -@dataclasses.dataclass -class WorkflowRequestEscalation: - """ - If the request is not approved/declined/cancelled in time, it might be passed to another recipient - (such as a supervisor, administrator, ...). The escalation is defined by the time after which the - request is escalated and the recipients of the escalation. - """ +from .requests import ( + WorkflowRequest, +) - after: timedelta - recipients: List[Generator] | Tuple[Generator] +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records.api import Record class WorkflowRequestPolicy: - """Base class for workflow request policies. Inherit from this class + """Base class for workflow request policies. + + Inherit from this class and add properties to define specific requests for a workflow. The name of the property is the request_type name and the value must be an instance of WorkflowRequest. Example: - class MyWorkflowRequests(WorkflowRequestPolicy): delete_request = WorkflowRequest( requesters = [ @@ -109,59 +42,52 @@ class MyWorkflowRequests(WorkflowRequestPolicy): rejected = 'published' ) ) + """ - def __getitem__(self, item): + def __init__(self): + """Initialize the request policy.""" + for rt_code, rt in self.items(): + rt._request_type = rt_code + + def __getitem__(self, request_type_id: str) -> WorkflowRequest: + """Get the workflow request type by its id.""" try: - return getattr(self, item) + return getattr(self, request_type_id) except AttributeError: raise KeyError( - f"Request type {item} not defined in {self.__class__.__name__}" - ) + f"Request type {request_type_id} not defined in {self.__class__.__name__}" + ) from None - def items(self): - return inspect.getmembers(self, lambda x: isinstance(x, WorkflowRequest)) + def items(self) -> list[tuple[str, WorkflowRequest]]: + """Return the list of request types and their instances. - -class RecipientGeneratorMixin: - """ - Mixin for permission generators that can be used as recipients in WorkflowRequest. - """ - - def reference_receivers(self, record=None, request_type=None, **kwargs): + This call mimics mapping items() method. """ - Taken the context (will include record amd request type at least), - return the reference receiver(s) of the request. - - Should return a list of receiver classes (whatever they are) or dictionary - serialization of the receiver classes. - - Might return empty list or None to indicate that the generator does not - provide any receivers. + ret = [] + parent_attrs = set(dir(WorkflowRequestPolicy)) + for attr in dir(self.__class__): + if parent_attrs and attr in parent_attrs: + continue + if attr.startswith("_"): + continue + possible_request = getattr(self, attr, None) + if isinstance(possible_request, WorkflowRequest): + ret.append((attr, possible_request)) + return ret + + def applicable_workflow_requests( + self, identity: Identity, *, record: Record, **context: Any + ) -> list[tuple[str, WorkflowRequest]]: + """Return a list of applicable requests for the identity and context. + + :param identity: Identity of the requester. + :param context: Context of the request that is passed to the requester generators. + :return: List of tuples (request_type_id, request) that are applicable for the identity and context. """ - raise NotImplementedError("Implement reference receiver in your code") - - -auto_request_need = SystemRoleNeed("auto_request") -auto_approve_need = SystemRoleNeed("auto_approve") - - -class AutoRequest(Generator): - """ - Auto request generator. This generator is used to automatically create a request - when a record is moved to a specific state. - """ - - def needs(self, **kwargs): - """Enabling Needs.""" - return [auto_request_need] - - -class AutoApprove(RecipientGeneratorMixin, Generator): - """ - Auto approve generator. If the generator is used within recipients of a request, - the request will be automatically approved when the request is submitted. - """ + ret = [] - def reference_receivers(self, record=None, request_type=None, **kwargs): - return [{"auto_approve": "true"}] + for name, request in self.items(): + if request.is_applicable(identity, record=record, **context): + ret.append((name, request)) + return ret diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py new file mode 100644 index 0000000..a2336fb --- /dev/null +++ b/oarepo_workflows/requests/requests.py @@ -0,0 +1,198 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Workflow requests.""" + +from __future__ import annotations + +import dataclasses +from functools import cached_property +from logging import getLogger +from typing import TYPE_CHECKING, Any, Optional + +from invenio_requests.proxies import ( + current_request_type_registry, + current_requests_service, +) + +from oarepo_workflows.errors import InvalidConfigurationError +from oarepo_workflows.proxies import current_oarepo_workflows +from oarepo_workflows.requests.generators import RecipientGeneratorMixin +from oarepo_workflows.requests.generators.multiple import MultipleGeneratorsGenerator + +if TYPE_CHECKING: + from datetime import timedelta + + from flask_principal import Identity + from invenio_records_permissions.generators import Generator + from invenio_records_resources.records.api import Record + from invenio_requests.customizations.request_types import RequestType + + from oarepo_workflows.requests.events import WorkflowEvent + +log = getLogger(__name__) + + +@dataclasses.dataclass +class WorkflowRequest: + """Workflow request definition. + + The request is defined by the requesters and recipients. + The requesters are the generators that define who can submit the request. The recipients + are the generators that define who can approve the request. + """ + + requesters: list[Generator] | tuple[Generator] + """Generators that define who can submit the request.""" + + recipients: list[Generator] | tuple[Generator] + """Generators that define who can approve the request.""" + + events: dict[str, WorkflowEvent] = dataclasses.field(default_factory=lambda: {}) + """Events that can be submitted with the request.""" + + transitions: Optional[WorkflowTransitions] = dataclasses.field( + default_factory=lambda: WorkflowTransitions() + ) + """Transitions applied to the state of the topic of the request.""" + + escalations: Optional[list[WorkflowRequestEscalation]] = None + """Escalations applied to the request if not approved/declined in time.""" + + _request_type: RequestType | None = dataclasses.field(default=None, init=False) + + @cached_property + def requester_generator(self) -> Generator: + """Return the requesters as a single requester generator.""" + return MultipleGeneratorsGenerator(self.requesters) + + def recipient_entity_reference(self, **context: Any) -> dict | None: + """Return the reference receiver of the workflow request with the given context. + + Note: invenio supports only one receiver, so the first one is returned at the moment. + Later on, a composite receiver can be implemented. + + :param context: Context of the request. + """ + return RecipientEntityReference(self, **context) + + def is_applicable( + self, identity: Identity, *, record: Record, **context: Any + ) -> bool: + """Check if the request is applicable for the identity and context (which might include record, community, ...). + + :param identity: Identity of the requester. + :param context: Context of the request that is passed to the requester generators. + """ + try: + if hasattr(self.request_type, "is_applicable_to"): + # the is_applicable_to must contain a permission check, so do not need to do any check here ... + return self.request_type.is_applicable_to( + identity, topic=record, **context + ) + else: + return current_requests_service.check_permission( + identity, + "create", + record=record, + request_type=self.request_type, + **context, + ) + except InvalidConfigurationError: + raise + except Exception as e: + log.exception("Error checking request applicability: %s", e) + return False + + @cached_property + def request_type(self) -> RequestType: + """Return the request type for the workflow request.""" + if self._request_type is None: + raise InvalidConfigurationError( + f"Probably this WorkflowRequest ({self}) is not part of a Workflow. " + "Please add it to a workflow or manually set the ._request_type attribute " + "to a code of a registered request type." + ) + try: + return current_request_type_registry.lookup(self._request_type) + except KeyError as e: + raise InvalidConfigurationError( + f"Request type {self._request_type} not found in the request type registry." + ) from e + + @property + def allowed_events(self) -> dict[str, WorkflowEvent]: + """Return the allowed events for the workflow request.""" + return current_oarepo_workflows.default_workflow_events | self.events + + +@dataclasses.dataclass +class WorkflowTransitions: + """Transitions for a workflow request. + + If the request is submitted and submitted is filled, + the record (topic) of the request will be moved to state defined in submitted. + If the request is approved, the record will be moved to state defined in approved. + If the request is rejected, the record will be moved to state defined in rejected. + """ + + submitted: Optional[str] = None + accepted: Optional[str] = None + declined: Optional[str] = None + + def __getitem__(self, transition_name: str): + """Get the transition by name.""" + if transition_name not in ["submitted", "accepted", "declined"]: + raise KeyError( + f"Transition {transition_name} not defined in {self.__class__.__name__}" + ) + return getattr(self, transition_name) + + +@dataclasses.dataclass +class WorkflowRequestEscalation: + """Escalation of the request. + + If the request is not approved/declined/cancelled in time, it might be passed to another recipient + (such as a supervisor, administrator, ...). The escalation is defined by the time after which the + request is escalated and the recipients of the escalation. + """ + + after: timedelta + recipients: list[Generator] | tuple[Generator] + + +# noinspection PyPep8Naming +def RecipientEntityReference(request: WorkflowRequest, **context: Any) -> dict | None: + """Return the reference receiver of the workflow request with the given context. + + Note: invenio supports only one receiver, so the first one is returned at the moment. + Later on, a composite receiver can be implemented. + + :param request: Workflow request. + :param context: Context of the request. + + :return: Reference receiver as a dictionary or None if no receiver has been resolved. + + Implementation note: intentionally looks like a class, later on might be converted into one extending from dict. + """ + if not request.recipients: + return None + + all_receivers = [] + for generator in request.recipients: + if isinstance(generator, RecipientGeneratorMixin): + ref: list[dict] = generator.reference_receivers(**context) + if ref: + all_receivers.extend(ref) + + if all_receivers: + if len(all_receivers) > 1: + log.debug("Multiple receivers for request %s: %s", request, all_receivers) + return all_receivers[0] + + return None diff --git a/oarepo_workflows/resolvers/__init__.py b/oarepo_workflows/resolvers/__init__.py new file mode 100644 index 0000000..10071d3 --- /dev/null +++ b/oarepo_workflows/resolvers/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py new file mode 100644 index 0000000..f13c90c --- /dev/null +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -0,0 +1,68 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Auto approve entity and resolver.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from invenio_records_resources.references.entity_resolvers import EntityProxy +from invenio_records_resources.references.entity_resolvers.base import EntityResolver + +if TYPE_CHECKING: + from flask_principal import Identity, Need + + +class AutoApproveEntity: + """Entity representing auto approve.""" + + +class AutoApproveProxy(EntityProxy): + """Proxy for auto approve entity.""" + + def _resolve(self) -> AutoApproveEntity: + """Resolve the entity reference into entity.""" + return AutoApproveEntity() + + def get_needs(self, ctx: dict | None = None) -> list[Need]: + """Get needs that the entity generate.""" + return [] # grant_tokens calls this + + def pick_resolved_fields(self, identity: Identity, resolved_dict: dict) -> dict: + """Pick resolved fields for serialization of the entity to json.""" + return {"auto_approve": resolved_dict["id"]} + + +class AutoApproveResolver(EntityResolver): + """A resolver that resolves auto approve entity.""" + + type_id = "auto_approve" + + def __init__(self) -> None: + """Initialize the resolver.""" + super().__init__("auto_approve") + + def matches_reference_dict(self, ref_dict: dict) -> bool: + """Check if the reference dictionary can be resolved by this resolver.""" + return self._parse_ref_dict_type(ref_dict) == self.type_id + + def _reference_entity(self, entity: Any) -> dict[str, str]: + """Return a reference dictionary for the entity.""" + return {self.type_id: "true"} + + def matches_entity(self, entity: Any) -> bool: + """Check if the entity can be serialized to a reference by this resolver.""" + return isinstance(entity, AutoApproveEntity) + + def _get_entity_proxy(self, ref_dict: dict) -> AutoApproveProxy: + """Get the entity proxy for the reference dictionary. + + Note: the proxy is returned to ensure the entity is loaded lazily, when needed. + :param ref_dict: Reference dictionary. + """ + return AutoApproveProxy(self, ref_dict) diff --git a/oarepo_workflows/services/__init__.py b/oarepo_workflows/services/__init__.py index e69de29..038ae23 100644 --- a/oarepo_workflows/services/__init__.py +++ b/oarepo_workflows/services/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflows/services/auto_approve/__init__.py b/oarepo_workflows/services/auto_approve/__init__.py new file mode 100644 index 0000000..e0a8132 --- /dev/null +++ b/oarepo_workflows/services/auto_approve/__init__.py @@ -0,0 +1,30 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 for reading auto-approve entities. + +The implementation is simple as auto approve is just one entity +so there is no need to store it to database/fetch it from the database. +""" + +from __future__ import annotations + +from oarepo_runtime.services.entity.config import KeywordEntityServiceConfig +from oarepo_runtime.services.entity.service import KeywordEntityService + + +class AutoApproveEntityServiceConfig(KeywordEntityServiceConfig): + """Configuration for auto-approve entity service.""" + + service_id = "auto_approve" + keyword = "auto_approve" + + +class AutoApproveEntityService(KeywordEntityService): + """Service for reading auto-approve entities.""" + + pass diff --git a/oarepo_workflows/services/components/__init__.py b/oarepo_workflows/services/components/__init__.py index e69de29..c0150e2 100644 --- a/oarepo_workflows/services/components/__init__.py +++ b/oarepo_workflows/services/components/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 components for supporting workflows on Invenio records.""" diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 86c7213..c416353 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -1,16 +1,58 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 components for supporting workflows on Invenio records.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + from invenio_records_resources.services.records.components.base import ServiceComponent from oarepo_workflows.errors import MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows +if TYPE_CHECKING: + from flask_principal import Identity + from invenio_records_resources.records import Record + class WorkflowComponent(ServiceComponent): + """Workflow component. + + This component is responsible for checking if the workflow is defined in the input data + when record is created. If it is not present, it raises an error. + """ - def create(self, identity, data=None, record=None, **kwargs): + def create( + self, + identity: Identity, + data: Optional[dict] = None, + record: Optional[Record] = None, + **kwargs: Any, + ) -> None: + """Implement record creation checks and set the workflow on the created record.""" + if not data: + # sanity check, should be handled by policy before the component is called + raise MissingWorkflowError( + "Workflow not defined in input. As this should be handled by a policy, " + "make sure you are using workflow-enabled policy.", + record=data, + ) # pragma: no cover try: workflow_id = data["parent"]["workflow"] - except KeyError: - raise MissingWorkflowError("Workflow not defined in input.") + except KeyError as e: # pragma: no cover + # sanity check, should be handled by policy before the component is called + raise MissingWorkflowError( # pragma: no cover + "Workflow not defined in input. As this should be handled by a policy, " + "make sure you are using workflow-enabled policy.", + record=data, + ) from e + current_oarepo_workflows.set_workflow( identity, record, workflow_id, uow=self.uow, **kwargs ) diff --git a/oarepo_workflows/services/permissions/__init__.py b/oarepo_workflows/services/permissions/__init__.py index 09499f4..7aba29a 100644 --- a/oarepo_workflows/services/permissions/__init__.py +++ b/oarepo_workflows/services/permissions/__init__.py @@ -1,11 +1,22 @@ -from .generators import AutoApprove, AutoRequest, IfInState, WorkflowPermission -from .policy import DefaultWorkflowPermissions, WorkflowPermissionPolicy +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 workflows.""" + +from __future__ import annotations + +from .generators import FromRecordWorkflow, IfInState, WorkflowPermission +from .record_permission_policy import WorkflowRecordPermissionPolicy +from .workflow_permissions import DefaultWorkflowPermissions __all__ = ( "IfInState", "WorkflowPermission", + "WorkflowRecordPermissionPolicy", "DefaultWorkflowPermissions", - "WorkflowPermissionPolicy", - "AutoApprove", - "AutoRequest", + "FromRecordWorkflow", ) diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 12366eb..03d57e3 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -1,123 +1,242 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 usable in workflow configurations.""" + +from __future__ import annotations + import operator from functools import reduce from itertools import chain +from typing import TYPE_CHECKING, Any, Iterable, Optional from invenio_records_permissions.generators import ConditionalGenerator, Generator from invenio_search.engine import dsl from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows -from oarepo_workflows.requests.policy import RecipientGeneratorMixin -from oarepo_workflows.services.permissions.identity import auto_request_need + +if TYPE_CHECKING: + from flask_principal import Need + from invenio_records_permissions import RecordPermissionPolicy + from invenio_records_resources.records import Record # invenio_records_permissions.generators.ConditionalGenerator._make_query -def _make_query(generators, **kwargs): - queries = [g.query_filter(**kwargs) for g in generators] +def _make_query(generators: Iterable[Generator], **context: Any) -> dict | None: + queries = [g.query_filter(**context) for g in generators] queries = [q for q in queries if q] return reduce(operator.or_, queries) if queries else None -class WorkflowPermission(Generator): - def __init__(self, action=None): +class FromRecordWorkflow(Generator): + """Permission delegating check to workflow. + + The implementation of the permission gets the workflow id from the passed context + (record or data) and then looks up the workflow definition in the configuration. + + The workflow definition must contain a permissions policy that is then used to + determine the permissions for the action. + """ + + _action: str + + def __init__(self, action: str) -> None: + """Initialize the permission.""" # might not be needed in subclasses super().__init__() self._action = action - def _get_workflow_id(self, record=None, **kwargs): + # noinspection PyMethodMayBeStatic + def _get_workflow_id(self, record: Optional[Record] = None, **context: Any) -> str: + """Get the workflow id from the context. + + If the record is passed, the workflow is determined from the record. + If the record is not passed, the workflow is determined from the input data. + + If the workflow is not found, an error is raised. + + :param record: Record to get the workflow from. + :param context: Context to get the workflow from. + :return: Workflow id. + :raises MissingWorkflowError: If the workflow is not found on the record/data. + """ if record: workflow_id = current_oarepo_workflows.get_workflow_from_record(record) if not workflow_id: - raise MissingWorkflowError("Workflow not defined on record.") + raise MissingWorkflowError( + "Workflow not defined on record.", record=record + ) else: - workflow_id = kwargs.get("data", {}).get("parent", {}).get("workflow", {}) + data = context.get("data", {}) + workflow_id = data.get("parent", {}).get("workflow", {}) if not workflow_id: - raise MissingWorkflowError("Workflow not defined in input.") + raise MissingWorkflowError( + "Workflow not defined in input.", record=data + ) return workflow_id - def _get_permissions_from_workflow(self, record=None, action_name=None, **kwargs): - workflow_id = self._get_workflow_id(record, **kwargs) + def _get_permissions_from_workflow( + self, + action_name: str, + record: Optional[Record] = None, + **context: Any, + ) -> RecordPermissionPolicy: + """Get the permissions policy from the workflow. + + At first the workflow id is determined from the context. + Then the permissions policy is determined from the workflow configuration, + is instantiated with the action name and the context and the permissions + for the action are returned. + """ + workflow_id = self._get_workflow_id(record, **context) if workflow_id not in current_oarepo_workflows.record_workflows: raise InvalidWorkflowError( - f"Workflow {workflow_id} does not exist in the configuration." + f"Workflow {workflow_id} does not exist in the configuration.", + record=record or context.get("data", {}), ) policy = current_oarepo_workflows.record_workflows[workflow_id].permissions - return policy(action_name, record=record, **kwargs) + return policy(action_name, record=record, **context) - def needs(self, record=None, **kwargs): - return self._get_permissions_from_workflow(record, self._action, **kwargs).needs + def needs(self, record: Optional[Record] = None, **context: Any) -> list[Need]: + """Return needs that are generated by the workflow permission.""" + return self._get_permissions_from_workflow( + self._action, record, **context + ).needs + + def excludes(self, **context: Any) -> list[Need]: + """Return excludes that are generated by the workflow permission.""" + return self._get_permissions_from_workflow(self._action, **context).excludes + + def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: + """Return query filters that are generated by the workflow permission. - def query_filter(self, record=None, **kwargs): + Note: this implementation in fact will be called from WorkflowRecordPermissionPolicy.query_filters + for each registered workflow type. The query_filters are then combined into a single query. + """ return self._get_permissions_from_workflow( - record, self._action, **kwargs + self._action, record, **context ).query_filters +class WorkflowPermission(FromRecordWorkflow): + """Deprecated alias for FromRecordWorkflow.""" + + def __init__(self, action: str) -> None: + """Initialize the generator.""" + import warnings + + warnings.warn( + "WorkflowPermission is deprecated. Use FromRecordWorkflow instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(action) + + class IfInState(ConditionalGenerator): - def __init__(self, state, then_): - super().__init__(then_, else_=[]) + """Generator that checks if the record is in a specific state. + + If it is in the state, the then_ generators are used, otherwise the else_ generators are used. + + Example: + .. code-block:: python + + can_edit = [ IfInState("draft", [RecordOwners()]) ] + + """ + + def __init__( + self, + state: str, + then_: list[Generator] | tuple[Generator] | Generator | None = None, + else_: list[Generator] | tuple[Generator] | Generator | None = None, + ) -> None: + """Initialize the generator.""" + if isinstance(then_, Generator): + then_ = [then_] + if isinstance(else_, Generator): + else_ = [else_] + super().__init__(then_ or [], else_=else_ or []) self.state = state - def _condition(self, record, **kwargs): + def _condition(self, record: Record, **context: Any) -> bool: + """Check if the record is in the state.""" try: - state = record.state + return record.state == self.state # noqa as AttributeError is caught except AttributeError: return False - return state == self.state - def query_filter(self, **kwargs): - """Filters for queries.""" + def query_filter(self, **context: Any) -> dsl.Q: + """Apply then or else filter.""" field = "state" q_instate = dsl.Q("term", **{field: self.state}) - then_query = self._make_query(self.then_, **kwargs) + if self.then_: + then_query = self._make_query(self.then_, **context) + else: + then_query = dsl.Q("match_none") - return q_instate & then_query + if self.else_: + else_query = self._make_query(self.else_, **context) + else: + else_query = dsl.Q("match_none") + + return (q_instate & then_query) | (~q_instate & else_query) class SameAs(Generator): - def __init__(self, as_): - self.as_ = as_ + """Generator that delegates the permissions to another action. - def _generators(self, policy, **kwargs): - return getattr(policy, self.as_) + Example: + .. code-block:: python + class Perms: + can_create_files = [SameAs("edit_files")] + can_edit_files = [RecordOwners()] - def needs(self, policy, **kwargs): - needs = [ - generator.needs(**kwargs) - for generator in self._generators(policy, **kwargs) - ] - return set(chain.from_iterable(needs)) + would mean that the permissions for creating files are the same as for editing files. + This works even if you inherit from the class and override the can_edit_files. - def excludes(self, policy, **kwargs): - """Preventing Needs.""" - excludes = [ - generator.excludes(**kwargs) - for generator in self._generators(policy, **kwargs) - ] - return set(chain.from_iterable(excludes)) - - def query_filter(self, policy, **kwargs): - """Search filters.""" - return _make_query(self._generators(policy, **kwargs), **kwargs) + """ + def __init__(self, permission_name: str) -> None: + """Initialize the generator. -class AutoRequest(Generator): - """ - Auto request generator. This generator is used to automatically create a request - when a record is moved to a specific state. - """ + :param permission_name: Name of the permission to delegate to. In most cases, + it will look like "can_". A property with this name must exist on the policy + and its value must be a list of generators. + """ + self.delegated_permission_name = permission_name - def needs(self, **kwargs): - """Enabling Needs.""" - return [auto_request_need] + # noinspection PyUnusedLocal + def _generators( + self, *, policy: RecordPermissionPolicy, **context: Any + ) -> list[Generator]: + """Get the generators from the policy.""" + return getattr(policy, self.delegated_permission_name) + def needs(self, *, policy: RecordPermissionPolicy, **context: Any) -> list[Need]: + """Get the needs from the policy.""" + needs = [ + generator.needs(**context) + for generator in self._generators(policy=policy, **context) + ] + return list(chain.from_iterable(needs)) -class AutoApprove(RecipientGeneratorMixin, Generator): - """ - Auto approve generator. If the generator is used within recipients of a request, - the request will be automatically approved when the request is submitted. - """ + def excludes(self, *, policy: RecordPermissionPolicy, **context: Any) -> list[Need]: + """Get the excludes from the policy.""" + excludes = [ + generator.excludes(**context) + for generator in self._generators(policy=policy, **context) + ] + return list(chain.from_iterable(excludes)) - def reference_receivers(self, record=None, request_type=None, **kwargs): - return [{"auto_approve": "true"}] + def query_filter( + self, *, policy: RecordPermissionPolicy, **context: Any + ) -> dict | None: + """Search filters.""" + return _make_query(self._generators(policy=policy, **context), **context) diff --git a/oarepo_workflows/services/permissions/identity.py b/oarepo_workflows/services/permissions/identity.py deleted file mode 100644 index 767201d..0000000 --- a/oarepo_workflows/services/permissions/identity.py +++ /dev/null @@ -1,3 +0,0 @@ -from invenio_access.permissions import SystemRoleNeed - -auto_request_need = SystemRoleNeed("auto_request") diff --git a/oarepo_workflows/services/permissions/policy.py b/oarepo_workflows/services/permissions/policy.py deleted file mode 100644 index 500cb98..0000000 --- a/oarepo_workflows/services/permissions/policy.py +++ /dev/null @@ -1,118 +0,0 @@ -from functools import reduce - -from invenio_records_permissions import RecordPermissionPolicy -from invenio_records_permissions.generators import ( - AnyUser, - AuthenticatedUser, - Disable, - SystemProcess, -) -from invenio_search.engine import dsl -from oarepo_runtime.services.generators import RecordOwners - -from ...proxies import current_oarepo_workflows -from .generators import IfInState, SameAs, WorkflowPermission - - -class DefaultWorkflowPermissions(RecordPermissionPolicy): - """ - Base class for workflow permissions, subclass from it and put the result to Workflow constructor. - Example: - class MyWorkflowPermissions(DefaultWorkflowPermissions): - can_read = [AnyUser()] - in invenio.cfg - WORKFLOWS = { - 'default': Workflow( - permission_policy_cls = MyWorkflowPermissions, ... - ) - } - """ - - # new version - update; edit current version - disable -> idk if there's other way than something like IfNoEditDraft/IfNoNewVersionDraft generators- - - files_edit = [ - IfInState("draft", [RecordOwners()]), - IfInState("published", [Disable()]), - ] - - system_process = SystemProcess() - - def __init__(self, action_name=None, **over): - can = getattr(self, f"can_{action_name}") - if self.system_process not in can: - can.append(self.system_process) - over["policy"] = self - super().__init__(action_name, **over) - - can_read = [ - IfInState("draft", [RecordOwners()]), - IfInState("published", [AuthenticatedUser()]), - ] - can_update = [IfInState("draft", [RecordOwners()])] - can_delete = [ - IfInState("draft", [RecordOwners()]), - ] - can_create = [AuthenticatedUser()] - can_publish = [AuthenticatedUser()] - can_new_version = [AuthenticatedUser()] - - can_create_files = [SameAs("files_edit")] - can_set_content_files = [SameAs("files_edit")] - can_commit_files = [SameAs("files_edit")] - can_update_files = [SameAs("files_edit")] - can_delete_files = [SameAs("files_edit")] - can_draft_create_files = [SameAs("files_edit")] - can_read_files = [SameAs("can_read")] - can_get_content_files = [SameAs("can_read")] - - can_read_draft = [SameAs("can_read")] - can_update_draft = [SameAs("can_update")] - can_delete_draft = [SameAs("can_delete")] - - -class WorkflowPermissionPolicy(RecordPermissionPolicy): - """ - Permission policy to be used in permission presets directly on RecordServiceConfig.permission_policy_cls - Do not use this class in Workflow constructor. - """ - - can_create = [WorkflowPermission("create")] - can_publish = [WorkflowPermission("publish")] - can_read = [WorkflowPermission("read")] - can_update = [WorkflowPermission("update")] - can_delete = [WorkflowPermission("delete")] - can_create_files = [WorkflowPermission("create_files")] - can_set_content_files = [WorkflowPermission("set_content_files")] - can_get_content_files = [WorkflowPermission("get_content_files")] - can_commit_files = [WorkflowPermission("commit_files")] - can_read_files = [WorkflowPermission("read_files")] - can_update_files = [WorkflowPermission("update_files")] - can_delete_files = [WorkflowPermission("delete_files")] - - can_read_draft = [WorkflowPermission("read_draft")] - can_update_draft = [WorkflowPermission("update_draft")] - can_delete_draft = [WorkflowPermission("delete_draft")] - can_edit = [WorkflowPermission("edit")] - can_new_version = [WorkflowPermission("new_version")] - can_draft_create_files = [WorkflowPermission("draft_create_files")] - - can_search = [SystemProcess(), AnyUser()] - can_search_drafts = [SystemProcess(), AnyUser()] - can_search_versions = [SystemProcess(), AnyUser()] - - @property - def query_filters(self): - if not (self.action == "read" or self.action == "read_draft"): - return super().query_filters - workflows = current_oarepo_workflows.record_workflows - queries = [] - for workflow_id, workflow in workflows.items(): - q_inworkflow = dsl.Q("term", **{"parent.workflow": workflow_id}) - workflow_filters = workflow.permissions( - self.action, **self.over - ).query_filters - if not workflow_filters: - workflow_filters = [dsl.Q("match_none")] - query = reduce(lambda f1, f2: f1 | f2, workflow_filters) & q_inworkflow - queries.append(query) - return [q for q in queries if q] diff --git a/oarepo_workflows/services/permissions/record_permission_policy.py b/oarepo_workflows/services/permissions/record_permission_policy.py new file mode 100644 index 0000000..d23d7ed --- /dev/null +++ b/oarepo_workflows/services/permissions/record_permission_policy.py @@ -0,0 +1,71 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 policy for workflows.""" + +from __future__ import annotations + +from functools import reduce + +from invenio_records_permissions import RecordPermissionPolicy +from invenio_records_permissions.generators import ( + AnyUser, + SystemProcess, +) +from invenio_search.engine import dsl + +from ...proxies import current_oarepo_workflows +from .generators import FromRecordWorkflow + + +class WorkflowRecordPermissionPolicy(RecordPermissionPolicy): + """Permission policy to be used in permission presets directly on RecordServiceConfig.permission_policy_cls. + + Do not use this class in Workflow constructor. + """ + + can_create = [FromRecordWorkflow("create")] + can_publish = [FromRecordWorkflow("publish")] + can_read = [FromRecordWorkflow("read")] + can_update = [FromRecordWorkflow("update")] + can_delete = [FromRecordWorkflow("delete")] + can_create_files = [FromRecordWorkflow("create_files")] + can_set_content_files = [FromRecordWorkflow("set_content_files")] + can_get_content_files = [FromRecordWorkflow("get_content_files")] + can_commit_files = [FromRecordWorkflow("commit_files")] + can_read_files = [FromRecordWorkflow("read_files")] + can_update_files = [FromRecordWorkflow("update_files")] + can_delete_files = [FromRecordWorkflow("delete_files")] + + can_read_draft = [FromRecordWorkflow("read_draft")] + can_update_draft = [FromRecordWorkflow("update_draft")] + can_delete_draft = [FromRecordWorkflow("delete_draft")] + can_edit = [FromRecordWorkflow("edit")] + can_new_version = [FromRecordWorkflow("new_version")] + can_draft_create_files = [FromRecordWorkflow("draft_create_files")] + + can_search = [SystemProcess(), AnyUser()] + can_search_drafts = [SystemProcess(), AnyUser()] + can_search_versions = [SystemProcess(), AnyUser()] + + @property + def query_filters(self) -> list[dict]: + """Return query filters from the delegated workflow permissions.""" + if not (self.action == "read" or self.action == "read_draft"): + return super().query_filters + workflows = current_oarepo_workflows.record_workflows + queries = [] + for workflow_id, workflow in workflows.items(): + q_in_workflow = dsl.Q("term", **{"parent.workflow": workflow_id}) + workflow_filters = workflow.permissions( + self.action, **self.over + ).query_filters + if not workflow_filters: + workflow_filters = [dsl.Q("match_none")] + query = reduce(lambda f1, f2: f1 | f2, workflow_filters) & q_in_workflow + queries.append(query) + return [q for q in queries if q] diff --git a/oarepo_workflows/services/permissions/workflow_permissions.py b/oarepo_workflows/services/permissions/workflow_permissions.py new file mode 100644 index 0000000..ffda667 --- /dev/null +++ b/oarepo_workflows/services/permissions/workflow_permissions.py @@ -0,0 +1,80 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Definition of workflow permissions.""" + +from __future__ import annotations + +from typing import Any + +from invenio_records_permissions import RecordPermissionPolicy +from invenio_records_permissions.generators import ( + AuthenticatedUser, + Disable, + SystemProcess, +) +from oarepo_runtime.services.permissions import RecordOwners + +from .generators import IfInState, SameAs + + +class DefaultWorkflowPermissions(RecordPermissionPolicy): + """Base class for workflow permissions, subclass from it and put the result to Workflow constructor. + + Example: + class MyWorkflowPermissions(DefaultWorkflowPermissions): + can_read = [AnyUser()] + in invenio.cfg + WORKFLOWS = { + 'default': Workflow( + permission_policy_cls = MyWorkflowPermissions, ... + ) + } + + """ + + # TODO: new version - update; edit current version - disable -> idk if there's other way than something like IfNoEditDraft/IfNoNewVersionDraft generators- + + files_edit = [ + IfInState("draft", [RecordOwners()]), + IfInState("published", [Disable()]), + ] + + system_process = SystemProcess() + + def __init__(self, action_name: str | None = None, **over: Any) -> None: + """Initialize the workflow permissions.""" + can = getattr(self, f"can_{action_name}") + if self.system_process not in can: + can.append(self.system_process) + over["policy"] = self + super().__init__(action_name, **over) + + can_read = [ + IfInState("draft", [RecordOwners()]), + IfInState("published", [AuthenticatedUser()]), + ] + can_update = [IfInState("draft", [RecordOwners()])] + can_delete = [ + IfInState("draft", [RecordOwners()]), + ] + can_create = [AuthenticatedUser()] + can_publish = [AuthenticatedUser()] + can_new_version = [AuthenticatedUser()] + + can_create_files = [SameAs("files_edit")] + can_set_content_files = [SameAs("files_edit")] + can_commit_files = [SameAs("files_edit")] + can_update_files = [SameAs("files_edit")] + can_delete_files = [SameAs("files_edit")] + can_draft_create_files = [SameAs("files_edit")] + can_read_files = [SameAs("can_read")] + can_get_content_files = [SameAs("can_read")] + + can_read_draft = [SameAs("can_read")] + can_update_draft = [SameAs("can_update")] + can_delete_draft = [SameAs("can_delete")] diff --git a/oarepo_workflows/services/records/__init__.py b/oarepo_workflows/services/records/__init__.py index e69de29..52b293a 100644 --- a/oarepo_workflows/services/records/__init__.py +++ b/oarepo_workflows/services/records/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 workflow-enabled records.""" diff --git a/oarepo_workflows/services/records/schema.py b/oarepo_workflows/services/records/schema.py index afac455..de75b07 100644 --- a/oarepo_workflows/services/records/schema.py +++ b/oarepo_workflows/services/records/schema.py @@ -1,6 +1,19 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 records with workflow support.""" + +from __future__ import annotations + import marshmallow as ma from invenio_drafts_resources.services.records.schema import ParentSchema class WorkflowParentSchema(ParentSchema): + """Schema for parent record with workflow support.""" + workflow = ma.fields.String() diff --git a/oarepo_workflows/views/__init__.py b/oarepo_workflows/views/__init__.py new file mode 100644 index 0000000..240ba96 --- /dev/null +++ b/oarepo_workflows/views/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""Registered blueprints for API/UI.""" diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py new file mode 100644 index 0000000..6e61a03 --- /dev/null +++ b/oarepo_workflows/views/api.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 blueprints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from flask import Blueprint, Flask + +if TYPE_CHECKING: + from flask.blueprints import BlueprintSetupState + + +def create_api_blueprint(app: Flask) -> Blueprint: + """Create requests blueprint.""" + blueprint = Blueprint("oarepo-workflows", __name__) + + # noinspection PyUnusedLocal + def register_auto_approve_entity_resolver(state: BlueprintSetupState) -> None: + from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + + requests = app.extensions["invenio-requests"] + requests.entity_resolvers_registry.register_type(AutoApproveResolver()) + + blueprint.record_once(register_auto_approve_entity_resolver) + + return blueprint diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py new file mode 100644 index 0000000..751fd2c --- /dev/null +++ b/oarepo_workflows/views/app.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +"""App blueprints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from flask import Blueprint, Flask + +if TYPE_CHECKING: + from flask.blueprints import BlueprintSetupState + + +def create_app_blueprint(app: Flask) -> Blueprint: + """Create requests blueprint.""" + blueprint = Blueprint("oarepo-workflows", __name__) + + # noinspection PyUnusedLocal + def register_auto_approve_entity_resolver(state: BlueprintSetupState) -> None: + from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + + requests = app.extensions["invenio-requests"] + requests.entity_resolvers_registry.register_type(AutoApproveResolver()) + + blueprint.record_once(register_auto_approve_entity_resolver) + + return blueprint diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..e0da368 --- /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 ba2c7b3..3c5fd8a 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -1,8 +1,10 @@ #!/bin/bash -PYTHON="${PYTHON:-python3.10}" +PYTHON="${PYTHON:-python3.12}" OAREPO_VERSION="${OAREPO_VERSION:-12}" +echo "Will run tests with python ${PYTHON} and oarepo version ${OAREPO_VERSION}" + set -e install_python_package() { @@ -20,9 +22,6 @@ if test -d $BUILDER_VENV ; then rm -rf $BUILDER_VENV fi curl -L -o forked_install.sh https://github.com/oarepo/nrp-devtools/raw/main/tests/forked_install.sh -if test -d $TESTS_VENV ; then - rm -rf $TESTS_VENV -fi $PYTHON -m venv $BUILDER_VENV . $BUILDER_VENV/bin/activate @@ -35,6 +34,9 @@ if test -d thesis ; then fi oarepo-compile-model ./tests/thesis.yaml --output-directory ./thesis -vvv +if test -d $TESTS_VENV ; then + rm -rf $TESTS_VENV +fi $PYTHON -m venv $TESTS_VENV . $TESTS_VENV/bin/activate pip install -U setuptools pip wheel diff --git a/setup.cfg b/setup.cfg index 35b8f9c..f09c803 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = oarepo-workflows -version = 1.0.11 +version = 1.1.0 description = authors = Ronald Krist readme = README.md @@ -9,7 +9,7 @@ long_description_content_type = text/markdown [options] -python = >=3.9 +python = >=3.12 install_requires = invenio-records-resources invenio-requests @@ -31,3 +31,13 @@ invenio_base.apps = oarepo_workflows = oarepo_workflows.ext:OARepoWorkflows invenio_base.api_apps = oarepo_workflows = oarepo_workflows.ext:OARepoWorkflows +invenio_requests.entity_resolvers = + auto_approve = oarepo_workflows.resolvers.auto_approve:AutoApproveResolver +invenio_base.finalize_app = + oarepo_workflows = oarepo_workflows.ext:finalize_app +invenio_base.api_finalize_app = + oarepo_workflows = oarepo_workflows.ext:finalize_app +invenio_base.api_blueprints = + oarepo_workflows = oarepo_workflows.views.api:create_api_blueprint +invenio_base.blueprints = + oarepo_workflows = oarepo_workflows.views.app:create_app_blueprint diff --git a/setup.py b/setup.py index 6068493..c07b204 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,12 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 module for oarepo_workflows.""" + from setuptools import setup setup() diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..892225a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 33cc8a4..12d5446 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 pytest @@ -9,7 +16,9 @@ from invenio_i18n import lazy_gettext as _ from invenio_records_permissions.generators import Generator from invenio_users_resources.records import UserAggregate -from oarepo_runtime.services.generators import RecordOwners +from invenio_requests.customizations.request_types import RequestType +from invenio_requests.proxies import current_request_type_registry +from oarepo_runtime.services.permissions import RecordOwners from oarepo_workflows.base import Workflow from oarepo_workflows.requests import ( @@ -25,7 +34,6 @@ class RecordOwnersReadTestWorkflowPermissionPolicy(DefaultWorkflowPermissions): class Administration(Generator): - def needs(self, **kwargs): """Enabling Needs.""" return [ActionNeed("administration")] @@ -49,6 +57,10 @@ class MyWorkflowRequests(WorkflowRequestPolicy): ) +class IsApplicableTestRequestPolicy(WorkflowRequestPolicy): + req = WorkflowRequest(requesters=[RecordOwners()], recipients=[]) + + WORKFLOWS = { "my_workflow": Workflow( label=_("Default workflow"), @@ -60,6 +72,11 @@ class MyWorkflowRequests(WorkflowRequestPolicy): permission_policy_cls=RecordOwnersReadTestWorkflowPermissionPolicy, request_policy_cls=MyWorkflowRequests, ), + "is_applicable_workflow": Workflow( + label=_("For testing is_applicable"), + permission_policy_cls=DefaultWorkflowPermissions, + request_policy_cls=IsApplicableTestRequestPolicy, + ), } @@ -128,7 +145,6 @@ def input_data(sample_metadata_list): @pytest.fixture() def users(app, db, UserFixture): - user1 = UserFixture( email="user1@example.org", password="password", @@ -212,3 +228,14 @@ def app_config(app_config): @pytest.fixture() def default_workflow_json(): return {"parent": {"workflow": "my_workflow"}} + + +@pytest.fixture() +def extra_request_types(): + def create_rt(type_id): + return type("Req", (RequestType,), {"type_id": type_id}) + + current_request_type_registry.register_type(create_rt("req"), force=True) + current_request_type_registry.register_type(create_rt("req1"), force=True) + current_request_type_registry.register_type(create_rt("req2"), force=True) + current_request_type_registry.register_type(create_rt("req3"), force=True) diff --git a/tests/test_auto_approve.py b/tests/test_auto_approve.py new file mode 100644 index 0000000..3a3f698 --- /dev/null +++ b/tests/test_auto_approve.py @@ -0,0 +1,39 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflows.requests import AutoApprove +import pytest +from invenio_requests.resolvers.registry import ResolverRegistry + +from oarepo_workflows.resolvers.auto_approve import AutoApproveEntity + + +def test_auto_approve_generator(): + a = AutoApprove() + + with pytest.raises(ValueError): + a.needs() + + with pytest.raises(ValueError): + a.excludes() + + with pytest.raises(ValueError): + a.query_filter() + + assert a.reference_receivers() == [{"auto_approve": "true"}] + + +def test_auto_approve_resolver(app): + resolved = ResolverRegistry.resolve_entity({"auto_approve": "true"}) + assert isinstance(resolved, AutoApproveEntity) + # + # assert resolved.resolve() == AutoApprove() + # assert resolved.pick_resolved_fields(system_identity, {"id": "true"}) == {"auto_approve": "true"} + # assert resolved.get_needs() == [] + + entity_reference = ResolverRegistry.reference_entity(AutoApproveEntity()) + assert entity_reference == {"auto_approve": "true"} diff --git a/tests/test_auto_request.py b/tests/test_auto_request.py new file mode 100644 index 0000000..02036af --- /dev/null +++ b/tests/test_auto_request.py @@ -0,0 +1,13 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflows.requests import AutoRequest +from oarepo_workflows.requests.generators.auto import auto_request_need + + +def test_auto_request_needs(app): + assert AutoRequest().needs() == [auto_request_need] diff --git a/tests/test_if_in_state.py b/tests/test_if_in_state.py new file mode 100644 index 0000000..892225a --- /dev/null +++ b/tests/test_if_in_state.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflow.py b/tests/test_workflow.py index a98d784..63957ae 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -1,8 +1,28 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_workflows.errors import InvalidWorkflowError from thesis.resources.records.config import ThesisResourceConfig -from thesis.thesis.records.api import ThesisDraft, ThesisRecord +from thesis.records.api import ThesisDraft, ThesisRecord + + +def test_create_without_workflow( + users, logged_client, default_workflow_json, search_clear +): + # create draft + user_client1 = logged_client(users[0]) + + create_response = user_client1.post(ThesisResourceConfig.url_prefix, json={}) + assert create_response.status_code == 400 + assert create_response.json["errors"][0]["messages"] == [ + "Workflow not defined in input." + ] def test_workflow_read(users, logged_client, default_workflow_json, search_clear): @@ -103,7 +123,7 @@ def test_invalid_workflow_input(users, logged_client, search_clear): ) assert invalid_wf_response.status_code == 400 assert invalid_wf_response.json["errors"][0]["messages"] == [ - "Workflow rglknjgidlrg does not exist in the configuration." + "Workflow rglknjgidlrg does not exist in the configuration. Used on record dict[{'parent': {'workflow': 'rglknjgidlrg'}}]" ] missing_wf_response = user_client1.post(ThesisResourceConfig.url_prefix, json={}) assert missing_wf_response.status_code == 400 diff --git a/tests/test_workflow_field.py b/tests/test_workflow_field.py index 4c87275..9cf5144 100644 --- a/tests/test_workflow_field.py +++ b/tests/test_workflow_field.py @@ -1,3 +1,10 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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_access.permissions import system_identity diff --git a/tests/test_workflow_permission.py b/tests/test_workflow_permission.py new file mode 100644 index 0000000..1e11322 --- /dev/null +++ b/tests/test_workflow_permission.py @@ -0,0 +1,36 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 types import SimpleNamespace + +from oarepo_workflows import FromRecordWorkflow +from oarepo_workflows.errors import MissingWorkflowError +from thesis.records.api import ThesisRecord +import pytest + +from flask_principal import Identity, UserNeed + + +def test_get_workflow_id(users, logged_client, search_clear, record_service): + thesis = ThesisRecord.create({}) + wp = FromRecordWorkflow("read") + with pytest.raises(MissingWorkflowError): + wp._get_workflow_id(record=thesis) + + fake_thesis = SimpleNamespace(parent=SimpleNamespace(workflow="")) + with pytest.raises(MissingWorkflowError): + assert wp._get_workflow_id(record=fake_thesis) # noqa + + +def test_query_filter(users, logged_client, search_clear, record_service): + wp = FromRecordWorkflow("read") + + id1 = Identity(id=1) + id1.provides.add(UserNeed(1)) + + with pytest.raises(MissingWorkflowError): + assert wp.query_filter(identity=id1) == {} diff --git a/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index 71417ea..0fc056d 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -1,8 +1,23 @@ +# +# Copyright (C) 2024 CESNET z.s.p.o. +# +# oarepo-workflows 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 types import SimpleNamespace + from invenio_records_permissions.generators import Generator -from oarepo_runtime.services.generators import RecordOwners +from oarepo_runtime.services.permissions import RecordOwners, UserWithRole + +from oarepo_workflows import WorkflowRequestPolicy, WorkflowTransitions +from oarepo_workflows.requests import WorkflowRequest +from oarepo_workflows.requests.generators import RecipientGeneratorMixin +from thesis.records.api import ThesisRecord -from oarepo_workflows.requests import RecipientGeneratorMixin, WorkflowRequest -from thesis.thesis.records.api import ThesisRecord +from flask_principal import Identity, UserNeed, RoleNeed +import pytest +from opensearch_dsl.query import Terms class TestRecipient(RecipientGeneratorMixin, Generator): @@ -16,16 +31,168 @@ def reference_receivers(self, record=None, request_type=None, **kwargs): return None +class FailingGenerator(Generator): + def needs(self, **context): + raise ValueError("Failing generator") + + def excludes(self, **context): + raise ValueError("Failing generator") + + def query_filter(self, **context): + raise ValueError("Failing generator") + + +class R(WorkflowRequestPolicy): + req = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[NullRecipient(), TestRecipient()], + transitions=WorkflowTransitions( + submitted="pending", + accepted="accepted", + declined="declined", + ), + ) + req1 = WorkflowRequest( + requesters=[ + UserWithRole("administrator"), + ], + recipients=[NullRecipient(), TestRecipient()], + ) + req2 = WorkflowRequest( + requesters=[], # never applicable, must be created by, for example, system identity + recipients=[], + ) + req3 = WorkflowRequest( + requesters=[FailingGenerator()], + recipients=[NullRecipient(), TestRecipient()], + ) + + def test_workflow_requests(users, logged_client, search_clear, record_service): req = WorkflowRequest( requesters=[RecordOwners()], recipients=[NullRecipient(), TestRecipient()], ) rec = ThesisRecord.create({}) - assert req.reference_receivers(record=rec) == {"user": "1"} + assert req.recipient_entity_reference(record=rec) == {"user": "1"} + + +def test_workflow_requests_no_recipient( + users, logged_client, search_clear, record_service +): + req1 = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[NullRecipient()], + ) + rec = ThesisRecord.create({}) + assert req1.recipient_entity_reference(record=rec) is None + + req2 = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[], + ) + assert req2.recipient_entity_reference(record=rec) is None def test_request_policy_access(app): request_policy = app.config["WORKFLOWS"]["my_workflow"].requests() assert getattr(request_policy, "delete_request", None) assert not getattr(request_policy, "non_existing_request", None) + + +def test_is_applicable( + users, logged_client, search_clear, record_service, extra_request_types +): + req = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[NullRecipient(), TestRecipient()], + ) + req._request_type = "req" + + id1 = Identity(id=1) + id1.provides.add(UserNeed(1)) + + id2 = Identity(id=2) + id2.provides.add(UserNeed(2)) + + record = SimpleNamespace( + parent=SimpleNamespace( + owners=[SimpleNamespace(id=1)], workflow="is_applicable_workflow" + ) + ) + assert req.is_applicable(id2, record=record) is False + assert req.is_applicable(id1, record=record) is True + + +def test_list_applicable_requests( + users, logged_client, search_clear, record_service, extra_request_types +): + requests = R() + + id1 = Identity(id=1) + id1.provides.add(UserNeed(1)) + + id2 = Identity(id=2) + id2.provides.add(UserNeed(2)) + id2.provides.add(RoleNeed("administrator")) + + record = SimpleNamespace( + parent=SimpleNamespace( + owners=[SimpleNamespace(id=1)], workflow="is_applicable_workflow" + ) + ) + + assert set( + x[0] for x in requests.applicable_workflow_requests(id1, record=record) + ) == {"req"} + + assert ( + set(x[0] for x in requests.applicable_workflow_requests(id2, record=record)) + == set() + ) + + +def test_get_workflow_request_via_index( + users, logged_client, search_clear, record_service, extra_request_types +): + requests = R() + assert requests["req"] == requests.req + assert requests["req1"] == requests.req1 + with pytest.raises(KeyError): + requests["non_existing_request"] # noqa + + +def test_get_request_type( + users, logged_client, search_clear, record_service, extra_request_types +): + requests = R() + for rt_code, wr in requests.items(): + assert wr.request_type.type_id == rt_code + + +def test_transition_getter( + users, logged_client, search_clear, record_service, extra_request_types +): + requests = R() + assert requests.req.transitions["submitted"] == "pending" + assert requests.req.transitions["accepted"] == "accepted" + assert requests.req.transitions["declined"] == "declined" + with pytest.raises(KeyError): + requests.req.transitions["non_existing_transition"] # noqa + + +def test_requestor_filter( + users, logged_client, search_clear, record_service, extra_request_types +): + requests = R() + sample_record = SimpleNamespace( + parent=SimpleNamespace(owners=[SimpleNamespace(id=1)]) + ) + + id1 = Identity(id=1) + id1.provides.add(UserNeed(1)) + + generator = requests.req.requester_generator + assert generator.query_filter(identity=id1, record=sample_record) == [ + Terms(parent__owners__user=[1]) + ] diff --git a/tests/utils.py b/tests/utils.py index c3424ad..998a717 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,8 @@ -def test_state_change_notifier(*args, **kwargs): +def test_state_change_notifier(*args, **_kwargs): record = args[1] record["state-change-notifier-called"] = True -def test_workflow_change_notifier(*args, **kwargs): +def test_workflow_change_notifier(*args, **_kwargs): record = args[1] record.parent["workflow-change-notifier-called"] = True