From bf5f7d73e76713321824abfb5636f2e3c41d364c Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 14:22:09 +0100 Subject: [PATCH 01/21] Moved AutoApprove to workflows --- oarepo_workflows/errors.py | 14 +- oarepo_workflows/ext.py | 19 ++ oarepo_workflows/requests/__init__.py | 8 + oarepo_workflows/requests/policy.py | 224 ++++++++++++++---- oarepo_workflows/resolvers/__init__.py | 0 .../resolvers/auto_approve/__init__.py | 42 ++++ .../services/auto_approve/__init__.py | 11 + .../services/components/workflow.py | 1 - oarepo_workflows/views/__init__.py | 0 oarepo_workflows/views/api.py | 14 ++ oarepo_workflows/views/app.py | 14 ++ setup.cfg | 10 + tests/test_workflow_requests.py | 51 +++- 13 files changed, 356 insertions(+), 52 deletions(-) create mode 100644 oarepo_workflows/resolvers/__init__.py create mode 100644 oarepo_workflows/resolvers/auto_approve/__init__.py create mode 100644 oarepo_workflows/services/auto_approve/__init__.py create mode 100644 oarepo_workflows/views/__init__.py create mode 100644 oarepo_workflows/views/api.py create mode 100644 oarepo_workflows/views/app.py diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index dc8d46a..9a26ab6 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -2,8 +2,18 @@ class MissingWorkflowError(ValidationError): - """""" + """ + Exception raised when a required workflow is missing. + Attributes: + message -- explanation of the error + """ + class InvalidWorkflowError(ValidationError): - """""" + """ + Exception raised when a workflow is invalid. + Attributes: + message -- explanation of the error + """ + diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 36ed96d..dcbe4b3 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -7,6 +7,8 @@ from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows +from oarepo_workflows.services.auto_approve import AutoApproveEntityService, \ + AutoApproveEntityServiceConfig class OARepoWorkflows(object): @@ -15,6 +17,7 @@ def __init__(self, app=None): if app: self.init_config(app) self.init_app(app) + self.init_services(app) def init_config(self, app): """Initialize configuration.""" @@ -31,6 +34,11 @@ def init_config(self, app): app.config.setdefault("WORKFLOWS", ext_config.WORKFLOWS) + def init_services(self, app): + self.autoapprove_service = AutoApproveEntityService( + config=AutoApproveEntityServiceConfig() + ) + @cached_property def state_changed_notifiers(self): group_name = "oarepo_workflows.state_changed_notifiers" @@ -124,3 +132,14 @@ def init_app(self, app): """Flask application initialization.""" self.app = app app.extensions["oarepo-workflows"] = self + +def finalize_app(app): + records_resources = app.extensions["invenio-records-resources"] + + ext = app.extensions["oarepo-workflows"] + + records_resources.registry.register( + ext.autoapprove_service, + service_id=ext.autoapprove_service.config.service_id, + ) + diff --git a/oarepo_workflows/requests/__init__.py b/oarepo_workflows/requests/__init__.py index 3cb96d2..3f48891 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -4,6 +4,10 @@ WorkflowRequestEscalation, WorkflowRequestPolicy, WorkflowTransitions, + AutoApprove, + AutoRequest, + RequesterGenerator, + RecipientEntityReference ) __all__ = ( @@ -12,4 +16,8 @@ "WorkflowTransitions", "RecipientGeneratorMixin", "WorkflowRequestEscalation", + "AutoApprove", + "AutoRequest", + "RequesterGenerator", + "RecipientEntityReference" ) diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index c2495c4..a9cc68b 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -1,56 +1,85 @@ +from __future__ import annotations + import dataclasses -import inspect +from functools import cached_property from datetime import timedelta +from logging import getLogger from typing import Dict, List, Optional, Tuple +from flask_principal import Need, Permission, Identity 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 +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: {}) - transitions: Optional["WorkflowTransitions"] = dataclasses.field( + """Events that can be submitted with the request.""" + + 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 + """Transitions applied to the state of the topic of the request.""" - def needs(self, **kwargs): - return { - need for generator in self.requesters for need in generator.needs(**kwargs) - } + escalations: Optional[List[WorkflowRequestEscalation]] = None + """Escalations applied to the request if not approved/declined in time.""" - def excludes(self, **kwargs): - return { - exclude - for generator in self.requesters - for exclude in generator.excludes(**kwargs) - } + @cached_property + def requester_generator(self) -> Generator: + """ + Return the requesters as a single requester generator. + """ + return RequesterGenerator(self.requesters) - def query_filters(self, **kwargs): - return [ - query_filter - for generator in self.requesters - for query_filter in generator.query_filter(**kwargs) - ] + def recipient_entity_reference(self, **context) -> 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, **context) -> 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: + p = Permission(*self.requester_generator.needs(**context)) + if not p.needs: + return False + p.excludes.update(self.requester_generator.excludes(**context)) + return p.allows(identity) + except Exception as e: + log.exception("Error checking request applicability: %s", e) + return False @property - def allowed_events(self): + def allowed_events(self) -> Dict[str, WorkflowEvent]: + """ + Return the allowed events for the workflow request. + """ return current_oarepo_workflows.default_workflow_event_submitters | self.events @@ -119,27 +148,30 @@ def __getitem__(self, item): f"Request type {item} not defined in {self.__class__.__name__}" ) + @cached_property def items(self): - return inspect.getmembers(self, lambda x: isinstance(x, WorkflowRequest)) - - -class RecipientGeneratorMixin: - """ - Mixin for permission generators that can be used as recipients in WorkflowRequest. - """ - - def reference_receivers(self, record=None, request_type=None, **kwargs): + 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, **context): """ - 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. + Return a list of applicable requests for the identity and context. """ - raise NotImplementedError("Implement reference receiver in your code") + ret = [] + + for name, request in self.items: + if request.is_applicable(identity, **context): + ret.append((name, request)) + return ret auto_request_need = SystemRoleNeed("auto_request") @@ -157,6 +189,24 @@ def needs(self, **kwargs): return [auto_request_need] +class RecipientGeneratorMixin: + """ + Mixin for permission generators that can be used as recipients in WorkflowRequest. + """ + + def reference_receivers(self, record=None, request_type=None, **kwargs) -> List[Dict]: + """ + Taken the context (will include record amd request type at least), + return the reference receiver(s) of the request. + + Should return a list of dictionary serialization of the receiver classes. + + Might return empty list or None to indicate that the generator does not + provide any receivers. + """ + raise NotImplementedError("Implement reference receiver in your code") + + class AutoApprove(RecipientGeneratorMixin, Generator): """ Auto approve generator. If the generator is used within recipients of a request, @@ -165,3 +215,81 @@ class AutoApprove(RecipientGeneratorMixin, Generator): def reference_receivers(self, record=None, request_type=None, **kwargs): return [{"auto_approve": "true"}] + + +class RequesterGenerator(Generator): + def __init__(self, requesters) -> None: + super().__init__() + self.requesters = requesters + + def needs(self, **context) -> set[Need]: + """ + Generate a set of needs that requester needs to have in order to create the request. + + :param context: Context of the request. + :return: Set of needs. + """ + + return { + need for generator in self.requesters for need in generator.needs(**context) + } + + def excludes(self, **context) -> set[Need]: + """ + Generate a set of needs that requester must not have in order to create the request. + + :param context: Context of the request. + :return: Set of needs. + """ + return { + exclude + for generator in self.requesters + for exclude in generator.excludes(**context) + } + + def query_filters(self, **context) -> list[dict]: + """ + Generate a list of opensearch query filters to get the listing of matching requests. + + :param context: Context of the request. + """ + + return [ + query_filter + for generator in self.requesters + for query_filter in generator.query_filter(**context) + ] + + +def RecipientEntityReference( + request: WorkflowRequest, **context +) -> 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..e69de29 diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py new file mode 100644 index 0000000..2df88e8 --- /dev/null +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -0,0 +1,42 @@ +from invenio_records_resources.references.entity_resolvers import EntityProxy +from invenio_records_resources.references.entity_resolvers.base import EntityResolver +from flask_principal import Need + + +class AutoApprover: + def __init__(self, value: bool): + self.value = value + + +class AutoApproveProxy(EntityProxy): + def _resolve(self) -> AutoApprover: + value = self._parse_ref_dict_id() + return AutoApprover(value) + + def get_needs(self, ctx=None) -> list[Need]: + return [] # granttokens calls this + + def pick_resolved_fields(self, identity, resolved_dict) -> dict: + return {"auto_approve": resolved_dict["id"]} + + +class AutoApproveResolver(EntityResolver): + type_id = "auto_approve" + + def __init__(self): + self.type_key = self.type_id + super().__init__( + "auto_approve", + ) + + def matches_reference_dict(self, ref_dict): + return self._parse_ref_dict_type(ref_dict) == self.type_id + + def _reference_entity(self, entity): + return {self.type_key: str(entity.value)} + + def matches_entity(self, entity): + return isinstance(entity, AutoApprover) + + def _get_entity_proxy(self, ref_dict): + return AutoApproveProxy(self, ref_dict) diff --git a/oarepo_workflows/services/auto_approve/__init__.py b/oarepo_workflows/services/auto_approve/__init__.py new file mode 100644 index 0000000..43a43ad --- /dev/null +++ b/oarepo_workflows/services/auto_approve/__init__.py @@ -0,0 +1,11 @@ +from oarepo_runtime.services.entity.config import KeywordEntityServiceConfig +from oarepo_runtime.services.entity.service import KeywordEntityService + + +class AutoApproveEntityServiceConfig(KeywordEntityServiceConfig): + service_id = "auto_approve" + keyword = "auto_approve" + + +class AutoApproveEntityService(KeywordEntityService): + pass diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 86c7213..70d7fb1 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -5,7 +5,6 @@ class WorkflowComponent(ServiceComponent): - def create(self, identity, data=None, record=None, **kwargs): try: workflow_id = data["parent"]["workflow"] diff --git a/oarepo_workflows/views/__init__.py b/oarepo_workflows/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py new file mode 100644 index 0000000..21aab4b --- /dev/null +++ b/oarepo_workflows/views/api.py @@ -0,0 +1,14 @@ +from flask import Blueprint + +def create_api_blueprint(app): + """Create requests blueprint.""" + blueprint = Blueprint("oarepo-workflows", __name__) + + def register_autoapprove_entity_resolver(state): + from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + requests = app.extensions["invenio-requests"] + requests.entity_resolvers_registry.register_type(AutoApproveResolver()) + + blueprint.record_once(register_autoapprove_entity_resolver) + + return blueprint diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py new file mode 100644 index 0000000..9ab1b08 --- /dev/null +++ b/oarepo_workflows/views/app.py @@ -0,0 +1,14 @@ +from flask import Blueprint + +def create_app_blueprint(app): + """Create requests blueprint.""" + blueprint = Blueprint("oarepo-workflows", __name__) + + def register_autoapprove_entity_resolver(state): + from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + requests = app.extensions["invenio-requests"] + requests.entity_resolvers_registry.register_type(AutoApproveResolver()) + + blueprint.record_once(register_autoapprove_entity_resolver) + + return blueprint diff --git a/setup.cfg b/setup.cfg index 35b8f9c..84900c5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index 71417ea..b12e975 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -1,9 +1,15 @@ +from types import SimpleNamespace + from invenio_records_permissions.generators import Generator from oarepo_runtime.services.generators import RecordOwners +from oarepo_workflows import WorkflowRequestPolicy from oarepo_workflows.requests import RecipientGeneratorMixin, WorkflowRequest from thesis.thesis.records.api import ThesisRecord +from flask_principal import Identity, UserNeed, RoleNeed +from oarepo_runtime.services.permissions.generators import UserWithRole + class TestRecipient(RecipientGeneratorMixin, Generator): def reference_receivers(self, record=None, request_type=None, **kwargs): @@ -22,10 +28,53 @@ def test_workflow_requests(users, logged_client, search_clear, record_service): 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_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): + req = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[NullRecipient(), TestRecipient()], + ) + 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)])) + 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): + class R(WorkflowRequestPolicy): + req = WorkflowRequest( + requesters=[RecordOwners()], + recipients=[NullRecipient(), TestRecipient()], + ) + req1 = WorkflowRequest( + requesters=[ + UserWithRole("administrator"), + ], + recipients=[NullRecipient(), TestRecipient()], + ) + + 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)])) + + 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)) == {'req1'} From abb70559f2ae27a8fe8bf2a220bf75298f6bffd0 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 15:20:33 +0100 Subject: [PATCH 02/21] Typing 1st round --- oarepo_workflows/base.py | 2 +- oarepo_workflows/ext.py | 12 ++++++------ oarepo_workflows/records/systemfields/state.py | 8 ++++---- oarepo_workflows/records/systemfields/workflow.py | 2 +- oarepo_workflows/resolvers/auto_approve/__init__.py | 4 ++-- oarepo_workflows/services/permissions/generators.py | 6 +++--- oarepo_workflows/services/permissions/policy.py | 2 +- oarepo_workflows/views/api.py | 2 +- oarepo_workflows/views/app.py | 2 +- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/oarepo_workflows/base.py b/oarepo_workflows/base.py index ad38159..0026f39 100644 --- a/oarepo_workflows/base.py +++ b/oarepo_workflows/base.py @@ -22,7 +22,7 @@ def requests(self): """Return instance of request policy for this workflow.""" return self.request_policy_cls() - def __post_init__(self): + def __post_init__(self) -> None: assert not issubclass(self.permission_policy_cls, WorkflowPermissionPolicy) assert issubclass(self.permission_policy_cls, DefaultWorkflowPermissions) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index dcbe4b3..1ce5a8a 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -13,13 +13,13 @@ class OARepoWorkflows(object): - def __init__(self, app=None): + def __init__(self, app=None) -> None: if app: self.init_config(app) self.init_app(app) self.init_services(app) - def init_config(self, app): + def init_config(self, app) -> None: """Initialize configuration.""" from . import ext_config @@ -34,7 +34,7 @@ def init_config(self, app): app.config.setdefault("WORKFLOWS", ext_config.WORKFLOWS) - def init_services(self, app): + def init_services(self, app) -> None: self.autoapprove_service = AutoApproveEntityService( config=AutoApproveEntityServiceConfig() ) @@ -55,7 +55,7 @@ def workflow_changed_notifiers(self): def set_state( self, identity, record, value, *args, uow=None, commit=True, **kwargs - ): + ) -> None: previous_value = record.state record.state = value if commit: @@ -128,12 +128,12 @@ def get_workflow(self, record): f"Workflow {record.parent.workflow} on record {self._get_id_from_record(record)} doesn't exist." ) - def init_app(self, app): + def init_app(self, app) -> None: """Flask application initialization.""" self.app = app app.extensions["oarepo-workflows"] = self -def finalize_app(app): +def finalize_app(app) -> None: records_resources = app.extensions["invenio-records-resources"] ext = app.extensions["oarepo-workflows"] diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index 13499aa..b4fe485 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -3,15 +3,15 @@ class RecordStateField(MappingSystemFieldMixin, SystemField): - def __init__(self, key="state", initial="draft", config=None): + def __init__(self, key="state", initial="draft", config=None) -> None: self._config = config self._initial = initial super().__init__(key=key) - def post_create(self, record): + def post_create(self, record) -> None: self.set_dictkey(record, self._initial) - def post_init(self, record, data, model=None, **kwargs): + def post_init(self, record, data, model=None, **kwargs) -> None: if not record.state: self.set_dictkey(record, self._initial) @@ -21,7 +21,7 @@ def __get__(self, record, owner=None): return self return self.get_dictkey(record) - def __set__(self, record, value): + def __set__(self, record, value) -> None: self.set_dictkey(record, value) @property diff --git a/oarepo_workflows/records/systemfields/workflow.py b/oarepo_workflows/records/systemfields/workflow.py index 4750d2d..0a9b761 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.py @@ -4,7 +4,7 @@ class WorkflowField(MappingSystemFieldMixin, ModelField): - def __init__(self): + def __init__(self) -> None: self._workflow = None # added in db super().__init__(model_field_name="workflow", key="workflow") diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py index 2df88e8..45fd471 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -4,7 +4,7 @@ class AutoApprover: - def __init__(self, value: bool): + def __init__(self, value: bool) -> None: self.value = value @@ -23,7 +23,7 @@ def pick_resolved_fields(self, identity, resolved_dict) -> dict: class AutoApproveResolver(EntityResolver): type_id = "auto_approve" - def __init__(self): + def __init__(self) -> None: self.type_key = self.type_id super().__init__( "auto_approve", diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 12366eb..c0fce77 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -19,7 +19,7 @@ def _make_query(generators, **kwargs): class WorkflowPermission(Generator): - def __init__(self, action=None): + def __init__(self, action=None) -> None: # might not be needed in subclasses super().__init__() self._action = action @@ -54,7 +54,7 @@ def query_filter(self, record=None, **kwargs): class IfInState(ConditionalGenerator): - def __init__(self, state, then_): + def __init__(self, state, then_) -> None: super().__init__(then_, else_=[]) self.state = state @@ -76,7 +76,7 @@ def query_filter(self, **kwargs): class SameAs(Generator): - def __init__(self, as_): + def __init__(self, as_) -> None: self.as_ = as_ def _generators(self, policy, **kwargs): diff --git a/oarepo_workflows/services/permissions/policy.py b/oarepo_workflows/services/permissions/policy.py index 500cb98..8e51f77 100644 --- a/oarepo_workflows/services/permissions/policy.py +++ b/oarepo_workflows/services/permissions/policy.py @@ -37,7 +37,7 @@ class MyWorkflowPermissions(DefaultWorkflowPermissions): system_process = SystemProcess() - def __init__(self, action_name=None, **over): + def __init__(self, action_name=None, **over) -> None: can = getattr(self, f"can_{action_name}") if self.system_process not in can: can.append(self.system_process) diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index 21aab4b..08a7625 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -4,7 +4,7 @@ def create_api_blueprint(app): """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state): + def register_autoapprove_entity_resolver(state) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver requests = app.extensions["invenio-requests"] requests.entity_resolvers_registry.register_type(AutoApproveResolver()) diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index 9ab1b08..9f0f387 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -4,7 +4,7 @@ def create_app_blueprint(app): """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state): + def register_autoapprove_entity_resolver(state) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver requests = app.extensions["invenio-requests"] requests.entity_resolvers_registry.register_type(AutoApproveResolver()) From f60cca71c515495358202573ddb4c2e169c9d0e2 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 15:21:38 +0100 Subject: [PATCH 03/21] Typing 2nd round --- oarepo_workflows/ext.py | 4 ++-- oarepo_workflows/records/systemfields/state.py | 2 +- oarepo_workflows/services/permissions/generators.py | 3 ++- oarepo_workflows/services/permissions/policy.py | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 1ce5a8a..310ef4d 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -54,7 +54,7 @@ def workflow_changed_notifiers(self): ] def set_state( - self, identity, record, value, *args, uow=None, commit=True, **kwargs + self, identity, record, value, *args, uow=None, commit: bool=True, **kwargs ) -> None: previous_value = record.state record.state = value @@ -67,7 +67,7 @@ def set_state( ) def set_workflow( - self, identity, record, new_workflow_id, *args, uow=None, commit=True, **kwargs + self, identity, record, new_workflow_id, *args, uow=None, commit: bool=True, **kwargs ): if new_workflow_id not in current_oarepo_workflows.record_workflows: raise InvalidWorkflowError( diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index b4fe485..3cb9b86 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -3,7 +3,7 @@ class RecordStateField(MappingSystemFieldMixin, SystemField): - def __init__(self, key="state", initial="draft", config=None) -> None: + def __init__(self, key: str="state", initial: str="draft", config=None) -> None: self._config = config self._initial = initial super().__init__(key=key) diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index c0fce77..c8135da 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -9,6 +9,7 @@ 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 +from typing import Optional # invenio_records_permissions.generators.ConditionalGenerator._make_query @@ -35,7 +36,7 @@ def _get_workflow_id(self, record=None, **kwargs): raise MissingWorkflowError("Workflow not defined in input.") return workflow_id - def _get_permissions_from_workflow(self, record=None, action_name=None, **kwargs): + def _get_permissions_from_workflow(self, record=None, action_name: Optional[str]=None, **kwargs): workflow_id = self._get_workflow_id(record, **kwargs) if workflow_id not in current_oarepo_workflows.record_workflows: raise InvalidWorkflowError( diff --git a/oarepo_workflows/services/permissions/policy.py b/oarepo_workflows/services/permissions/policy.py index 8e51f77..06d41c3 100644 --- a/oarepo_workflows/services/permissions/policy.py +++ b/oarepo_workflows/services/permissions/policy.py @@ -12,6 +12,7 @@ from ...proxies import current_oarepo_workflows from .generators import IfInState, SameAs, WorkflowPermission +from typing import Optional class DefaultWorkflowPermissions(RecordPermissionPolicy): @@ -37,7 +38,7 @@ class MyWorkflowPermissions(DefaultWorkflowPermissions): system_process = SystemProcess() - def __init__(self, action_name=None, **over) -> None: + def __init__(self, action_name: Optional[str]=None, **over) -> None: can = getattr(self, f"can_{action_name}") if self.system_process not in can: can.append(self.system_process) From d803f3a78672bdfba5bb1b9547242c4e9987f7fc Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 15:29:26 +0100 Subject: [PATCH 04/21] Typing 3rd round --- oarepo_workflows/ext.py | 8 ++++---- oarepo_workflows/records/systemfields/state.py | 6 ++++-- oarepo_workflows/records/systemfields/workflow.py | 2 +- oarepo_workflows/requests/events.py | 7 ++++--- oarepo_workflows/requests/policy.py | 8 ++++---- .../resolvers/auto_approve/__init__.py | 8 ++++---- oarepo_workflows/services/components/workflow.py | 2 +- .../services/permissions/generators.py | 15 ++++++++------- oarepo_workflows/services/permissions/policy.py | 2 +- oarepo_workflows/views/api.py | 3 ++- oarepo_workflows/views/app.py | 3 ++- 11 files changed, 35 insertions(+), 29 deletions(-) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 310ef4d..4e9d363 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -40,14 +40,14 @@ def init_services(self, app) -> None: ) @cached_property - def state_changed_notifiers(self): + def state_changed_notifiers(self) -> list: 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: group_name = "oarepo_workflows.workflow_changed_notifiers" return [ x.load() for x in importlib_metadata.entry_points().select(group=group_name) @@ -68,7 +68,7 @@ def set_state( def set_workflow( self, identity, record, new_workflow_id, *args, uow=None, commit: bool=True, **kwargs - ): + ) -> None: if new_workflow_id not in current_oarepo_workflows.record_workflows: raise InvalidWorkflowError( f"Workflow {new_workflow_id} does not exist in the configuration." @@ -106,7 +106,7 @@ def record_workflows(self): return self.app.config["WORKFLOWS"] @property - def default_workflow_event_submitters(self): + def default_workflow_event_submitters(self) -> dict: if "DEFAULT_WORKFLOW_EVENT_SUBMITTERS" in self.app.config: return self.app.config["DEFAULT_WORKFLOW_EVENT_SUBMITTERS"] else: diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index 3cb9b86..edbd6f3 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -1,6 +1,8 @@ +from __future__ import annotations from invenio_records.systemfields.base import SystemField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin +from typing import Self class RecordStateField(MappingSystemFieldMixin, SystemField): def __init__(self, key: str="state", initial: str="draft", config=None) -> None: @@ -15,7 +17,7 @@ def post_init(self, record, data, model=None, **kwargs) -> None: if not record.state: self.set_dictkey(record, self._initial) - def __get__(self, record, owner=None): + def __get__(self, record, owner=None) -> str | Self: """Get the persistent identifier.""" if record is None: return self @@ -25,5 +27,5 @@ def __set__(self, record, value) -> None: self.set_dictkey(record, value) @property - def mapping(self): + def mapping(self) -> dict[str, dict[str, str]]: return {self.attr_name: {"type": "keyword"}} diff --git a/oarepo_workflows/records/systemfields/workflow.py b/oarepo_workflows/records/systemfields/workflow.py index 0a9b761..19caea1 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.py @@ -9,5 +9,5 @@ def __init__(self) -> None: super().__init__(model_field_name="workflow", key="workflow") @property - def mapping(self): + def mapping(self) -> dict[str, dict[str, str]]: return {self.attr_name: {"type": "keyword"}} diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index b558dae..c224294 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -1,6 +1,7 @@ import dataclasses from typing import List, Tuple +from flask_principal import Need from invenio_records_permissions.generators import Generator @@ -8,19 +9,19 @@ class WorkflowEvent: submitters: List[Generator] | Tuple[Generator] - def needs(self, **kwargs): + def needs(self, **kwargs) -> set[Need]: return { need for generator in self.submitters for need in generator.needs(**kwargs) } - def excludes(self, **kwargs): + def excludes(self, **kwargs) -> set[Need]: return { exclude for generator in self.submitters for exclude in generator.excludes(**kwargs) } - def query_filters(self, **kwargs): + def query_filters(self, **kwargs) -> list[dict]: return [ query_filter for generator in self.submitters diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index a9cc68b..ceba3fb 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -149,7 +149,7 @@ def __getitem__(self, item): ) @cached_property - def items(self): + def items(self) -> list[tuple[str, WorkflowRequest]]: ret = [] parent_attrs = set(dir(WorkflowRequestPolicy)) for attr in dir(self.__class__): @@ -162,7 +162,7 @@ def items(self): ret.append((attr, possible_request)) return ret - def applicable_workflow_requests(self, identity, **context): + def applicable_workflow_requests(self, identity, **context) -> list[tuple[str, WorkflowRequest]]: """ Return a list of applicable requests for the identity and context. """ @@ -184,7 +184,7 @@ class AutoRequest(Generator): when a record is moved to a specific state. """ - def needs(self, **kwargs): + def needs(self, **kwargs) -> list[Need]: """Enabling Needs.""" return [auto_request_need] @@ -213,7 +213,7 @@ class AutoApprove(RecipientGeneratorMixin, Generator): the request will be automatically approved when the request is submitted. """ - def reference_receivers(self, record=None, request_type=None, **kwargs): + def reference_receivers(self, record=None, request_type=None, **kwargs) -> list[dict[str, str]]: return [{"auto_approve": "true"}] diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py index 45fd471..5d09006 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -29,14 +29,14 @@ def __init__(self) -> None: "auto_approve", ) - def matches_reference_dict(self, ref_dict): + def matches_reference_dict(self, ref_dict) -> bool: return self._parse_ref_dict_type(ref_dict) == self.type_id - def _reference_entity(self, entity): + def _reference_entity(self, entity) -> dict[str, str]: return {self.type_key: str(entity.value)} - def matches_entity(self, entity): + def matches_entity(self, entity) -> bool: return isinstance(entity, AutoApprover) - def _get_entity_proxy(self, ref_dict): + def _get_entity_proxy(self, ref_dict) -> AutoApproveProxy: return AutoApproveProxy(self, ref_dict) diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 70d7fb1..5fc9fdd 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -5,7 +5,7 @@ class WorkflowComponent(ServiceComponent): - def create(self, identity, data=None, record=None, **kwargs): + def create(self, identity, data=None, record=None, **kwargs) -> None: try: workflow_id = data["parent"]["workflow"] except KeyError: diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index c8135da..c9c159b 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -10,10 +10,11 @@ from oarepo_workflows.requests.policy import RecipientGeneratorMixin from oarepo_workflows.services.permissions.identity import auto_request_need from typing import Optional +from flask import Need # invenio_records_permissions.generators.ConditionalGenerator._make_query -def _make_query(generators, **kwargs): +def _make_query(generators, **kwargs) -> dict: queries = [g.query_filter(**kwargs) for g in generators] queries = [q for q in queries if q] return reduce(operator.or_, queries) if queries else None @@ -59,7 +60,7 @@ def __init__(self, state, then_) -> None: super().__init__(then_, else_=[]) self.state = state - def _condition(self, record, **kwargs): + def _condition(self, record, **kwargs) -> bool: try: state = record.state except AttributeError: @@ -83,14 +84,14 @@ def __init__(self, as_) -> None: def _generators(self, policy, **kwargs): return getattr(policy, self.as_) - def needs(self, policy, **kwargs): + def needs(self, policy, **kwargs) -> set[Need]: needs = [ generator.needs(**kwargs) for generator in self._generators(policy, **kwargs) ] return set(chain.from_iterable(needs)) - def excludes(self, policy, **kwargs): + def excludes(self, policy, **kwargs) -> set[Need]: """Preventing Needs.""" excludes = [ generator.excludes(**kwargs) @@ -98,7 +99,7 @@ def excludes(self, policy, **kwargs): ] return set(chain.from_iterable(excludes)) - def query_filter(self, policy, **kwargs): + def query_filter(self, policy, **kwargs) -> dict: """Search filters.""" return _make_query(self._generators(policy, **kwargs), **kwargs) @@ -109,7 +110,7 @@ class AutoRequest(Generator): when a record is moved to a specific state. """ - def needs(self, **kwargs): + def needs(self, **kwargs) -> list[Need]: """Enabling Needs.""" return [auto_request_need] @@ -120,5 +121,5 @@ class AutoApprove(RecipientGeneratorMixin, Generator): the request will be automatically approved when the request is submitted. """ - def reference_receivers(self, record=None, request_type=None, **kwargs): + def reference_receivers(self, record=None, request_type=None, **kwargs) -> list[dict[str, str]]: return [{"auto_approve": "true"}] diff --git a/oarepo_workflows/services/permissions/policy.py b/oarepo_workflows/services/permissions/policy.py index 06d41c3..1cb75b4 100644 --- a/oarepo_workflows/services/permissions/policy.py +++ b/oarepo_workflows/services/permissions/policy.py @@ -102,7 +102,7 @@ class WorkflowPermissionPolicy(RecordPermissionPolicy): can_search_versions = [SystemProcess(), AnyUser()] @property - def query_filters(self): + def query_filters(self) -> list[dict]: if not (self.action == "read" or self.action == "read_draft"): return super().query_filters workflows = current_oarepo_workflows.record_workflows diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index 08a7625..ffc2b7d 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -1,6 +1,7 @@ from flask import Blueprint -def create_api_blueprint(app): +from flask.blueprints import Blueprint +def create_api_blueprint(app) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index 9f0f387..82e58ef 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -1,6 +1,7 @@ from flask import Blueprint -def create_app_blueprint(app): +from flask.blueprints import Blueprint +def create_app_blueprint(app) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) From 85c9c4ce7f5aac1ae8ab6179aa92d8868695fb8a Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 15:30:25 +0100 Subject: [PATCH 05/21] isort --- oarepo_workflows/__init__.py | 23 ++++++++----------- oarepo_workflows/base.py | 2 ++ oarepo_workflows/errors.py | 2 ++ oarepo_workflows/ext.py | 6 +++-- oarepo_workflows/ext_config.py | 5 +++- oarepo_workflows/proxies.py | 2 ++ oarepo_workflows/records/models.py | 2 ++ .../records/systemfields/state.py | 4 +++- .../records/systemfields/workflow.py | 2 ++ oarepo_workflows/requests/__init__.py | 17 +++++--------- oarepo_workflows/requests/events.py | 2 ++ oarepo_workflows/requests/policy.py | 4 ++-- .../resolvers/auto_approve/__init__.py | 7 ++++-- .../services/auto_approve/__init__.py | 2 ++ .../services/components/workflow.py | 5 +++- .../services/permissions/__init__.py | 2 ++ .../services/permissions/generators.py | 9 +++++--- .../services/permissions/identity.py | 2 ++ .../services/permissions/policy.py | 12 ++++------ oarepo_workflows/services/records/schema.py | 2 ++ oarepo_workflows/views/api.py | 5 +++- oarepo_workflows/views/app.py | 5 +++- 22 files changed, 76 insertions(+), 46 deletions(-) diff --git a/oarepo_workflows/__init__.py b/oarepo_workflows/__init__.py index 5d973e1..2b95ad1 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -1,19 +1,14 @@ -from oarepo_workflows.services.permissions import ( - AutoApprove, - AutoRequest, - DefaultWorkflowPermissions, - IfInState, - WorkflowPermission, - WorkflowPermissionPolicy, -) +from __future__ import annotations + +from oarepo_workflows.services.permissions import (AutoApprove, AutoRequest, + DefaultWorkflowPermissions, + IfInState, + WorkflowPermission, + WorkflowPermissionPolicy) from .base import Workflow -from .requests import ( - WorkflowRequest, - WorkflowRequestEscalation, - WorkflowRequestPolicy, - WorkflowTransitions, -) +from .requests import (WorkflowRequest, WorkflowRequestEscalation, + WorkflowRequestPolicy, WorkflowTransitions) __all__ = ( "IfInState", diff --git a/oarepo_workflows/base.py b/oarepo_workflows/base.py index 0026f39..ab9ca7b 100644 --- a/oarepo_workflows/base.py +++ b/oarepo_workflows/base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import dataclasses from typing import Type diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index 9a26ab6..d489c2b 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from marshmallow import ValidationError diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 4e9d363..885e6e0 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cached_property import importlib_metadata @@ -7,8 +9,8 @@ from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows -from oarepo_workflows.services.auto_approve import AutoApproveEntityService, \ - AutoApproveEntityServiceConfig +from oarepo_workflows.services.auto_approve import ( + AutoApproveEntityService, AutoApproveEntityServiceConfig) class OARepoWorkflows(object): diff --git a/oarepo_workflows/ext_config.py b/oarepo_workflows/ext_config.py index 5f70579..111a928 100644 --- a/oarepo_workflows/ext_config.py +++ b/oarepo_workflows/ext_config.py @@ -1,4 +1,7 @@ -from oarepo_workflows.services.permissions.policy import WorkflowPermissionPolicy +from __future__ import annotations + +from oarepo_workflows.services.permissions.policy import \ + WorkflowPermissionPolicy OAREPO_PERMISSIONS_PRESETS = { "workflow": WorkflowPermissionPolicy, diff --git a/oarepo_workflows/proxies.py b/oarepo_workflows/proxies.py index 323e5f3..15c86ec 100644 --- a/oarepo_workflows/proxies.py +++ b/oarepo_workflows/proxies.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from flask import current_app from werkzeug.local import LocalProxy diff --git a/oarepo_workflows/records/models.py b/oarepo_workflows/records/models.py index b71b183..e7da3ec 100644 --- a/oarepo_workflows/records/models.py +++ b/oarepo_workflows/records/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from invenio_db import db from sqlalchemy import String diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index edbd6f3..06bd03c 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -1,8 +1,10 @@ from __future__ import annotations + +from typing import Self + from invenio_records.systemfields.base import SystemField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin -from typing import Self class RecordStateField(MappingSystemFieldMixin, SystemField): def __init__(self, key: str="state", initial: str="draft", config=None) -> None: diff --git a/oarepo_workflows/records/systemfields/workflow.py b/oarepo_workflows/records/systemfields/workflow.py index 19caea1..43a1d2b 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from invenio_records.systemfields.model import ModelField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin diff --git a/oarepo_workflows/requests/__init__.py b/oarepo_workflows/requests/__init__.py index 3f48891..d638c5c 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -1,14 +1,9 @@ -from .policy import ( - RecipientGeneratorMixin, - WorkflowRequest, - WorkflowRequestEscalation, - WorkflowRequestPolicy, - WorkflowTransitions, - AutoApprove, - AutoRequest, - RequesterGenerator, - RecipientEntityReference -) +from __future__ import annotations + +from .policy import (AutoApprove, AutoRequest, RecipientEntityReference, + RecipientGeneratorMixin, RequesterGenerator, + WorkflowRequest, WorkflowRequestEscalation, + WorkflowRequestPolicy, WorkflowTransitions) __all__ = ( "WorkflowRequestPolicy", diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index c224294..a6188c2 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import dataclasses from typing import List, Tuple diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index ceba3fb..b83c02c 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -1,12 +1,12 @@ from __future__ import annotations import dataclasses -from functools import cached_property from datetime import timedelta +from functools import cached_property from logging import getLogger from typing import Dict, List, Optional, Tuple -from flask_principal import Need, Permission, Identity +from flask_principal import Identity, Need, Permission from invenio_access.permissions import SystemRoleNeed from invenio_records_permissions.generators import Generator diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py index 5d09006..cd5950c 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -1,6 +1,9 @@ -from invenio_records_resources.references.entity_resolvers import EntityProxy -from invenio_records_resources.references.entity_resolvers.base import EntityResolver +from __future__ import annotations + from flask_principal import Need +from invenio_records_resources.references.entity_resolvers import EntityProxy +from invenio_records_resources.references.entity_resolvers.base import \ + EntityResolver class AutoApprover: diff --git a/oarepo_workflows/services/auto_approve/__init__.py b/oarepo_workflows/services/auto_approve/__init__.py index 43a43ad..720e698 100644 --- a/oarepo_workflows/services/auto_approve/__init__.py +++ b/oarepo_workflows/services/auto_approve/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from oarepo_runtime.services.entity.config import KeywordEntityServiceConfig from oarepo_runtime.services.entity.service import KeywordEntityService diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 5fc9fdd..1829014 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -1,4 +1,7 @@ -from invenio_records_resources.services.records.components.base import ServiceComponent +from __future__ import annotations + +from invenio_records_resources.services.records.components.base import \ + ServiceComponent from oarepo_workflows.errors import MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows diff --git a/oarepo_workflows/services/permissions/__init__.py b/oarepo_workflows/services/permissions/__init__.py index 09499f4..8fb5c14 100644 --- a/oarepo_workflows/services/permissions/__init__.py +++ b/oarepo_workflows/services/permissions/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .generators import AutoApprove, AutoRequest, IfInState, WorkflowPermission from .policy import DefaultWorkflowPermissions, WorkflowPermissionPolicy diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index c9c159b..a36016b 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -1,16 +1,19 @@ +from __future__ import annotations + import operator from functools import reduce from itertools import chain +from typing import Optional -from invenio_records_permissions.generators import ConditionalGenerator, Generator +from flask import Need +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 -from typing import Optional -from flask import Need # invenio_records_permissions.generators.ConditionalGenerator._make_query diff --git a/oarepo_workflows/services/permissions/identity.py b/oarepo_workflows/services/permissions/identity.py index 767201d..89da89b 100644 --- a/oarepo_workflows/services/permissions/identity.py +++ b/oarepo_workflows/services/permissions/identity.py @@ -1,3 +1,5 @@ +from __future__ import annotations + 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 index 1cb75b4..37dc2a3 100644 --- a/oarepo_workflows/services/permissions/policy.py +++ b/oarepo_workflows/services/permissions/policy.py @@ -1,18 +1,16 @@ +from __future__ import annotations + from functools import reduce +from typing import Optional from invenio_records_permissions import RecordPermissionPolicy -from invenio_records_permissions.generators import ( - AnyUser, - AuthenticatedUser, - Disable, - SystemProcess, -) +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 -from typing import Optional class DefaultWorkflowPermissions(RecordPermissionPolicy): diff --git a/oarepo_workflows/services/records/schema.py b/oarepo_workflows/services/records/schema.py index afac455..5c8f137 100644 --- a/oarepo_workflows/services/records/schema.py +++ b/oarepo_workflows/services/records/schema.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import marshmallow as ma from invenio_drafts_resources.services.records.schema import ParentSchema diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index ffc2b7d..d62ea5c 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -1,6 +1,9 @@ -from flask import Blueprint +from __future__ import annotations +from flask import Blueprint from flask.blueprints import Blueprint + + def create_api_blueprint(app) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index 82e58ef..969eede 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -1,6 +1,9 @@ -from flask import Blueprint +from __future__ import annotations +from flask import Blueprint from flask.blueprints import Blueprint + + def create_app_blueprint(app) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) From ed8c0639d93f8c814a8281efdc1ef662c4c105c6 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 20:59:32 +0100 Subject: [PATCH 06/21] Working on documentation and type annotations --- .copyright.tmpl | 5 + annotations.json | 348 ++++++++++++++++++ mypy.ini | 2 + oarepo_workflows/__init__.py | 36 +- oarepo_workflows/base.py | 110 +++++- oarepo_workflows/errors.py | 66 +++- oarepo_workflows/ext.py | 189 +++++++--- oarepo_workflows/ext_config.py | 18 +- oarepo_workflows/proxies.py | 10 + oarepo_workflows/records/__init__.py | 8 + oarepo_workflows/records/models.py | 12 + .../records/systemfields/__init__.py | 16 + .../records/systemfields/state.py | 51 ++- .../records/systemfields/workflow.py | 12 + oarepo_workflows/requests/__init__.py | 34 +- oarepo_workflows/requests/events.py | 51 +-- oarepo_workflows/requests/generators.py | 116 ++++++ oarepo_workflows/requests/policy.py | 278 ++------------ oarepo_workflows/requests/requests.py | 161 ++++++++ oarepo_workflows/resolvers/__init__.py | 8 + .../resolvers/auto_approve/__init__.py | 65 ++-- oarepo_workflows/services/__init__.py | 8 + .../services/auto_approve/__init__.py | 17 + .../services/components/__init__.py | 8 + .../services/components/workflow.py | 40 +- .../services/permissions/__init__.py | 18 +- .../services/permissions/generators.py | 201 ++++++---- .../services/permissions/identity.py | 5 - .../services/permissions/policy.py | 117 ------ .../permissions/record_permission_policy.py | 71 ++++ .../permissions/workflow_permissions.py | 71 ++++ oarepo_workflows/services/records/__init__.py | 7 + oarepo_workflows/services/records/schema.py | 7 + oarepo_workflows/views/__init__.py | 7 + oarepo_workflows/views/api.py | 8 + oarepo_workflows/views/app.py | 8 + ruff.toml | 26 ++ setup.cfg | 2 +- setup.py | 7 + tests/__init__.py | 7 + tests/conftest.py | 9 +- tests/test_workflow.py | 7 + tests/test_workflow_field.py | 7 + tests/test_workflow_requests.py | 29 +- 44 files changed, 1692 insertions(+), 591 deletions(-) create mode 100644 .copyright.tmpl create mode 100644 annotations.json create mode 100644 mypy.ini create mode 100644 oarepo_workflows/requests/generators.py create mode 100644 oarepo_workflows/requests/requests.py delete mode 100644 oarepo_workflows/services/permissions/identity.py delete mode 100644 oarepo_workflows/services/permissions/policy.py create mode 100644 oarepo_workflows/services/permissions/record_permission_policy.py create mode 100644 oarepo_workflows/services/permissions/workflow_permissions.py create mode 100644 ruff.toml 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/annotations.json b/annotations.json new file mode 100644 index 0000000..6f39b8e --- /dev/null +++ b/annotations.json @@ -0,0 +1,348 @@ +[ + { + "path": "oarepo_workflows/base.py", + "line": 23, + "func_name": "Workflow.requests", + "type_comments": [ + "() -> tests.conftest.MyWorkflowRequests" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/records/systemfields/state.py", + "line": 15, + "func_name": "RecordStateField.post_create", + "type_comments": [ + "(thesis.thesis.records.api.ThesisRecord) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/records/systemfields/state.py", + "line": 18, + "func_name": "RecordStateField.post_init", + "type_comments": [ + "(thesis.thesis.records.api.ThesisRecord, Dict[str, str], thesis.records.models.ThesisMetadata) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/records/systemfields/state.py", + "line": 22, + "func_name": "RecordStateField.__get__", + "type_comments": [ + "(thesis.thesis.records.api.ThesisRecord, invenio_records.systemfields.base.SystemFieldsMeta) -> None" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 44, + "func_name": "requester_generator", + "type_comments": [ + "() -> oarepo_workflows.requests.policy.RequesterGenerator" + ], + "samples": 3 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 51, + "func_name": "WorkflowRequest.recipient_entity_reference", + "type_comments": [ + "() -> Dict[str, str]" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 61, + "func_name": "WorkflowRequest.is_applicable", + "type_comments": [ + "(flask_principal.Identity) -> bool" + ], + "samples": 6 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 151, + "func_name": "items", + "type_comments": [ + "() -> List[Tuple[str, oarepo_workflows.requests.policy.WorkflowRequest]]" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 165, + "func_name": "WorkflowRequestPolicy.applicable_workflow_requests", + "type_comments": [ + "(flask_principal.Identity) -> List[Tuple[str, oarepo_workflows.requests.policy.WorkflowRequest]]" + ], + "samples": 2 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 221, + "func_name": "RequesterGenerator.__init__", + "type_comments": [ + "(List[oarepo_runtime.services.permissions.generators.UserWithRole]) -> pyannotate_runtime.collect_types.NoReturnType", + "(List[oarepo_runtime.services.permissions.generators.RecordOwners]) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 3 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 225, + "func_name": "RequesterGenerator.needs", + "type_comments": [ + "() -> Set[flask_principal.Need]" + ], + "samples": 6 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 237, + "func_name": "RequesterGenerator.excludes", + "type_comments": [ + "() -> Set" + ], + "samples": 6 + }, + { + "path": "oarepo_workflows/requests/policy.py", + "line": 264, + "func_name": "RecipientEntityReference", + "type_comments": [ + "(oarepo_workflows.requests.policy.WorkflowRequest) -> Dict[str, str]" + ], + "samples": 1 + }, + { + "path": "oarepo_workflows/services/permissions/generators.py", + "line": 32, + "func_name": "WorkflowPermission._get_workflow_id", + "type_comments": [ + "(None) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 10 + }, + { + "path": "oarepo_workflows/services/permissions/generators.py", + "line": 43, + "func_name": "WorkflowPermission._get_permissions_from_workflow", + "type_comments": [ + "(None, str) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 10 + }, + { + "path": "oarepo_workflows/services/permissions/generators.py", + "line": 52, + "func_name": "WorkflowPermission.needs", + "type_comments": [ + "(None) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 10 + }, + { + "path": "tests/conftest.py", + "line": 153, + "func_name": "LoggedClient.__init__", + "type_comments": [ + "(flask.testing.FlaskClient, pytest_invenio.user.UserFixtureBase) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 7 + }, + { + "path": "tests/conftest.py", + "line": 157, + "func_name": "LoggedClient._login", + "type_comments": [ + "() -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 5 + }, + { + "path": "tests/conftest.py", + "line": 161, + "func_name": "LoggedClient.post", + "type_comments": [ + "(*str) -> werkzeug.test.WrapperTestResponse", + "(*str) -> werkzeug.test.WrapperTestResponse", + "(*str) -> werkzeug.test.WrapperTestResponse", + "(*str) -> werkzeug.test.WrapperTestResponse" + ], + "samples": 5 + }, + { + "path": "tests/conftest.py", + "line": 180, + "func_name": "_logged_client", + "type_comments": [ + "(pytest_invenio.user.UserFixtureBase) -> tests.conftest.LoggedClient" + ], + "samples": 7 + }, + { + "path": "tests/test_workflow.py", + "line": 8, + "func_name": "test_workflow_read", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 42, + "func_name": "test_workflow_publish", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 67, + "func_name": "test_query_filter", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 98, + "func_name": "test_invalid_workflow_input", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 115, + "func_name": "test_state_change", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], werkzeug.local.LocalProxy, method, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 123, + "func_name": "test_set_workflow", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, method, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 142, + "func_name": "test_state_change_entrypoint_hookup", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], werkzeug.local.LocalProxy, method, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow.py", + "line": 150, + "func_name": "test_set_workflow_entrypoint_hookup", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, method, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_field.py", + "line": 4, + "func_name": "test_workflow_read", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 15, + "func_name": "TestRecipient.reference_receivers", + "type_comments": [ + "(thesis.thesis.records.api.ThesisRecord, None) -> List[Dict[str, str]]" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 21, + "func_name": "NullRecipient.reference_receivers", + "type_comments": [ + "(thesis.thesis.records.api.ThesisRecord, None) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 25, + "func_name": "test_workflow_requests", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 34, + "func_name": "test_request_policy_access", + "type_comments": [ + "(invenio_app.factory:flask.Flask) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 39, + "func_name": "test_is_applicable", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 54, + "func_name": "test_list_applicable_requests", + "type_comments": [ + "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "tests/test_workflow_requests.py", + "line": 55, + "func_name": "R", + "type_comments": [ + "() -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 1 + }, + { + "path": "thesis/thesis/resources/records/config.py", + "line": 14, + "func_name": "response_handlers", + "type_comments": [ + "() -> Dict[str, flask_resources.responses.ResponseHandler]" + ], + "samples": 5 + }, + { + "path": "thesis/thesis/resources/records/ui.py", + "line": 12, + "func_name": "ThesisUIJSONSerializer.__init__", + "type_comments": [ + "() -> pyannotate_runtime.collect_types.NoReturnType" + ], + "samples": 5 + } +] \ No newline at end of file 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 2b95ad1..0621c00 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -1,25 +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. +# +"""Support any workflows on Invenio record.""" + from __future__ import annotations -from oarepo_workflows.services.permissions import (AutoApprove, AutoRequest, - DefaultWorkflowPermissions, - IfInState, - WorkflowPermission, - WorkflowPermissionPolicy) +from oarepo_workflows.services.permissions import ( + IfInState, + WorkflowPermission, + WorkflowRecordPermissionPolicy, +) from .base import Workflow -from .requests import (WorkflowRequest, WorkflowRequestEscalation, - WorkflowRequestPolicy, WorkflowTransitions) +from .requests import ( + AutoApprove, + AutoRequest, + WorkflowRequest, + WorkflowRequestEscalation, + WorkflowRequestPolicy, + WorkflowTransitions, +) __all__ = ( "IfInState", "Workflow", "WorkflowPermission", - "DefaultWorkflowPermissions", - "WorkflowPermissionPolicy", + "WorkflowRecordPermissionPolicy", "WorkflowRequestPolicy", - "WorkflowRequest", - "WorkflowTransitions", "AutoApprove", "AutoRequest", + "WorkflowRequest", "WorkflowRequestEscalation", + "WorkflowTransitions", ) diff --git a/oarepo_workflows/base.py b/oarepo_workflows/base.py index ab9ca7b..063e74e 100644 --- a/oarepo_workflows/base.py +++ b/oarepo_workflows/base.py @@ -1,31 +1,119 @@ +# +# 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 import dataclasses -from typing import Type - -from flask_babel import LazyString +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.""" + + permission_policy_cls: type[DefaultWorkflowPermissions] + """A permission policy class that handles permissions on records that are governed by this workflow.""" - def permissions(self, action, **over): - """Return permission policy for this workflow applicable to the given action.""" + 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) -> None: - assert not issubclass(self.permission_policy_cls, WorkflowPermissionPolicy) - assert issubclass(self.permission_policy_cls, DefaultWorkflowPermissions) + """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 d489c2b..c211771 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -1,21 +1,67 @@ +# +# 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 + from marshmallow import ValidationError +if TYPE_CHECKING: + from invenio_records_resources.records import Record -class MissingWorkflowError(ValidationError): + +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. """ - Exception raised when a required workflow is missing. - Attributes: - message -- explanation of the error + # community record doesn't have id in dict form, only uuid + return str(record["id"]) if "id" in record else str(record.id) + + +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. - Attributes: - message -- explanation of the error - """ + """Exception raised when a workflow is invalid.""" + def __init__( + self, + message: str, + record: Record | dict | None = None, + community_id: 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) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 885e6e0..96518a4 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -1,6 +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. +# +"""Flask extension for workflows.""" + from __future__ import annotations from functools import cached_property +from typing import TYPE_CHECKING, Any import importlib_metadata from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp @@ -10,19 +20,43 @@ from oarepo_workflows.errors import InvalidWorkflowError, MissingWorkflowError from oarepo_workflows.proxies import current_oarepo_workflows from oarepo_workflows.services.auto_approve import ( - AutoApproveEntityService, AutoApproveEntityServiceConfig) + AutoApproveEntityService, + AutoApproveEntityServiceConfig, +) + +if TYPE_CHECKING: + from flask import Flask + from flask_principal import Identity + from invenio_drafts_resources.records import ParentRecord + from invenio_records_resources.records import Record + from invenio_records_resources.services.uow import UnitOfWork + + from oarepo_workflows.base import ( + StateChangedNotifier, + Workflow, + WorkflowChangeNotifier, + ) + +class OARepoWorkflows: + """OARepo workflows extension.""" -class OARepoWorkflows(object): + def __init__(self, app: Flask = None) -> None: + """Initialize the extension. - def __init__(self, app=None) -> 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(app) - def init_config(self, app) -> None: - """Initialize configuration.""" + 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: @@ -36,53 +70,100 @@ def init_config(self, app) -> None: app.config.setdefault("WORKFLOWS", ext_config.WORKFLOWS) - def init_services(self, app) -> None: + def init_services(self) -> None: + """Initialize workflow services.""" self.autoapprove_service = AutoApproveEntityService( config=AutoApproveEntityServiceConfig() ) @cached_property - def state_changed_notifiers(self) -> list: + 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) -> list: + 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) ] def set_state( - self, identity, record, value, *args, uow=None, commit: bool=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 ) def set_workflow( - self, identity, record, new_workflow_id, *args, uow=None, commit: bool=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( @@ -95,47 +176,64 @@ 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 + 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) -> dict: - if "DEFAULT_WORKFLOW_EVENT_SUBMITTERS" in self.app.config: - return self.app.config["DEFAULT_WORKFLOW_EVENT_SUBMITTERS"] - else: - return {} - - 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 - - def get_workflow(self, record): + def default_workflow_events(self) -> dict: + """Return a dictionary of default workflow events. + + 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) -> 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 + """ + parent = record.parent # noqa for typing: we do not have a better type for record with parent 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[parent.workflow] + except AttributeError as e: + raise MissingWorkflowError("Workflow not found.", record=record) from e + except KeyError as e: raise InvalidWorkflowError( - f"Workflow {record.parent.workflow} on record {self._get_id_from_record(record)} doesn't exist." - ) + f"Workflow {parent.workflow} doesn't exist in the configuration.", + record=record, + ) from e - def init_app(self, app) -> None: + def init_app(self, app: Flask) -> None: """Flask application initialization.""" self.app = app app.extensions["oarepo-workflows"] = self -def finalize_app(app) -> None: + +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"] @@ -144,4 +242,3 @@ def finalize_app(app) -> None: ext.autoapprove_service, service_id=ext.autoapprove_service.config.service_id, ) - diff --git a/oarepo_workflows/ext_config.py b/oarepo_workflows/ext_config.py index 111a928..62b9ba3 100644 --- a/oarepo_workflows/ext_config.py +++ b/oarepo_workflows/ext_config.py @@ -1,10 +1,22 @@ +# +# 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 oarepo_workflows.services.permissions.policy import \ - WorkflowPermissionPolicy +from oarepo_workflows.services.permissions.record_permission_policy import ( + WorkflowRecordPermissionPolicy, +) OAREPO_PERMISSIONS_PRESETS = { - "workflow": WorkflowPermissionPolicy, + "workflow": WorkflowRecordPermissionPolicy, } +"""Permissions presets for oarepo-workflows.""" WORKFLOWS = {} +"""Configuration of workflows, must be provided by the user inside, for example, invenio.cfg.""" diff --git a/oarepo_workflows/proxies.py b/oarepo_workflows/proxies.py index 15c86ec..58c803b 100644 --- a/oarepo_workflows/proxies.py +++ b/oarepo_workflows/proxies.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. +# +"""Proxies for accessing the current OARepo workflows extension without bringing dependencies.""" + from __future__ import annotations from flask import current_app @@ -6,3 +15,4 @@ current_oarepo_workflows = LocalProxy( 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 e7da3ec..5c19413 100644 --- a/oarepo_workflows/records/models.py +++ b/oarepo_workflows/records/models.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. +# +"""Model mixins.""" + from __future__ import annotations from invenio_db import db @@ -5,4 +14,7 @@ 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 06bd03c..d66071d 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -1,33 +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 Self +from typing import Any, 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: str="state", initial: str="draft", config=None) -> 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) -> None: + 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) -> None: + def post_init( + self, record: WithState, data: dict, model: 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) -> str | Self: + @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) -> None: + def __set__(self, record: WithState, value: str) -> None: + """Directly set the state of the record.""" self.set_dictkey(record, value) @property 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 43a1d2b..88606f8 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.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. +# +"""Workflow system field.""" + from __future__ import annotations from invenio_records.systemfields.model import ModelField @@ -5,11 +14,14 @@ class WorkflowField(MappingSystemFieldMixin, ModelField): + """Workflow system field, should be defined on ParentRecord.""" 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) -> 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 d638c5c..296bab3 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -1,18 +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 .policy import (AutoApprove, AutoRequest, RecipientEntityReference, - RecipientGeneratorMixin, RequesterGenerator, - WorkflowRequest, WorkflowRequestEscalation, - WorkflowRequestPolicy, WorkflowTransitions) +from .generators import AutoApprove, AutoRequest, RecipientGeneratorMixin +from .policy import ( + WorkflowRequestPolicy, +) +from .requests import ( + RecipientEntityReference, + WorkflowRequest, + WorkflowRequestEscalation, + WorkflowTransitions, +) __all__ = ( "WorkflowRequestPolicy", - "WorkflowRequest", - "WorkflowTransitions", - "RecipientGeneratorMixin", - "WorkflowRequestEscalation", "AutoApprove", "AutoRequest", - "RequesterGenerator", - "RecipientEntityReference" + "RecipientGeneratorMixin", + "RecipientEntityReference", + "WorkflowRequest", + "WorkflowRequestEscalation", + "WorkflowTransitions", ) diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index a6188c2..f80e0fb 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -1,31 +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 import MultipleGeneratorsGenerator -from flask_principal import Need -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) -> set[Need]: - return { - need for generator in self.submitters for need in generator.needs(**kwargs) - } - - def excludes(self, **kwargs) -> set[Need]: - return { - exclude - for generator in self.submitters - for exclude in generator.excludes(**kwargs) - } - - def query_filters(self, **kwargs) -> list[dict]: - 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.py b/oarepo_workflows/requests/generators.py new file mode 100644 index 0000000..4510504 --- /dev/null +++ b/oarepo_workflows/requests/generators.py @@ -0,0 +1,116 @@ +# +# 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 __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING, Any + +from invenio_access import SystemRoleNeed +from invenio_records_permissions.generators import Generator + +if TYPE_CHECKING: + from flask_principal import Need + from invenio_records_resources.records import Record + from invenio_requests.customizations.request_types import RequestType + + +@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_filters(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. + """ + return [ + query_filter + for generator in self.generators + for query_filter in generator.query_filter(**context) + ] + + +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 RecipientGeneratorMixin: + """Mixin for permission generators that can be used as recipients in WorkflowRequest.""" + + def reference_receivers( + self, record: Record = None, request_type: RequestType = None, **context: Any + ) -> list[dict[str, str]]: + """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") + + +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: Record = None, request_type: 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"}] diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index b83c02c..b628a6c 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -1,131 +1,35 @@ +# +# 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.""" + from __future__ import annotations -import dataclasses -from datetime import timedelta from functools import cached_property -from logging import getLogger -from typing import Dict, List, Optional, Tuple - -from flask_principal import Identity, Need, Permission -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 - -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.""" +from typing import TYPE_CHECKING, Any - @cached_property - def requester_generator(self) -> Generator: - """ - Return the requesters as a single requester generator. - """ - return RequesterGenerator(self.requesters) +from .requests import ( + WorkflowRequest, +) - def recipient_entity_reference(self, **context) -> 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, **context) -> 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: - p = Permission(*self.requester_generator.needs(**context)) - if not p.needs: - return False - p.excludes.update(self.requester_generator.excludes(**context)) - return p.allows(identity) - except Exception as e: - log.exception("Error checking request applicability: %s", e) - return False - - @property - def allowed_events(self) -> Dict[str, WorkflowEvent]: - """ - Return the allowed events for the workflow request. - """ - 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. - """ - - 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__}" - ) - - -@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. - """ - - after: timedelta - recipients: List[Generator] | Tuple[Generator] +if TYPE_CHECKING: + from flask_principal import Identity 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 = [ @@ -138,18 +42,24 @@ class MyWorkflowRequests(WorkflowRequestPolicy): rejected = 'published' ) ) + """ - def __getitem__(self, item): + 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 @cached_property def items(self) -> list[tuple[str, WorkflowRequest]]: + """Return the list of request types and their instances. + + This call mimics mapping items() method. + """ ret = [] parent_attrs = set(dir(WorkflowRequestPolicy)) for attr in dir(self.__class__): @@ -162,9 +72,14 @@ def items(self) -> list[tuple[str, WorkflowRequest]]: ret.append((attr, possible_request)) return ret - def applicable_workflow_requests(self, identity, **context) -> list[tuple[str, WorkflowRequest]]: - """ - Return a list of applicable requests for the identity and context. + def applicable_workflow_requests( + self, identity: Identity, **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. """ ret = [] @@ -172,124 +87,3 @@ def applicable_workflow_requests(self, identity, **context) -> list[tuple[str, W if request.is_applicable(identity, **context): ret.append((name, request)) return ret - - -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) -> list[Need]: - """Enabling Needs.""" - return [auto_request_need] - - -class RecipientGeneratorMixin: - """ - Mixin for permission generators that can be used as recipients in WorkflowRequest. - """ - - def reference_receivers(self, record=None, request_type=None, **kwargs) -> List[Dict]: - """ - Taken the context (will include record amd request type at least), - return the reference receiver(s) of the request. - - Should return a list of dictionary serialization of the receiver classes. - - Might return empty list or None to indicate that the generator does not - provide any receivers. - """ - raise NotImplementedError("Implement reference receiver in your code") - - -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=None, request_type=None, **kwargs) -> list[dict[str, str]]: - return [{"auto_approve": "true"}] - - -class RequesterGenerator(Generator): - def __init__(self, requesters) -> None: - super().__init__() - self.requesters = requesters - - def needs(self, **context) -> set[Need]: - """ - Generate a set of needs that requester needs to have in order to create the request. - - :param context: Context of the request. - :return: Set of needs. - """ - - return { - need for generator in self.requesters for need in generator.needs(**context) - } - - def excludes(self, **context) -> set[Need]: - """ - Generate a set of needs that requester must not have in order to create the request. - - :param context: Context of the request. - :return: Set of needs. - """ - return { - exclude - for generator in self.requesters - for exclude in generator.excludes(**context) - } - - def query_filters(self, **context) -> list[dict]: - """ - Generate a list of opensearch query filters to get the listing of matching requests. - - :param context: Context of the request. - """ - - return [ - query_filter - for generator in self.requesters - for query_filter in generator.query_filter(**context) - ] - - -def RecipientEntityReference( - request: WorkflowRequest, **context -) -> 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/requests/requests.py b/oarepo_workflows/requests/requests.py new file mode 100644 index 0000000..c5bdbd7 --- /dev/null +++ b/oarepo_workflows/requests/requests.py @@ -0,0 +1,161 @@ +# +# 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 flask_principal import Identity, Permission + +from oarepo_workflows.proxies import current_oarepo_workflows +from oarepo_workflows.requests import RecipientGeneratorMixin +from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator + +if TYPE_CHECKING: + from datetime import timedelta + + from invenio_records_permissions.generators import Generator + + 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.""" + + @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, **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: + p = Permission(*self.requester_generator.needs(**context)) + if not p.needs: + return False + p.excludes.update(self.requester_generator.excludes(**context)) + return p.allows(identity) + except Exception as e: + log.exception("Error checking request applicability: %s", e) + return False + + @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.""" + try: + return getattr(self, transition_name) + except AttributeError: + raise KeyError( + f"Transition {transition_name} not defined in {self.__class__.__name__}" + ) from None + + +@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] + + +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 index e69de29..10071d3 100644 --- a/oarepo_workflows/resolvers/__init__.py +++ 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 index cd5950c..683b8f9 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -1,45 +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 flask_principal import Need +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 +from invenio_records_resources.references.entity_resolvers.base import EntityResolver + +if TYPE_CHECKING: + from flask_principal import Identity, Need -class AutoApprover: - def __init__(self, value: bool) -> None: - self.value = value +class AutoApproveEntity: + """Entity representing auto approve.""" class AutoApproveProxy(EntityProxy): - def _resolve(self) -> AutoApprover: - value = self._parse_ref_dict_id() - return AutoApprover(value) + """Proxy for auto approve entity.""" - def get_needs(self, ctx=None) -> list[Need]: + 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 [] # granttokens calls this - def pick_resolved_fields(self, identity, resolved_dict) -> dict: + 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: - self.type_key = self.type_id - super().__init__( - "auto_approve", - ) + """Initialize the resolver.""" + super().__init__("auto_approve") - def matches_reference_dict(self, ref_dict) -> bool: + 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) -> dict[str, str]: - return {self.type_key: str(entity.value)} + def _reference_entity(self, entity: Any) -> dict[str, str]: + """Return a reference dictionary for the entity.""" + return {self.type_id: str(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 matches_entity(self, entity) -> bool: - return isinstance(entity, AutoApprover) + def _get_entity_proxy(self, ref_dict: dict) -> AutoApproveProxy: + """Get the entity proxy for the reference dictionary. - def _get_entity_proxy(self, ref_dict) -> AutoApproveProxy: + 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 index 720e698..e0a8132 100644 --- a/oarepo_workflows/services/auto_approve/__init__.py +++ b/oarepo_workflows/services/auto_approve/__init__.py @@ -1,3 +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. +# +"""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 @@ -5,9 +18,13 @@ 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 1829014..63669e9 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -1,18 +1,48 @@ +# +# 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 invenio_records_resources.services.records.components.base import \ - ServiceComponent +from typing import TYPE_CHECKING, Any + +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): - def create(self, identity, data=None, record=None, **kwargs) -> None: + """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: Identity, + data: dict = None, + record: Record = None, + **kwargs: Any, + ) -> None: + """Implement record creation checks and set the workflow on the created record.""" try: workflow_id = data["parent"]["workflow"] - except KeyError: - raise MissingWorkflowError("Workflow not defined in input.") + except KeyError as e: + raise MissingWorkflowError( + "Workflow not defined in input.", 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 8fb5c14..efb3e97 100644 --- a/oarepo_workflows/services/permissions/__init__.py +++ b/oarepo_workflows/services/permissions/__init__.py @@ -1,13 +1,21 @@ +# +# 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 AutoApprove, AutoRequest, IfInState, WorkflowPermission -from .policy import DefaultWorkflowPermissions, WorkflowPermissionPolicy +from .generators import IfInState, WorkflowPermission +from .record_permission_policy import WorkflowRecordPermissionPolicy +from .workflow_permissions import DefaultWorkflowPermissions __all__ = ( "IfInState", "WorkflowPermission", + "WorkflowRecordPermissionPolicy", "DefaultWorkflowPermissions", - "WorkflowPermissionPolicy", - "AutoApprove", - "AutoRequest", ) diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index a36016b..6895560 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -1,128 +1,201 @@ +# +# 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 Optional +from typing import TYPE_CHECKING, Any, Iterable -from flask import Need -from invenio_records_permissions.generators import (ConditionalGenerator, - Generator) +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) -> dict: - queries = [g.query_filter(**kwargs) for g in generators] +def _make_query(generators: Iterable[Generator], **context: Any) -> dict: + 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) -> None: + """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. + """ + + def __init__(self, action: str | None = None) -> None: + """Initialize the permission.""" # might not be needed in subclasses super().__init__() self._action = action - def _get_workflow_id(self, record=None, **kwargs): + def _get_workflow_id(self, record: 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: Optional[str]=None, **kwargs): - workflow_id = self._get_workflow_id(record, **kwargs) + def _get_permissions_from_workflow( + self, record: Record = None, action_name: str | None = 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: Record = None, **context: Any) -> set[Need]: + """Return needs that are generated by the workflow permission.""" + return self._get_permissions_from_workflow( + record, self._action, **context + ).needs - def query_filter(self, record=None, **kwargs): + def query_filter(self, record: Record = None, **context: Any) -> dict: + """Return query filters that are generated by the workflow permission.""" return self._get_permissions_from_workflow( - record, self._action, **kwargs + record, self._action, **context ).query_filters class IfInState(ConditionalGenerator): - def __init__(self, state, then_) -> None: - 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] | None = None, + else_: list[Generator] | tuple[Generator] | None = None, + ) -> None: + """Initialize the generator.""" + super().__init__(then_ or [], else_=else_ or []) self.state = state - def _condition(self, record, **kwargs) -> bool: + 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_) -> None: - self.as_ = as_ + """Generator that delegates the permissions to another action. + + Example: + .. code-block:: python + class Perms: + can_create_files = [SameAs("edit_files")] + can_edit_files = [RecordOwners()] - def _generators(self, policy, **kwargs): - return getattr(policy, self.as_) + 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 needs(self, policy, **kwargs) -> set[Need]: + def __init__(self, action: str) -> None: + """Initialize the generator.""" + self.action_generator_name = f"can_{action}" + + def _generators( + self, policy: RecordPermissionPolicy, **context: Any + ) -> list[Generator]: + """Get the generators from the policy.""" + return getattr(policy, self.action_generator_name) + + def needs(self, policy: RecordPermissionPolicy, **context: Any) -> set[Need]: + """Get the needs from the policy.""" needs = [ - generator.needs(**kwargs) - for generator in self._generators(policy, **kwargs) + generator.needs(**context) + for generator in self._generators(policy, **context) ] return set(chain.from_iterable(needs)) - def excludes(self, policy, **kwargs) -> set[Need]: - """Preventing Needs.""" + def excludes(self, policy: RecordPermissionPolicy, **context: Any) -> set[Need]: + """Get the excludes from the policy.""" excludes = [ - generator.excludes(**kwargs) - for generator in self._generators(policy, **kwargs) + generator.excludes(**context) + for generator in self._generators(policy, **context) ] return set(chain.from_iterable(excludes)) - def query_filter(self, policy, **kwargs) -> dict: + def query_filter(self, policy: RecordPermissionPolicy, **context: Any) -> dict: """Search filters.""" - return _make_query(self._generators(policy, **kwargs), **kwargs) - - -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) -> list[Need]: - """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. - """ - - def reference_receivers(self, record=None, request_type=None, **kwargs) -> list[dict[str, str]]: - return [{"auto_approve": "true"}] + return _make_query(self._generators(policy, **context), **context) diff --git a/oarepo_workflows/services/permissions/identity.py b/oarepo_workflows/services/permissions/identity.py deleted file mode 100644 index 89da89b..0000000 --- a/oarepo_workflows/services/permissions/identity.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -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 37dc2a3..0000000 --- a/oarepo_workflows/services/permissions/policy.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from functools import reduce -from typing import Optional - -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: Optional[str]=None, **over) -> None: - 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) -> list[dict]: - 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..8d1ee24 --- /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 WorkflowPermission + + +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 = [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) -> 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_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/workflow_permissions.py b/oarepo_workflows/services/permissions/workflow_permissions.py new file mode 100644 index 0000000..037b2d6 --- /dev/null +++ b/oarepo_workflows/services/permissions/workflow_permissions.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Optional + +from invenio_records_permissions import RecordPermissionPolicy +from invenio_records_permissions.generators import ( + AuthenticatedUser, + Disable, + SystemProcess, +) +from oarepo_runtime.services.permissions import RecordOwners + +from oarepo_workflows import IfInState +from oarepo_workflows.services.permissions.generators import 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, ... + ) + } + + """ + + # 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: Optional[str] = None, **over) -> None: + 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..892225a 100644 --- a/oarepo_workflows/services/records/__init__.py +++ b/oarepo_workflows/services/records/__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/oarepo_workflows/services/records/schema.py b/oarepo_workflows/services/records/schema.py index 5c8f137..a3fea29 100644 --- a/oarepo_workflows/services/records/schema.py +++ b/oarepo_workflows/services/records/schema.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 __future__ import annotations import marshmallow as ma diff --git a/oarepo_workflows/views/__init__.py b/oarepo_workflows/views/__init__.py index e69de29..892225a 100644 --- a/oarepo_workflows/views/__init__.py +++ b/oarepo_workflows/views/__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/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index d62ea5c..e2d97cf 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.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 __future__ import annotations from flask import Blueprint @@ -10,6 +17,7 @@ def create_api_blueprint(app) -> Blueprint: def register_autoapprove_entity_resolver(state) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + requests = app.extensions["invenio-requests"] requests.entity_resolvers_registry.register_type(AutoApproveResolver()) diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index 969eede..c751291 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.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 __future__ import annotations from flask import Blueprint @@ -10,6 +17,7 @@ def create_app_blueprint(app) -> Blueprint: def register_autoapprove_entity_resolver(state) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver + requests = app.extensions["invenio-requests"] requests.entity_resolvers_registry.register_type(AutoApproveResolver()) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6a1c41c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,26 @@ +[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/setup.cfg b/setup.cfg index 84900c5..2de0b95 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ long_description_content_type = text/markdown [options] -python = >=3.9 +python = >=3.12 install_requires = invenio-records-resources invenio-requests diff --git a/setup.py b/setup.py index 6068493..a8aef14 100644 --- a/setup.py +++ b/setup.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 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..7e4b2e0 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 @@ -25,7 +32,6 @@ class RecordOwnersReadTestWorkflowPermissionPolicy(DefaultWorkflowPermissions): class Administration(Generator): - def needs(self, **kwargs): """Enabling Needs.""" return [ActionNeed("administration")] @@ -128,7 +134,6 @@ def input_data(sample_metadata_list): @pytest.fixture() def users(app, db, UserFixture): - user1 = UserFixture( email="user1@example.org", password="password", diff --git a/tests/test_workflow.py b/tests/test_workflow.py index a98d784..00111bd 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.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 pytest from oarepo_workflows.errors import InvalidWorkflowError 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_requests.py b/tests/test_workflow_requests.py index b12e975..cb11e2b 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.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 types import SimpleNamespace from invenio_records_permissions.generators import Generator @@ -36,21 +43,23 @@ def test_request_policy_access(app): 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): req = WorkflowRequest( requesters=[RecordOwners()], recipients=[NullRecipient(), TestRecipient()], ) - id1=Identity(id=1) + id1 = Identity(id=1) id1.provides.add(UserNeed(1)) - id2=Identity(id=2) + id2 = Identity(id=2) id2.provides.add(UserNeed(2)) - record = SimpleNamespace(parent=SimpleNamespace(owners = [SimpleNamespace(id=1)])) + record = SimpleNamespace(parent=SimpleNamespace(owners=[SimpleNamespace(id=1)])) 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): class R(WorkflowRequestPolicy): req = WorkflowRequest( @@ -66,15 +75,19 @@ class R(WorkflowRequestPolicy): requests = R() - id1=Identity(id=1) + id1 = Identity(id=1) id1.provides.add(UserNeed(1)) - id2=Identity(id=2) + id2 = Identity(id=2) id2.provides.add(UserNeed(2)) id2.provides.add(RoleNeed("administrator")) - record = SimpleNamespace(parent=SimpleNamespace(owners = [SimpleNamespace(id=1)])) + record = SimpleNamespace(parent=SimpleNamespace(owners=[SimpleNamespace(id=1)])) - 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(id1, record=record) + ) == {"req"} - assert set(x[0] for x in requests.applicable_workflow_requests(id2, record=record)) == {'req1'} + assert set( + x[0] for x in requests.applicable_workflow_requests(id2, record=record) + ) == {"req1"} From 491850f976cfa0d058847621e3278e9983023ee9 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 15 Nov 2024 21:10:21 +0100 Subject: [PATCH 07/21] Added documentation and annotations --- oarepo_workflows/errors.py | 10 +++++++--- oarepo_workflows/ext.py | 6 +++--- oarepo_workflows/ext_config.py | 7 ++++++- .../records/systemfields/state.py | 4 ++-- oarepo_workflows/requests/events.py | 2 +- oarepo_workflows/requests/generators.py | 12 +++++++++--- .../services/components/workflow.py | 8 +++++--- .../services/permissions/generators.py | 19 ++++++++++++------- .../permissions/workflow_permissions.py | 14 ++++++++++++-- oarepo_workflows/services/records/__init__.py | 1 + oarepo_workflows/services/records/schema.py | 4 ++++ oarepo_workflows/views/__init__.py | 1 + oarepo_workflows/views/api.py | 9 +++++---- oarepo_workflows/views/app.py | 9 +++++---- setup.py | 2 ++ 15 files changed, 75 insertions(+), 33 deletions(-) diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index c211771..ff440fb 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from marshmallow import ValidationError @@ -24,7 +24,11 @@ def _get_id_from_record(record: Record | dict) -> str: :return str: The id of the record. """ # community record doesn't have id in dict form, only uuid - return str(record["id"]) if "id" in record else str(record.id) + if "id" in record: + return str(record["id"]) + if hasattr(record, "id"): + return str(record.id) + raise ValueError(f"Record {record} doesn't have an id.") def _format_record(record: Record | dict) -> str: @@ -55,7 +59,7 @@ def __init__( self, message: str, record: Record | dict | None = None, - community_id: str = None, + community_id: Optional[str] = None, ) -> None: """Initialize the exception.""" self.record = record diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 96518a4..b0dd6b1 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -10,7 +10,7 @@ from __future__ import annotations from functools import cached_property -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional import importlib_metadata from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp @@ -41,7 +41,7 @@ class OARepoWorkflows: """OARepo workflows extension.""" - def __init__(self, app: Flask = None) -> None: + def __init__(self, app: Optional[Flask] = None) -> None: """Initialize the extension. :param app: Flask application to initialize with. @@ -50,7 +50,7 @@ def __init__(self, app: Flask = None) -> None: if app: self.init_config(app) self.init_app(app) - self.init_services(app) + self.init_services() def init_config(self, app: Flask) -> None: """Initialize configuration. diff --git a/oarepo_workflows/ext_config.py b/oarepo_workflows/ext_config.py index 62b9ba3..36c1c76 100644 --- a/oarepo_workflows/ext_config.py +++ b/oarepo_workflows/ext_config.py @@ -9,14 +9,19 @@ 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": WorkflowRecordPermissionPolicy, } """Permissions presets for oarepo-workflows.""" -WORKFLOWS = {} +WORKFLOWS: dict[str, Workflow] = {} """Configuration of workflows, must be provided by the user inside, for example, invenio.cfg.""" diff --git a/oarepo_workflows/records/systemfields/state.py b/oarepo_workflows/records/systemfields/state.py index d66071d..2002a09 100644 --- a/oarepo_workflows/records/systemfields/state.py +++ b/oarepo_workflows/records/systemfields/state.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Any, Protocol, Self, overload +from typing import Any, Optional, Protocol, Self, overload from invenio_records.systemfields.base import SystemField from oarepo_runtime.records.systemfields import MappingSystemFieldMixin @@ -40,7 +40,7 @@ def post_create(self, record: WithState) -> None: self.set_dictkey(record, self._initial) def post_init( - self, record: WithState, data: dict, model: Any = None, **kwargs: Any + self, record: WithState, data: dict, model: Optional[Any] = None, **kwargs: Any ) -> None: """Set the initial state when record is created.""" if not record.state: diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index f80e0fb..012bc5e 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -13,7 +13,7 @@ from functools import cached_property from typing import TYPE_CHECKING -from oarepo_workflows.requests import MultipleGeneratorsGenerator +from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator if TYPE_CHECKING: from invenio_records_permissions.generators import Generator diff --git a/oarepo_workflows/requests/generators.py b/oarepo_workflows/requests/generators.py index 4510504..a8fd8cc 100644 --- a/oarepo_workflows/requests/generators.py +++ b/oarepo_workflows/requests/generators.py @@ -10,7 +10,7 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional from invenio_access import SystemRoleNeed from invenio_records_permissions.generators import Generator @@ -85,7 +85,10 @@ class RecipientGeneratorMixin: """Mixin for permission generators that can be used as recipients in WorkflowRequest.""" def reference_receivers( - self, record: Record = None, request_type: RequestType = None, **context: Any + self, + record: Optional[Record] = None, + request_type: Optional[RequestType] = None, + **context: Any, ) -> list[dict[str, str]]: """Return the reference receiver(s) of the request. @@ -107,7 +110,10 @@ class AutoApprove(RecipientGeneratorMixin, Generator): """ def reference_receivers( - self, record: Record = None, request_type: RequestType = None, **kwargs: Any + 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. diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 63669e9..4acf5e3 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional from invenio_records_resources.services.records.components.base import ServiceComponent @@ -31,11 +31,13 @@ class WorkflowComponent(ServiceComponent): def create( self, identity: Identity, - data: dict = None, - record: Record = None, + 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: + raise MissingWorkflowError("Workflow not defined in input.", record=data) try: workflow_id = data["parent"]["workflow"] except KeyError as e: diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 6895560..d71e894 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -12,7 +12,7 @@ import operator from functools import reduce from itertools import chain -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any, Iterable, Optional from invenio_records_permissions.generators import ConditionalGenerator, Generator from invenio_search.engine import dsl @@ -27,7 +27,7 @@ # invenio_records_permissions.generators.ConditionalGenerator._make_query -def _make_query(generators: Iterable[Generator], **context: Any) -> dict: +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 @@ -49,7 +49,7 @@ def __init__(self, action: str | None = None) -> None: super().__init__() self._action = action - def _get_workflow_id(self, record: Record = None, **context: Any) -> str: + 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. @@ -78,7 +78,10 @@ def _get_workflow_id(self, record: Record = None, **context: Any) -> str: return workflow_id def _get_permissions_from_workflow( - self, record: Record = None, action_name: str | None = None, **context: Any + self, + record: Optional[Record] = None, + action_name: str | None = None, + **context: Any, ) -> RecordPermissionPolicy: """Get the permissions policy from the workflow. @@ -96,13 +99,13 @@ def _get_permissions_from_workflow( policy = current_oarepo_workflows.record_workflows[workflow_id].permissions return policy(action_name, record=record, **context) - def needs(self, record: Record = None, **context: Any) -> set[Need]: + def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: """Return needs that are generated by the workflow permission.""" return self._get_permissions_from_workflow( record, self._action, **context ).needs - def query_filter(self, record: Record = None, **context: Any) -> dict: + def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: """Return query filters that are generated by the workflow permission.""" return self._get_permissions_from_workflow( record, self._action, **context @@ -196,6 +199,8 @@ def excludes(self, policy: RecordPermissionPolicy, **context: Any) -> set[Need]: ] return set(chain.from_iterable(excludes)) - def query_filter(self, policy: RecordPermissionPolicy, **context: Any) -> dict: + def query_filter( + self, policy: RecordPermissionPolicy, **context: Any + ) -> dict | None: """Search filters.""" return _make_query(self._generators(policy, **context), **context) diff --git a/oarepo_workflows/services/permissions/workflow_permissions.py b/oarepo_workflows/services/permissions/workflow_permissions.py index 037b2d6..820b187 100644 --- a/oarepo_workflows/services/permissions/workflow_permissions.py +++ b/oarepo_workflows/services/permissions/workflow_permissions.py @@ -1,6 +1,15 @@ +# +# 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 Optional +from typing import Any from invenio_records_permissions import RecordPermissionPolicy from invenio_records_permissions.generators import ( @@ -38,7 +47,8 @@ class MyWorkflowPermissions(DefaultWorkflowPermissions): system_process = SystemProcess() - def __init__(self, action_name: Optional[str] = None, **over) -> None: + 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) diff --git a/oarepo_workflows/services/records/__init__.py b/oarepo_workflows/services/records/__init__.py index 892225a..52b293a 100644 --- a/oarepo_workflows/services/records/__init__.py +++ b/oarepo_workflows/services/records/__init__.py @@ -5,3 +5,4 @@ # 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 a3fea29..de75b07 100644 --- a/oarepo_workflows/services/records/schema.py +++ b/oarepo_workflows/services/records/schema.py @@ -5,6 +5,8 @@ # 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 @@ -12,4 +14,6 @@ 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 index 892225a..240ba96 100644 --- a/oarepo_workflows/views/__init__.py +++ b/oarepo_workflows/views/__init__.py @@ -5,3 +5,4 @@ # 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 index e2d97cf..d2d12cd 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -5,17 +5,18 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # +"""API blueprints.""" + from __future__ import annotations -from flask import Blueprint -from flask.blueprints import Blueprint +from flask import Blueprint, BlueprintSetupState, Flask -def create_api_blueprint(app) -> Blueprint: +def create_api_blueprint(app: Flask) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state) -> None: + def register_autoapprove_entity_resolver(state: BlueprintSetupState) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver requests = app.extensions["invenio-requests"] diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index c751291..e3af857 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -5,17 +5,18 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # +"""App blueprints.""" + from __future__ import annotations -from flask import Blueprint -from flask.blueprints import Blueprint +from flask.blueprints import Blueprint, BlueprintSetupState, Flask -def create_app_blueprint(app) -> Blueprint: +def create_app_blueprint(app: Flask) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state) -> None: + def register_autoapprove_entity_resolver(state: BlueprintSetupState) -> None: from oarepo_workflows.resolvers.auto_approve import AutoApproveResolver requests = app.extensions["invenio-requests"] diff --git a/setup.py b/setup.py index a8aef14..c07b204 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,8 @@ # 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() From 43cd9c0b5e9126789b25e95db1d4de0fd185531d Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Mon, 18 Nov 2024 05:38:54 +0100 Subject: [PATCH 08/21] Working tests, updated docs, typing --- oarepo_workflows/errors.py | 2 +- oarepo_workflows/ext.py | 4 ++- .../services/permissions/generators.py | 31 +++++++++++-------- .../permissions/workflow_permissions.py | 3 +- oarepo_workflows/views/api.py | 7 ++++- oarepo_workflows/views/app.py | 7 ++++- run-tests.sh | 10 +++--- tests/test_workflow.py | 2 +- 8 files changed, 42 insertions(+), 24 deletions(-) diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index ff440fb..cc6d159 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -28,7 +28,7 @@ def _get_id_from_record(record: Record | dict) -> str: return str(record["id"]) if hasattr(record, "id"): return str(record.id) - raise ValueError(f"Record {record} doesn't have an id.") + return str(record) def _format_record(record: Record | dict) -> str: diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index b0dd6b1..d7c7eae 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -14,7 +14,7 @@ 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 @@ -102,6 +102,7 @@ def workflow_changed_notifiers(self) -> list[WorkflowChangeNotifier]: x.load() for x in importlib_metadata.entry_points().select(group=group_name) ] + @unit_of_work() def set_state( self, identity: Identity, @@ -132,6 +133,7 @@ def set_state( identity, record, previous_value, new_state, *args, uow=uow, **kwargs ) + @unit_of_work() def set_workflow( self, identity: Identity, diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index d71e894..2074400 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -173,34 +173,39 @@ class Perms: """ - def __init__(self, action: str) -> None: - """Initialize the generator.""" - self.action_generator_name = f"can_{action}" + def __init__(self, permission_name: str) -> None: + """Initialize the generator. + + :param permission_name: Name of the permission to delegate to. In most cases, + it will look like "can_<action>". 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 _generators( - self, policy: RecordPermissionPolicy, **context: Any + self, *, policy: RecordPermissionPolicy, **context: Any ) -> list[Generator]: """Get the generators from the policy.""" - return getattr(policy, self.action_generator_name) + return getattr(policy, self.delegated_permission_name) - def needs(self, policy: RecordPermissionPolicy, **context: Any) -> set[Need]: + 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, **context) + for generator in self._generators(policy=policy, **context) ] - return set(chain.from_iterable(needs)) + return list(chain.from_iterable(needs)) - def excludes(self, policy: RecordPermissionPolicy, **context: Any) -> set[Need]: + 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, **context) + for generator in self._generators(policy=policy, **context) ] - return set(chain.from_iterable(excludes)) + return list(chain.from_iterable(excludes)) def query_filter( - self, policy: RecordPermissionPolicy, **context: Any + self, *, policy: RecordPermissionPolicy, **context: Any ) -> dict | None: """Search filters.""" - return _make_query(self._generators(policy, **context), **context) + return _make_query(self._generators(policy=policy, **context), **context) diff --git a/oarepo_workflows/services/permissions/workflow_permissions.py b/oarepo_workflows/services/permissions/workflow_permissions.py index 820b187..1ec44d9 100644 --- a/oarepo_workflows/services/permissions/workflow_permissions.py +++ b/oarepo_workflows/services/permissions/workflow_permissions.py @@ -19,8 +19,7 @@ ) from oarepo_runtime.services.permissions import RecordOwners -from oarepo_workflows import IfInState -from oarepo_workflows.services.permissions.generators import SameAs +from .generators import IfInState, SameAs class DefaultWorkflowPermissions(RecordPermissionPolicy): diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index d2d12cd..e788ca9 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -9,7 +9,12 @@ from __future__ import annotations -from flask import Blueprint, BlueprintSetupState, Flask +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: diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index e3af857..c090bea 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -9,7 +9,12 @@ from __future__ import annotations -from flask.blueprints import Blueprint, BlueprintSetupState, Flask +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: 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/tests/test_workflow.py b/tests/test_workflow.py index 00111bd..d1ff0d2 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -110,7 +110,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 From eb640be0b8c3076f18f94fc32e6630cb12d91824 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Mon, 18 Nov 2024 11:03:45 +0100 Subject: [PATCH 09/21] More tests --- .coveragerc | 4 + oarepo_workflows/errors.py | 7 +- oarepo_workflows/requests/generators.py | 36 +++++-- oarepo_workflows/requests/requests.py | 8 +- .../services/components/workflow.py | 12 ++- .../services/permissions/generators.py | 6 +- ruff.toml | 4 + tests/test_auto_approve.py | 33 +++++++ tests/test_auto_request.py | 6 ++ tests/test_if_in_state.py | 0 tests/test_workflow.py | 10 ++ tests/test_workflow_permission.py | 28 ++++++ tests/test_workflow_requests.py | 93 ++++++++++++++++--- 13 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/test_auto_approve.py create mode 100644 tests/test_auto_request.py create mode 100644 tests/test_if_in_state.py create mode 100644 tests/test_workflow_permission.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1bd1b83 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: \ No newline at end of file diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index cc6d159..d01643f 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -24,8 +24,11 @@ def _get_id_from_record(record: Record | dict) -> str: :return str: The id of the record. """ # community record doesn't have id in dict form, only uuid - if "id" in record: - return str(record["id"]) + try: + if "id" in record: + return str(record["id"]) + except TypeError: + pass if hasattr(record, "id"): return str(record.id) return str(record) diff --git a/oarepo_workflows/requests/generators.py b/oarepo_workflows/requests/generators.py index a8fd8cc..51633cf 100644 --- a/oarepo_workflows/requests/generators.py +++ b/oarepo_workflows/requests/generators.py @@ -10,6 +10,7 @@ from __future__ import annotations import dataclasses +from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Optional from invenio_access import SystemRoleNeed @@ -50,7 +51,7 @@ def excludes(self, **context: Any) -> set[Need]: for exclude in generator.excludes(**context) } - def query_filters(self, **context: Any) -> list[dict]: + 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 @@ -58,11 +59,15 @@ def query_filters(self, **context: Any) -> list[dict]: :param context: Context. """ - return [ - query_filter - for generator in self.generators - for query_filter in generator.query_filter(**context) - ] + ret = [] + 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 auto_request_need = SystemRoleNeed("auto_request") @@ -89,7 +94,7 @@ def reference_receivers( record: Optional[Record] = None, request_type: Optional[RequestType] = None, **context: Any, - ) -> list[dict[str, str]]: + ) -> 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" @@ -119,4 +124,19 @@ def reference_receivers( Returning "auto_approve" is a signal to the workflow that the request should be auto-approved. """ - return [{"auto_approve": "true"}] + return [{"auto_approve": "True"}] + + def needs(self, **kwargs): + """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, **kwargs): + """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, **kwargs): + """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.") \ No newline at end of file diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index c5bdbd7..c006798 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -109,13 +109,11 @@ class WorkflowTransitions: def __getitem__(self, transition_name: str): """Get the transition by name.""" - try: - return getattr(self, transition_name) - except AttributeError: + if transition_name not in ["submitted", "accepted", "declined"]: raise KeyError( f"Transition {transition_name} not defined in {self.__class__.__name__}" - ) from None - + ) + return getattr(self, transition_name) @dataclasses.dataclass class WorkflowRequestEscalation: diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index 4acf5e3..d1dedee 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -37,12 +37,16 @@ def create( ) -> None: """Implement record creation checks and set the workflow on the created record.""" if not data: - raise MissingWorkflowError("Workflow not defined in input.", record=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 as e: - raise MissingWorkflowError( - "Workflow not defined in input.", record=data + 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( diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 2074400..028c38e 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -106,7 +106,11 @@ def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: ).needs def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: - """Return query filters that are generated by the workflow permission.""" + """Return query filters that are generated by the workflow permission. + + 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, **context ).query_filters diff --git a/ruff.toml b/ruff.toml index 6a1c41c..e0da368 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,3 +1,7 @@ +exclude = [ + "tests" +] + [lint] extend-select = [ "UP", # pyupgrade diff --git a/tests/test_auto_approve.py b/tests/test_auto_approve.py new file mode 100644 index 0000000..645ad87 --- /dev/null +++ b/tests/test_auto_approve.py @@ -0,0 +1,33 @@ +from oarepo_workflows.requests.generators import AutoApprove, auto_approve_need +import pytest +from invenio_requests.resolvers.registry import ResolverRegistry + +from oarepo_workflows.resolvers.auto_approve import AutoApproveProxy, AutoApproveEntity +from invenio_access.permissions import system_identity + + +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..c4a0e00 --- /dev/null +++ b/tests/test_auto_request.py @@ -0,0 +1,6 @@ +from oarepo_workflows import AutoRequest +from oarepo_workflows.requests.generators import auto_request_need + + +def test_auto_request_needs(app): + assert AutoRequest().needs() == [auto_request_need] \ No newline at end of file diff --git a/tests/test_if_in_state.py b/tests/test_if_in_state.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_workflow.py b/tests/test_workflow.py index d1ff0d2..7fb5d61 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -12,6 +12,16 @@ from thesis.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): # create draft user_client1 = logged_client(users[0]) diff --git a/tests/test_workflow_permission.py b/tests/test_workflow_permission.py new file mode 100644 index 0000000..41cb5d8 --- /dev/null +++ b/tests/test_workflow_permission.py @@ -0,0 +1,28 @@ +from types import SimpleNamespace + +from oarepo_workflows import WorkflowPermission +from oarepo_workflows.errors import MissingWorkflowError +from thesis.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 = WorkflowPermission("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 = WorkflowPermission("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 cb11e2b..1579cb2 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -8,14 +8,15 @@ 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 +from oarepo_workflows import WorkflowRequestPolicy, WorkflowTransitions from oarepo_workflows.requests import RecipientGeneratorMixin, WorkflowRequest from thesis.thesis.records.api import ThesisRecord from flask_principal import Identity, UserNeed, RoleNeed -from oarepo_runtime.services.permissions.generators import UserWithRole +import pytest +from opensearch_dsl.query import Terms class TestRecipient(RecipientGeneratorMixin, Generator): @@ -28,6 +29,40 @@ class NullRecipient(RecipientGeneratorMixin, Generator): 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( @@ -37,6 +72,19 @@ def test_workflow_requests(users, logged_client, search_clear, record_service): rec = ThesisRecord.create({}) 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() @@ -61,17 +109,7 @@ def test_is_applicable(users, logged_client, search_clear, record_service): def test_list_applicable_requests(users, logged_client, search_clear, record_service): - class R(WorkflowRequestPolicy): - req = WorkflowRequest( - requesters=[RecordOwners()], - recipients=[NullRecipient(), TestRecipient()], - ) - req1 = WorkflowRequest( - requesters=[ - UserWithRole("administrator"), - ], - recipients=[NullRecipient(), TestRecipient()], - ) + requests = R() @@ -91,3 +129,30 @@ class R(WorkflowRequestPolicy): assert set( x[0] for x in requests.applicable_workflow_requests(id2, record=record) ) == {"req1"} + + +def test_get_request_type(users, logged_client, search_clear, record_service): + requests = R() + assert requests["req"] == requests.req + assert requests["req1"] == requests.req1 + with pytest.raises(KeyError): + requests["non_existing_request"] # noqa + +def test_transition_getter(users, logged_client, search_clear, record_service): + 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): + 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])] \ No newline at end of file From 27ecdd884b84f026882e9e2dc582865cdc264495 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Mon, 18 Nov 2024 11:52:52 +0100 Subject: [PATCH 10/21] coverage settings --- .coveragerc | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 1bd1b83..ba18d3c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,12 @@ [report] exclude_lines = pragma: no cover - if TYPE_CHECKING: \ No newline at end of file + ^# +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 From 8d39706422d0ff632ed0c39447aae5717d2d0490 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Mon, 18 Nov 2024 11:55:48 +0100 Subject: [PATCH 11/21] Sources re-formatted --- oarepo_workflows/requests/generators.py | 28 ++++++++------ oarepo_workflows/requests/requests.py | 1 + .../services/components/workflow.py | 14 ++++--- .../services/permissions/generators.py | 4 +- tests/test_auto_approve.py | 7 ++++ tests/test_auto_request.py | 9 ++++- tests/test_if_in_state.py | 7 ++++ tests/test_workflow.py | 13 ++++--- tests/test_workflow_permission.py | 8 ++++ tests/test_workflow_requests.py | 38 ++++++++++++------- 10 files changed, 91 insertions(+), 38 deletions(-) diff --git a/oarepo_workflows/requests/generators.py b/oarepo_workflows/requests/generators.py index 51633cf..444f036 100644 --- a/oarepo_workflows/requests/generators.py +++ b/oarepo_workflows/requests/generators.py @@ -59,7 +59,7 @@ def query_filter(self, **context: Any) -> list[dict]: :param context: Context. """ - ret = [] + ret: list[dict] = [] for generator in self.generators: query_filter = generator.query_filter(**context) if query_filter: @@ -94,7 +94,7 @@ def reference_receivers( record: Optional[Record] = None, request_type: Optional[RequestType] = None, **context: Any, - ) -> list[dict[str, str]]: # pragma: no cover + ) -> 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" @@ -126,17 +126,23 @@ def reference_receivers( """ return [{"auto_approve": "True"}] - def needs(self, **kwargs): + 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.") + raise ValueError( + "Auto-approve generator can not create needs and " + "should be used only in `recipient` section of WorkflowRequest." + ) - def excludes(self, **kwargs): + 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.") + raise ValueError( + "Auto-approve generator can not create needs and " + "should be used only in `recipient` section of WorkflowRequest." + ) - def query_filter(self, **kwargs): + 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.") \ No newline at end of file + 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/requests.py b/oarepo_workflows/requests/requests.py index c006798..cc1d973 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -115,6 +115,7 @@ def __getitem__(self, transition_name: str): ) return getattr(self, transition_name) + @dataclasses.dataclass class WorkflowRequestEscalation: """Escalation of the request. diff --git a/oarepo_workflows/services/components/workflow.py b/oarepo_workflows/services/components/workflow.py index d1dedee..c416353 100644 --- a/oarepo_workflows/services/components/workflow.py +++ b/oarepo_workflows/services/components/workflow.py @@ -38,15 +38,19 @@ def create( """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 + 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 as e: # pragma: no cover + except KeyError as e: # pragma: no cover # sanity check, should be handled by policy before the component is called - raise MissingWorkflowError( # pragma: no cover + 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 + "make sure you are using workflow-enabled policy.", + record=data, ) from e current_oarepo_workflows.set_workflow( diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 028c38e..9945e4f 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -108,8 +108,8 @@ def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: """Return query filters that are generated by the workflow permission. - 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. + 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, **context diff --git a/tests/test_auto_approve.py b/tests/test_auto_approve.py index 645ad87..9acbd2a 100644 --- a/tests/test_auto_approve.py +++ b/tests/test_auto_approve.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 oarepo_workflows.requests.generators import AutoApprove, auto_approve_need import pytest from invenio_requests.resolvers.registry import ResolverRegistry diff --git a/tests/test_auto_request.py b/tests/test_auto_request.py index c4a0e00..be7a57d 100644 --- a/tests/test_auto_request.py +++ b/tests/test_auto_request.py @@ -1,6 +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 import AutoRequest from oarepo_workflows.requests.generators import auto_request_need def test_auto_request_needs(app): - assert AutoRequest().needs() == [auto_request_need] \ No newline at end of file + assert AutoRequest().needs() == [auto_request_need] diff --git a/tests/test_if_in_state.py b/tests/test_if_in_state.py index e69de29..892225a 100644 --- a/tests/test_if_in_state.py +++ 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 7fb5d61..16ab497 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -12,15 +12,18 @@ from thesis.thesis.records.api import ThesisDraft, ThesisRecord -def test_create_without_workflow(users, logged_client, default_workflow_json, search_clear): +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={} - ) + 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.'] + assert create_response.json["errors"][0]["messages"] == [ + "Workflow not defined in input." + ] + def test_workflow_read(users, logged_client, default_workflow_json, search_clear): # create draft diff --git a/tests/test_workflow_permission.py b/tests/test_workflow_permission.py index 41cb5d8..fa5a28c 100644 --- a/tests/test_workflow_permission.py +++ b/tests/test_workflow_permission.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 types import SimpleNamespace from oarepo_workflows import WorkflowPermission @@ -18,6 +25,7 @@ def test_get_workflow_id(users, logged_client, search_clear, record_service): 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 = WorkflowPermission("read") diff --git a/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index 1579cb2..b13b23a 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -29,6 +29,7 @@ class NullRecipient(RecipientGeneratorMixin, Generator): def reference_receivers(self, record=None, request_type=None, **kwargs): return None + class FailingGenerator(Generator): def needs(self, **context): raise ValueError("Failing generator") @@ -39,6 +40,7 @@ def excludes(self, **context): def query_filter(self, **context): raise ValueError("Failing generator") + class R(WorkflowRequestPolicy): req = WorkflowRequest( requesters=[RecordOwners()], @@ -56,14 +58,15 @@ class R(WorkflowRequestPolicy): recipients=[NullRecipient(), TestRecipient()], ) req2 = WorkflowRequest( - requesters = [], # never applicable, must be created by, for example, system identity - recipients = [] + requesters=[], # never applicable, must be created by, for example, system identity + recipients=[], ) req3 = WorkflowRequest( - requesters = [FailingGenerator()], - recipients = [NullRecipient(), TestRecipient()], + requesters=[FailingGenerator()], + recipients=[NullRecipient(), TestRecipient()], ) + def test_workflow_requests(users, logged_client, search_clear, record_service): req = WorkflowRequest( requesters=[RecordOwners()], @@ -72,7 +75,10 @@ def test_workflow_requests(users, logged_client, search_clear, record_service): rec = ThesisRecord.create({}) assert req.recipient_entity_reference(record=rec) == {"user": "1"} -def test_workflow_requests_no_recipient(users, logged_client, search_clear, record_service): + +def test_workflow_requests_no_recipient( + users, logged_client, search_clear, record_service +): req1 = WorkflowRequest( requesters=[RecordOwners()], recipients=[NullRecipient()], @@ -86,6 +92,7 @@ def test_workflow_requests_no_recipient(users, logged_client, search_clear, reco ) 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) @@ -109,8 +116,6 @@ def test_is_applicable(users, logged_client, search_clear, record_service): def test_list_applicable_requests(users, logged_client, search_clear, record_service): - - requests = R() id1 = Identity(id=1) @@ -136,23 +141,28 @@ def test_get_request_type(users, logged_client, search_clear, record_service): assert requests["req"] == requests.req assert requests["req1"] == requests.req1 with pytest.raises(KeyError): - requests["non_existing_request"] # noqa + requests["non_existing_request"] # noqa + def test_transition_getter(users, logged_client, search_clear, record_service): requests = R() - assert requests.req.transitions['submitted'] == 'pending' - assert requests.req.transitions['accepted'] == 'accepted' - assert requests.req.transitions['declined'] == 'declined' + 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 + requests.req.transitions["non_existing_transition"] # noqa def test_requestor_filter(users, logged_client, search_clear, record_service): requests = R() - sample_record = SimpleNamespace(parent=SimpleNamespace(owners=[SimpleNamespace(id=1)])) + 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])] \ No newline at end of file + assert generator.query_filter(identity=id1, record=sample_record) == [ + Terms(parent__owners__user=[1]) + ] From f525f7810136e3ec162b24fbd0da6fcc86386889 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Wed, 20 Nov 2024 11:22:22 +0100 Subject: [PATCH 12/21] Renaming etc. --- oarepo_workflows/__init__.py | 2 + oarepo_workflows/ext.py | 10 +++-- oarepo_workflows/proxies.py | 7 +++- oarepo_workflows/requests/policy.py | 6 +-- oarepo_workflows/requests/requests.py | 1 + .../resolvers/auto_approve/__init__.py | 2 +- .../services/permissions/__init__.py | 3 +- .../services/permissions/generators.py | 25 ++++++++--- .../permissions/record_permission_policy.py | 42 +++++++++---------- .../permissions/workflow_permissions.py | 2 +- oarepo_workflows/views/api.py | 5 ++- oarepo_workflows/views/app.py | 5 ++- tests/test_workflow_permission.py | 6 +-- 13 files changed, 72 insertions(+), 44 deletions(-) diff --git a/oarepo_workflows/__init__.py b/oarepo_workflows/__init__.py index 0621c00..56f1a53 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -12,6 +12,7 @@ from oarepo_workflows.services.permissions import ( IfInState, WorkflowPermission, + FromRecordWorkflow, WorkflowRecordPermissionPolicy, ) @@ -36,4 +37,5 @@ "WorkflowRequest", "WorkflowRequestEscalation", "WorkflowTransitions", + "FromRecordWorkflow", ) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index d7c7eae..8c15019 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -52,6 +52,7 @@ def __init__(self, app: Optional[Flask] = None) -> None: self.init_app(app) self.init_services() + # noinspection PyMethodMayBeStatic def init_config(self, app: Flask) -> None: """Initialize configuration. @@ -72,7 +73,8 @@ def init_config(self, app: Flask) -> None: def init_services(self) -> None: """Initialize workflow services.""" - self.autoapprove_service = AutoApproveEntityService( + # noinspection PyAttributeOutsideInit + self.auto_approve_service = AutoApproveEntityService( config=AutoApproveEntityServiceConfig() ) @@ -178,6 +180,7 @@ def set_workflow( **kwargs, ) + # noinspection PyMethodMayBeStatic def get_workflow_from_record(self, record: Record | ParentRecord) -> str | None: """Get the workflow from a record. @@ -224,6 +227,7 @@ def get_workflow(self, record: Record) -> Workflow: def init_app(self, app: Flask) -> None: """Flask application initialization.""" + # noinspection PyAttributeOutsideInit self.app = app app.extensions["oarepo-workflows"] = self @@ -241,6 +245,6 @@ def finalize_app(app: Flask) -> None: ext = app.extensions["oarepo-workflows"] records_resources.registry.register( - ext.autoapprove_service, - service_id=ext.autoapprove_service.config.service_id, + ext.auto_approve_service, + service_id=ext.auto_approve_service.config.service_id, ) diff --git a/oarepo_workflows/proxies.py b/oarepo_workflows/proxies.py index 58c803b..fcbc295 100644 --- a/oarepo_workflows/proxies.py +++ b/oarepo_workflows/proxies.py @@ -9,10 +9,15 @@ 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/requests/policy.py b/oarepo_workflows/requests/policy.py index b628a6c..12f345b 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -9,7 +9,6 @@ from __future__ import annotations -from functools import cached_property from typing import TYPE_CHECKING, Any from .requests import ( @@ -54,7 +53,6 @@ def __getitem__(self, request_type_id: str) -> WorkflowRequest: f"Request type {request_type_id} not defined in {self.__class__.__name__}" ) from None - @cached_property def items(self) -> list[tuple[str, WorkflowRequest]]: """Return the list of request types and their instances. @@ -79,11 +77,11 @@ def applicable_workflow_requests( :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. + :return: List of tuples (request_type_id, request) that are applicable for the identity and context. """ ret = [] - for name, request in self.items: + for name, request in self.items(): if request.is_applicable(identity, **context): ret.append((name, request)) return ret diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index cc1d973..83c1ab4 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -129,6 +129,7 @@ class WorkflowRequestEscalation: 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. diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py index 683b8f9..e88d16a 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -31,7 +31,7 @@ def _resolve(self) -> AutoApproveEntity: def get_needs(self, ctx: dict | None = None) -> list[Need]: """Get needs that the entity generate.""" - return [] # granttokens calls this + 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.""" diff --git a/oarepo_workflows/services/permissions/__init__.py b/oarepo_workflows/services/permissions/__init__.py index efb3e97..5c157b0 100644 --- a/oarepo_workflows/services/permissions/__init__.py +++ b/oarepo_workflows/services/permissions/__init__.py @@ -9,7 +9,7 @@ from __future__ import annotations -from .generators import IfInState, WorkflowPermission +from .generators import IfInState, WorkflowPermission, FromRecordWorkflow from .record_permission_policy import WorkflowRecordPermissionPolicy from .workflow_permissions import DefaultWorkflowPermissions @@ -18,4 +18,5 @@ "WorkflowPermission", "WorkflowRecordPermissionPolicy", "DefaultWorkflowPermissions", + "FromRecordWorkflow", ) diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 9945e4f..d7fd30c 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -33,7 +33,7 @@ def _make_query(generators: Iterable[Generator], **context: Any) -> dict | None: return reduce(operator.or_, queries) if queries else None -class WorkflowPermission(Generator): +class FromRecordWorkflow(Generator): """Permission delegating check to workflow. The implementation of the permission gets the workflow id from the passed context @@ -43,12 +43,15 @@ class WorkflowPermission(Generator): determine the permissions for the action. """ - def __init__(self, action: str | None = None) -> None: + _action: str + + def __init__(self, action: str) -> None: """Initialize the permission.""" # might not be needed in subclasses super().__init__() self._action = action + # noinspection PyMethodMayBeStatic def _get_workflow_id(self, record: Optional[Record] = None, **context: Any) -> str: """Get the workflow id from the context. @@ -79,8 +82,8 @@ def _get_workflow_id(self, record: Optional[Record] = None, **context: Any) -> s def _get_permissions_from_workflow( self, + action_name: str, record: Optional[Record] = None, - action_name: str | None = None, **context: Any, ) -> RecordPermissionPolicy: """Get the permissions policy from the workflow. @@ -102,9 +105,15 @@ def _get_permissions_from_workflow( def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: """Return needs that are generated by the workflow permission.""" return self._get_permissions_from_workflow( - record, self._action, **context + self._action, record, **context ).needs + def excludes(self, **context: Any) -> set[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. @@ -112,9 +121,14 @@ def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: for each registered workflow type. The query_filters are then combined into a single query. """ return self._get_permissions_from_workflow( - record, self._action, **context + self._action, record, **context ).query_filters +class WorkflowPermission(FromRecordWorkflow): + def __init__(self, action: str) -> None: + import warnings + warnings.warn("WorkflowPermission is deprecated. Use FromRecordWorkflow instead.", DeprecationWarning) + super().__init__(action) class IfInState(ConditionalGenerator): """Generator that checks if the record is in a specific state. @@ -186,6 +200,7 @@ def __init__(self, permission_name: str) -> None: """ self.delegated_permission_name = permission_name + # noinspection PyUnusedLocal def _generators( self, *, policy: RecordPermissionPolicy, **context: Any ) -> list[Generator]: diff --git a/oarepo_workflows/services/permissions/record_permission_policy.py b/oarepo_workflows/services/permissions/record_permission_policy.py index 8d1ee24..d23d7ed 100644 --- a/oarepo_workflows/services/permissions/record_permission_policy.py +++ b/oarepo_workflows/services/permissions/record_permission_policy.py @@ -19,7 +19,7 @@ from invenio_search.engine import dsl from ...proxies import current_oarepo_workflows -from .generators import WorkflowPermission +from .generators import FromRecordWorkflow class WorkflowRecordPermissionPolicy(RecordPermissionPolicy): @@ -28,25 +28,25 @@ class WorkflowRecordPermissionPolicy(RecordPermissionPolicy): 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_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 = [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_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()] @@ -60,12 +60,12 @@ def query_filters(self) -> list[dict]: workflows = current_oarepo_workflows.record_workflows queries = [] for workflow_id, workflow in workflows.items(): - q_inworkflow = dsl.Q("term", **{"parent.workflow": workflow_id}) + 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_inworkflow + 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 index 1ec44d9..ffda667 100644 --- a/oarepo_workflows/services/permissions/workflow_permissions.py +++ b/oarepo_workflows/services/permissions/workflow_permissions.py @@ -37,7 +37,7 @@ class MyWorkflowPermissions(DefaultWorkflowPermissions): """ - # new version - update; edit current version - disable -> idk if there's other way than something like IfNoEditDraft/IfNoNewVersionDraft generators- + # 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()]), diff --git a/oarepo_workflows/views/api.py b/oarepo_workflows/views/api.py index e788ca9..6e61a03 100644 --- a/oarepo_workflows/views/api.py +++ b/oarepo_workflows/views/api.py @@ -21,12 +21,13 @@ def create_api_blueprint(app: Flask) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state: BlueprintSetupState) -> None: + # 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_autoapprove_entity_resolver) + blueprint.record_once(register_auto_approve_entity_resolver) return blueprint diff --git a/oarepo_workflows/views/app.py b/oarepo_workflows/views/app.py index c090bea..751fd2c 100644 --- a/oarepo_workflows/views/app.py +++ b/oarepo_workflows/views/app.py @@ -21,12 +21,13 @@ def create_app_blueprint(app: Flask) -> Blueprint: """Create requests blueprint.""" blueprint = Blueprint("oarepo-workflows", __name__) - def register_autoapprove_entity_resolver(state: BlueprintSetupState) -> None: + # 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_autoapprove_entity_resolver) + blueprint.record_once(register_auto_approve_entity_resolver) return blueprint diff --git a/tests/test_workflow_permission.py b/tests/test_workflow_permission.py index fa5a28c..11049af 100644 --- a/tests/test_workflow_permission.py +++ b/tests/test_workflow_permission.py @@ -7,7 +7,7 @@ # from types import SimpleNamespace -from oarepo_workflows import WorkflowPermission +from oarepo_workflows import FromRecordWorkflow from oarepo_workflows.errors import MissingWorkflowError from thesis.thesis.records.api import ThesisRecord import pytest @@ -17,7 +17,7 @@ def test_get_workflow_id(users, logged_client, search_clear, record_service): thesis = ThesisRecord.create({}) - wp = WorkflowPermission("read") + wp = FromRecordWorkflow("read") with pytest.raises(MissingWorkflowError): wp._get_workflow_id(record=thesis) @@ -27,7 +27,7 @@ def test_get_workflow_id(users, logged_client, search_clear, record_service): def test_query_filter(users, logged_client, search_clear, record_service): - wp = WorkflowPermission("read") + wp = FromRecordWorkflow("read") id1 = Identity(id=1) id1.provides.add(UserNeed(1)) From 9882eb545c6309bde47eb837afb2630da42d496a Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Wed, 20 Nov 2024 14:32:11 +0100 Subject: [PATCH 13/21] Applicable requests evaluate can_create --- oarepo_workflows/__init__.py | 2 +- oarepo_workflows/errors.py | 4 +++ oarepo_workflows/proxies.py | 2 +- oarepo_workflows/requests/generators.py | 2 +- oarepo_workflows/requests/policy.py | 5 ++++ oarepo_workflows/requests/requests.py | 29 +++++++++++++++++- .../resolvers/auto_approve/__init__.py | 2 +- .../services/permissions/__init__.py | 2 +- .../services/permissions/generators.py | 24 +++++++++++---- tests/conftest.py | 15 +++++++++- tests/test_auto_approve.py | 11 ++++--- tests/test_workflow.py | 2 +- tests/test_workflow_permission.py | 2 +- tests/test_workflow_requests.py | 30 +++++++++++++++---- tests/utils.py | 4 +-- 15 files changed, 107 insertions(+), 29 deletions(-) diff --git a/oarepo_workflows/__init__.py b/oarepo_workflows/__init__.py index 56f1a53..7f36821 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -10,9 +10,9 @@ from __future__ import annotations from oarepo_workflows.services.permissions import ( + FromRecordWorkflow, IfInState, WorkflowPermission, - FromRecordWorkflow, WorkflowRecordPermissionPolicy, ) diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index d01643f..8de5452 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -72,3 +72,7 @@ def __init__( super().__init__(f"{message} Used on community {community_id}") else: super().__init__(message) + + +class InvalidConfigurationError(Exception): + """Exception raised when a configuration is invalid.""" diff --git a/oarepo_workflows/proxies.py b/oarepo_workflows/proxies.py index fcbc295..221147f 100644 --- a/oarepo_workflows/proxies.py +++ b/oarepo_workflows/proxies.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from oarepo_workflows.ext import OARepoWorkflows -current_oarepo_workflows: OARepoWorkflows = LocalProxy( # type: ignore +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/requests/generators.py b/oarepo_workflows/requests/generators.py index 444f036..b1b57d6 100644 --- a/oarepo_workflows/requests/generators.py +++ b/oarepo_workflows/requests/generators.py @@ -124,7 +124,7 @@ def reference_receivers( Returning "auto_approve" is a signal to the workflow that the request should be auto-approved. """ - return [{"auto_approve": "True"}] + return [{"auto_approve": "true"}] def needs(self, **context: Any) -> list[Need]: """Get needs that signal workflow to automatically approve the request.""" diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index 12f345b..4b539cf 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -44,6 +44,11 @@ class MyWorkflowRequests(WorkflowRequestPolicy): """ + 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: diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index 83c1ab4..de005bc 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -15,15 +15,18 @@ from typing import TYPE_CHECKING, Any, Optional from flask_principal import Identity, Permission +from invenio_requests.proxies import current_request_type_registry from oarepo_workflows.proxies import current_oarepo_workflows from oarepo_workflows.requests import RecipientGeneratorMixin from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator +from oarepo_workflows.errors import InvalidConfigurationError if TYPE_CHECKING: from datetime import timedelta from invenio_records_permissions.generators import Generator + from invenio_requests.customizations.request_types import RequestType from oarepo_workflows.requests.events import WorkflowEvent @@ -56,6 +59,8 @@ class WorkflowRequest: 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.""" @@ -82,11 +87,33 @@ def is_applicable(self, identity: Identity, **context: Any) -> bool: if not p.needs: return False p.excludes.update(self.requester_generator.excludes(**context)) - return p.allows(identity) + if not p.allows(identity): + return False + if hasattr(self.request_type, "can_create"): + return self.request_type.can_create(identity, **context) + return True + except InvalidConfigurationError: + raise except Exception as e: log.exception("Error checking request applicability: %s", e) return False + @cached_property + def request_type(self) -> type[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.""" diff --git a/oarepo_workflows/resolvers/auto_approve/__init__.py b/oarepo_workflows/resolvers/auto_approve/__init__.py index e88d16a..f13c90c 100644 --- a/oarepo_workflows/resolvers/auto_approve/__init__.py +++ b/oarepo_workflows/resolvers/auto_approve/__init__.py @@ -53,7 +53,7 @@ def matches_reference_dict(self, ref_dict: dict) -> bool: def _reference_entity(self, entity: Any) -> dict[str, str]: """Return a reference dictionary for the entity.""" - return {self.type_id: str(True)} + 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.""" diff --git a/oarepo_workflows/services/permissions/__init__.py b/oarepo_workflows/services/permissions/__init__.py index 5c157b0..7aba29a 100644 --- a/oarepo_workflows/services/permissions/__init__.py +++ b/oarepo_workflows/services/permissions/__init__.py @@ -9,7 +9,7 @@ from __future__ import annotations -from .generators import IfInState, WorkflowPermission, FromRecordWorkflow +from .generators import FromRecordWorkflow, IfInState, WorkflowPermission from .record_permission_policy import WorkflowRecordPermissionPolicy from .workflow_permissions import DefaultWorkflowPermissions diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index d7fd30c..48809dd 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -110,9 +110,7 @@ def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: def excludes(self, **context: Any) -> set[Need]: """Return excludes that are generated by the workflow permission.""" - return self._get_permissions_from_workflow( - self._action, **context - ).excludes + 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. @@ -124,12 +122,22 @@ def query_filter(self, record: Optional[Record] = None, **context: Any) -> dict: 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) + + warnings.warn( + "WorkflowPermission is deprecated. Use FromRecordWorkflow instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(action) + class IfInState(ConditionalGenerator): """Generator that checks if the record is in a specific state. @@ -145,10 +153,14 @@ class IfInState(ConditionalGenerator): def __init__( self, state: str, - then_: list[Generator] | tuple[Generator] | None = None, - else_: list[Generator] | tuple[Generator] | None = None, + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 7e4b2e0..9c78d3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,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 ( @@ -217,3 +219,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 index 9acbd2a..c56b132 100644 --- a/tests/test_auto_approve.py +++ b/tests/test_auto_approve.py @@ -5,12 +5,11 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # -from oarepo_workflows.requests.generators import AutoApprove, auto_approve_need +from oarepo_workflows.requests.generators import AutoApprove import pytest from invenio_requests.resolvers.registry import ResolverRegistry -from oarepo_workflows.resolvers.auto_approve import AutoApproveProxy, AutoApproveEntity -from invenio_access.permissions import system_identity +from oarepo_workflows.resolvers.auto_approve import AutoApproveEntity def test_auto_approve_generator(): @@ -25,11 +24,11 @@ def test_auto_approve_generator(): with pytest.raises(ValueError): a.query_filter() - assert a.reference_receivers() == [{"auto_approve": "True"}] + assert a.reference_receivers() == [{"auto_approve": "true"}] def test_auto_approve_resolver(app): - resolved = ResolverRegistry.resolve_entity({"auto_approve": "True"}) + resolved = ResolverRegistry.resolve_entity({"auto_approve": "true"}) assert isinstance(resolved, AutoApproveEntity) # # assert resolved.resolve() == AutoApprove() @@ -37,4 +36,4 @@ def test_auto_approve_resolver(app): # assert resolved.get_needs() == [] entity_reference = ResolverRegistry.reference_entity(AutoApproveEntity()) - assert entity_reference == {"auto_approve": "True"} + assert entity_reference == {"auto_approve": "true"} diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 16ab497..63957ae 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -9,7 +9,7 @@ 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( diff --git a/tests/test_workflow_permission.py b/tests/test_workflow_permission.py index 11049af..1e11322 100644 --- a/tests/test_workflow_permission.py +++ b/tests/test_workflow_permission.py @@ -9,7 +9,7 @@ from oarepo_workflows import FromRecordWorkflow from oarepo_workflows.errors import MissingWorkflowError -from thesis.thesis.records.api import ThesisRecord +from thesis.records.api import ThesisRecord import pytest from flask_principal import Identity, UserNeed diff --git a/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index b13b23a..d7be03a 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -12,7 +12,7 @@ from oarepo_workflows import WorkflowRequestPolicy, WorkflowTransitions from oarepo_workflows.requests import RecipientGeneratorMixin, WorkflowRequest -from thesis.thesis.records.api import ThesisRecord +from thesis.records.api import ThesisRecord from flask_principal import Identity, UserNeed, RoleNeed import pytest @@ -99,11 +99,13 @@ def test_request_policy_access(app): assert not getattr(request_policy, "non_existing_request", None) -def test_is_applicable(users, logged_client, search_clear, record_service): +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)) @@ -115,7 +117,9 @@ def test_is_applicable(users, logged_client, search_clear, record_service): assert req.is_applicable(id1, record=record) is True -def test_list_applicable_requests(users, logged_client, search_clear, record_service): +def test_list_applicable_requests( + users, logged_client, search_clear, record_service, extra_request_types +): requests = R() id1 = Identity(id=1) @@ -136,7 +140,9 @@ def test_list_applicable_requests(users, logged_client, search_clear, record_ser ) == {"req1"} -def test_get_request_type(users, logged_client, search_clear, record_service): +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 @@ -144,7 +150,17 @@ def test_get_request_type(users, logged_client, search_clear, record_service): requests["non_existing_request"] # noqa -def test_transition_getter(users, logged_client, search_clear, record_service): +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" @@ -153,7 +169,9 @@ def test_transition_getter(users, logged_client, search_clear, record_service): requests.req.transitions["non_existing_transition"] # noqa -def test_requestor_filter(users, logged_client, search_clear, record_service): +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)]) 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 From 50e0add9e0f304345a733352448caa30bda3fbd4 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Wed, 20 Nov 2024 14:32:39 +0100 Subject: [PATCH 14/21] Formatting --- oarepo_workflows/requests/requests.py | 2 +- tests/test_workflow_requests.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index de005bc..d133dea 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -17,10 +17,10 @@ from flask_principal import Identity, Permission from invenio_requests.proxies import current_request_type_registry +from oarepo_workflows.errors import InvalidConfigurationError from oarepo_workflows.proxies import current_oarepo_workflows from oarepo_workflows.requests import RecipientGeneratorMixin from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator -from oarepo_workflows.errors import InvalidConfigurationError if TYPE_CHECKING: from datetime import timedelta diff --git a/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index d7be03a..5dab92e 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -99,7 +99,9 @@ def test_request_policy_access(app): assert not getattr(request_policy, "non_existing_request", None) -def test_is_applicable(users, logged_client, search_clear, record_service, extra_request_types): +def test_is_applicable( + users, logged_client, search_clear, record_service, extra_request_types +): req = WorkflowRequest( requesters=[RecordOwners()], recipients=[NullRecipient(), TestRecipient()], From 77cdd3f31c3143bf95653aac06648b227b8035b6 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Wed, 20 Nov 2024 14:34:51 +0100 Subject: [PATCH 15/21] Python version --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 85f8872cd032f835e892865837be5917ae914aa6 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Wed, 20 Nov 2024 20:01:04 +0100 Subject: [PATCH 16/21] Extracting workflow even from dict --- oarepo_workflows/ext.py | 31 +++++++++++++++++++++------ oarepo_workflows/requests/policy.py | 5 +++-- oarepo_workflows/requests/requests.py | 13 +++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 8c15019..66f306f 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -14,6 +14,7 @@ import importlib_metadata from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp +from invenio_records_resources.records import Record from invenio_records_resources.services.uow import RecordCommitOp, unit_of_work from oarepo_runtime.datastreams.utils import get_record_service_for_record @@ -28,7 +29,6 @@ from flask import Flask from flask_principal import Identity from invenio_drafts_resources.records import ParentRecord - from invenio_records_resources.records import Record from invenio_records_resources.services.uow import UnitOfWork from oarepo_workflows.base import ( @@ -207,18 +207,37 @@ def default_workflow_events(self) -> dict: """ return self.app.config.get("DEFAULT_WORKFLOW_EVENTS", {}) - def get_workflow(self, record: Record) -> Workflow: + 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 """ - parent = record.parent # noqa for typing: we do not have a better type for record with parent + if isinstance(record, Record): + try: + parent = 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 = parent.workflow + except AttributeError as e: + raise MissingWorkflowError("Parent record does not have a workflow attribute.", + record=record) from e + else: + try: + parent = record["parent"] + except KeyError as e: + raise MissingWorkflowError("Record does not have a parent attribute.", + record=record) from e + try: + workflow_id = parent["workflow"] + except KeyError as e: + raise MissingWorkflowError("Parent record does not have a workflow attribute.", record=record) from e + try: - return self.record_workflows[parent.workflow] - except AttributeError as e: - raise MissingWorkflowError("Workflow not found.", record=record) from e + return self.record_workflows[workflow_id] except KeyError as e: raise InvalidWorkflowError( f"Workflow {parent.workflow} doesn't exist in the configuration.", diff --git a/oarepo_workflows/requests/policy.py b/oarepo_workflows/requests/policy.py index 4b539cf..c1f6f9f 100644 --- a/oarepo_workflows/requests/policy.py +++ b/oarepo_workflows/requests/policy.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from flask_principal import Identity + from invenio_records_resources.records.api import Record class WorkflowRequestPolicy: @@ -76,7 +77,7 @@ def items(self) -> list[tuple[str, WorkflowRequest]]: return ret def applicable_workflow_requests( - self, identity: Identity, **context: Any + self, identity: Identity, *, record: Record, **context: Any ) -> list[tuple[str, WorkflowRequest]]: """Return a list of applicable requests for the identity and context. @@ -87,6 +88,6 @@ def applicable_workflow_requests( ret = [] for name, request in self.items(): - if request.is_applicable(identity, **context): + 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 index d133dea..aadfc23 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -26,6 +26,7 @@ from datetime import timedelta 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 @@ -76,21 +77,21 @@ def recipient_entity_reference(self, **context: Any) -> dict | None: """ return RecipientEntityReference(self, **context) - def is_applicable(self, identity: Identity, **context: Any) -> bool: + 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: - p = Permission(*self.requester_generator.needs(**context)) + p = Permission(*self.requester_generator.needs(record=record, **context)) if not p.needs: return False - p.excludes.update(self.requester_generator.excludes(**context)) + p.excludes.update(self.requester_generator.excludes(record=record, **context)) if not p.allows(identity): return False - if hasattr(self.request_type, "can_create"): - return self.request_type.can_create(identity, **context) + if hasattr(self.request_type, "is_applicable_to"): + return self.request_type.is_applicable_to(identity, topic=record, **context) return True except InvalidConfigurationError: raise @@ -99,7 +100,7 @@ def is_applicable(self, identity: Identity, **context: Any) -> bool: return False @cached_property - def request_type(self) -> type[RequestType]: + def request_type(self) -> RequestType: """Return the request type for the workflow request.""" if self._request_type is None: raise InvalidConfigurationError( From e548b8369552abe0700b9db110f75683b4712b33 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Thu, 21 Nov 2024 10:17:06 +0100 Subject: [PATCH 17/21] re moved annotations.json, formatting --- annotations.json | 348 -------------------------- oarepo_workflows/ext.py | 20 +- oarepo_workflows/requests/requests.py | 12 +- 3 files changed, 22 insertions(+), 358 deletions(-) delete mode 100644 annotations.json diff --git a/annotations.json b/annotations.json deleted file mode 100644 index 6f39b8e..0000000 --- a/annotations.json +++ /dev/null @@ -1,348 +0,0 @@ -[ - { - "path": "oarepo_workflows/base.py", - "line": 23, - "func_name": "Workflow.requests", - "type_comments": [ - "() -> tests.conftest.MyWorkflowRequests" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/records/systemfields/state.py", - "line": 15, - "func_name": "RecordStateField.post_create", - "type_comments": [ - "(thesis.thesis.records.api.ThesisRecord) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/records/systemfields/state.py", - "line": 18, - "func_name": "RecordStateField.post_init", - "type_comments": [ - "(thesis.thesis.records.api.ThesisRecord, Dict[str, str], thesis.records.models.ThesisMetadata) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/records/systemfields/state.py", - "line": 22, - "func_name": "RecordStateField.__get__", - "type_comments": [ - "(thesis.thesis.records.api.ThesisRecord, invenio_records.systemfields.base.SystemFieldsMeta) -> None" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 44, - "func_name": "requester_generator", - "type_comments": [ - "() -> oarepo_workflows.requests.policy.RequesterGenerator" - ], - "samples": 3 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 51, - "func_name": "WorkflowRequest.recipient_entity_reference", - "type_comments": [ - "() -> Dict[str, str]" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 61, - "func_name": "WorkflowRequest.is_applicable", - "type_comments": [ - "(flask_principal.Identity) -> bool" - ], - "samples": 6 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 151, - "func_name": "items", - "type_comments": [ - "() -> List[Tuple[str, oarepo_workflows.requests.policy.WorkflowRequest]]" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 165, - "func_name": "WorkflowRequestPolicy.applicable_workflow_requests", - "type_comments": [ - "(flask_principal.Identity) -> List[Tuple[str, oarepo_workflows.requests.policy.WorkflowRequest]]" - ], - "samples": 2 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 221, - "func_name": "RequesterGenerator.__init__", - "type_comments": [ - "(List[oarepo_runtime.services.permissions.generators.UserWithRole]) -> pyannotate_runtime.collect_types.NoReturnType", - "(List[oarepo_runtime.services.permissions.generators.RecordOwners]) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 3 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 225, - "func_name": "RequesterGenerator.needs", - "type_comments": [ - "() -> Set[flask_principal.Need]" - ], - "samples": 6 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 237, - "func_name": "RequesterGenerator.excludes", - "type_comments": [ - "() -> Set" - ], - "samples": 6 - }, - { - "path": "oarepo_workflows/requests/policy.py", - "line": 264, - "func_name": "RecipientEntityReference", - "type_comments": [ - "(oarepo_workflows.requests.policy.WorkflowRequest) -> Dict[str, str]" - ], - "samples": 1 - }, - { - "path": "oarepo_workflows/services/permissions/generators.py", - "line": 32, - "func_name": "WorkflowPermission._get_workflow_id", - "type_comments": [ - "(None) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 10 - }, - { - "path": "oarepo_workflows/services/permissions/generators.py", - "line": 43, - "func_name": "WorkflowPermission._get_permissions_from_workflow", - "type_comments": [ - "(None, str) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 10 - }, - { - "path": "oarepo_workflows/services/permissions/generators.py", - "line": 52, - "func_name": "WorkflowPermission.needs", - "type_comments": [ - "(None) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 10 - }, - { - "path": "tests/conftest.py", - "line": 153, - "func_name": "LoggedClient.__init__", - "type_comments": [ - "(flask.testing.FlaskClient, pytest_invenio.user.UserFixtureBase) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 7 - }, - { - "path": "tests/conftest.py", - "line": 157, - "func_name": "LoggedClient._login", - "type_comments": [ - "() -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 5 - }, - { - "path": "tests/conftest.py", - "line": 161, - "func_name": "LoggedClient.post", - "type_comments": [ - "(*str) -> werkzeug.test.WrapperTestResponse", - "(*str) -> werkzeug.test.WrapperTestResponse", - "(*str) -> werkzeug.test.WrapperTestResponse", - "(*str) -> werkzeug.test.WrapperTestResponse" - ], - "samples": 5 - }, - { - "path": "tests/conftest.py", - "line": 180, - "func_name": "_logged_client", - "type_comments": [ - "(pytest_invenio.user.UserFixtureBase) -> tests.conftest.LoggedClient" - ], - "samples": 7 - }, - { - "path": "tests/test_workflow.py", - "line": 8, - "func_name": "test_workflow_read", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 42, - "func_name": "test_workflow_publish", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 67, - "func_name": "test_query_filter", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 98, - "func_name": "test_invalid_workflow_input", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 115, - "func_name": "test_state_change", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], werkzeug.local.LocalProxy, method, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 123, - "func_name": "test_set_workflow", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, method, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 142, - "func_name": "test_state_change_entrypoint_hookup", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], werkzeug.local.LocalProxy, method, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow.py", - "line": 150, - "func_name": "test_set_workflow_entrypoint_hookup", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, method, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_field.py", - "line": 4, - "func_name": "test_workflow_read", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, Dict[str, Dict[str, str]], werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 15, - "func_name": "TestRecipient.reference_receivers", - "type_comments": [ - "(thesis.thesis.records.api.ThesisRecord, None) -> List[Dict[str, str]]" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 21, - "func_name": "NullRecipient.reference_receivers", - "type_comments": [ - "(thesis.thesis.records.api.ThesisRecord, None) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 25, - "func_name": "test_workflow_requests", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 34, - "func_name": "test_request_policy_access", - "type_comments": [ - "(invenio_app.factory:flask.Flask) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 39, - "func_name": "test_is_applicable", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 54, - "func_name": "test_list_applicable_requests", - "type_comments": [ - "(List[pytest_invenio.user.UserFixtureBase], function, werkzeug.local.LocalProxy, werkzeug.local.LocalProxy) -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "tests/test_workflow_requests.py", - "line": 55, - "func_name": "R", - "type_comments": [ - "() -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 1 - }, - { - "path": "thesis/thesis/resources/records/config.py", - "line": 14, - "func_name": "response_handlers", - "type_comments": [ - "() -> Dict[str, flask_resources.responses.ResponseHandler]" - ], - "samples": 5 - }, - { - "path": "thesis/thesis/resources/records/ui.py", - "line": 12, - "func_name": "ThesisUIJSONSerializer.__init__", - "type_comments": [ - "() -> pyannotate_runtime.collect_types.NoReturnType" - ], - "samples": 5 - } -] \ No newline at end of file diff --git a/oarepo_workflows/ext.py b/oarepo_workflows/ext.py index 66f306f..813ebbb 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -218,23 +218,29 @@ def get_workflow(self, record: Record | dict) -> Workflow: try: parent = 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 + raise MissingWorkflowError( + "Record does not have a parent attribute, is it a draft-enabled record?", + record=record, + ) from e try: workflow_id = parent.workflow except AttributeError as e: - raise MissingWorkflowError("Parent record does not have a workflow attribute.", - record=record) from e + raise MissingWorkflowError( + "Parent record does not have a workflow attribute.", record=record + ) from e else: try: parent = record["parent"] except KeyError as e: - raise MissingWorkflowError("Record does not have a parent attribute.", - record=record) from e + raise MissingWorkflowError( + "Record does not have a parent attribute.", record=record + ) from e try: workflow_id = parent["workflow"] except KeyError as e: - raise MissingWorkflowError("Parent record does not have a workflow attribute.", record=record) from e + raise MissingWorkflowError( + "Parent record does not have a workflow attribute.", record=record + ) from e try: return self.record_workflows[workflow_id] diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index aadfc23..948fc54 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -77,7 +77,9 @@ def recipient_entity_reference(self, **context: Any) -> dict | None: """ return RecipientEntityReference(self, **context) - def is_applicable(self, identity: Identity, *, record: Record, **context: Any) -> bool: + 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. @@ -87,11 +89,15 @@ def is_applicable(self, identity: Identity, *, record: Record, **context: Any) - p = Permission(*self.requester_generator.needs(record=record, **context)) if not p.needs: return False - p.excludes.update(self.requester_generator.excludes(record=record, **context)) + p.excludes.update( + self.requester_generator.excludes(record=record, **context) + ) if not p.allows(identity): return False if hasattr(self.request_type, "is_applicable_to"): - return self.request_type.is_applicable_to(identity, topic=record, **context) + return self.request_type.is_applicable_to( + identity, topic=record, **context + ) return True except InvalidConfigurationError: raise From e655886a0837f231022f0ee3c845b7273232826d Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Thu, 21 Nov 2024 15:32:00 +0100 Subject: [PATCH 18/21] Version bump --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2de0b95..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 <krist@cesnet.cz> readme = README.md From 91a84da833d4125cb6e2baad3b3158a18c5bf877 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Thu, 21 Nov 2024 16:03:44 +0100 Subject: [PATCH 19/21] Fixed double permission check --- oarepo_workflows/requests/requests.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/oarepo_workflows/requests/requests.py b/oarepo_workflows/requests/requests.py index 948fc54..5e86a69 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Optional from flask_principal import Identity, Permission -from invenio_requests.proxies import current_request_type_registry +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 @@ -86,19 +86,15 @@ def is_applicable( :param context: Context of the request that is passed to the requester generators. """ try: - p = Permission(*self.requester_generator.needs(record=record, **context)) - if not p.needs: - return False - p.excludes.update( - self.requester_generator.excludes(record=record, **context) - ) - if not p.allows(identity): - return False 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 ) - return True + else: + return current_requests_service.check_permission( + identity, "create", record=record, request_type=self.request_type, **context + ) except InvalidConfigurationError: raise except Exception as e: From c86ed15fbeda93c520718a89e9c9a1a2a605e07e Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 22 Nov 2024 07:21:10 +0100 Subject: [PATCH 20/21] Moved the whole workflow request policy from oarepo-requests here --- oarepo_workflows/__init__.py | 4 +- oarepo_workflows/errors.py | 29 +++ oarepo_workflows/ext.py | 54 +++++- oarepo_workflows/ext_config.py | 2 + .../records/systemfields/workflow.py | 14 ++ oarepo_workflows/requests/__init__.py | 7 +- oarepo_workflows/requests/events.py | 2 +- .../requests/generators/__init__.py | 25 +++ .../{generators.py => generators/auto.py} | 78 +------- .../requests/generators/conditionals.py | 58 ++++++ .../requests/generators/multiple.py | 66 +++++++ .../generators/recipient_generator.py | 37 ++++ .../requests/generators/workflow_based.py | 173 ++++++++++++++++++ oarepo_workflows/requests/permissions.py | 43 +++++ oarepo_workflows/requests/requests.py | 17 +- .../services/permissions/generators.py | 4 +- tests/conftest.py | 9 + tests/test_auto_approve.py | 2 +- tests/test_auto_request.py | 4 +- tests/test_workflow_requests.py | 22 ++- 20 files changed, 545 insertions(+), 105 deletions(-) create mode 100644 oarepo_workflows/requests/generators/__init__.py rename oarepo_workflows/requests/{generators.py => generators/auto.py} (50%) create mode 100644 oarepo_workflows/requests/generators/conditionals.py create mode 100644 oarepo_workflows/requests/generators/multiple.py create mode 100644 oarepo_workflows/requests/generators/recipient_generator.py create mode 100644 oarepo_workflows/requests/generators/workflow_based.py create mode 100644 oarepo_workflows/requests/permissions.py diff --git a/oarepo_workflows/__init__.py b/oarepo_workflows/__init__.py index 7f36821..7c5aaf6 100644 --- a/oarepo_workflows/__init__.py +++ b/oarepo_workflows/__init__.py @@ -32,10 +32,10 @@ "WorkflowPermission", "WorkflowRecordPermissionPolicy", "WorkflowRequestPolicy", - "AutoApprove", - "AutoRequest", "WorkflowRequest", "WorkflowRequestEscalation", "WorkflowTransitions", "FromRecordWorkflow", + "AutoApprove", + "AutoRequest", ) diff --git a/oarepo_workflows/errors.py b/oarepo_workflows/errors.py index 8de5452..bf54d07 100644 --- a/oarepo_workflows/errors.py +++ b/oarepo_workflows/errors.py @@ -76,3 +76,32 @@ def __init__( 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 813ebbb..06c9de9 100644 --- a/oarepo_workflows/ext.py +++ b/oarepo_workflows/ext.py @@ -14,7 +14,6 @@ import importlib_metadata from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp -from invenio_records_resources.records import Record from invenio_records_resources.services.uow import RecordCommitOp, unit_of_work from oarepo_runtime.datastreams.utils import get_record_service_for_record @@ -28,7 +27,7 @@ if TYPE_CHECKING: from flask import Flask from flask_principal import Identity - from invenio_drafts_resources.records import ParentRecord + from invenio_drafts_resources.records import ParentRecord, Record from invenio_records_resources.services.uow import UnitOfWork from oarepo_workflows.base import ( @@ -36,6 +35,7 @@ Workflow, WorkflowChangeNotifier, ) + from oarepo_workflows.records.systemfields.workflow import WithWorkflow class OARepoWorkflows: @@ -71,6 +71,11 @@ def init_config(self, app: Flask) -> None: 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 @@ -214,29 +219,29 @@ def get_workflow(self, record: Record | dict) -> Workflow: :raises MissingWorkflowError: if the workflow is not found :raises InvalidWorkflowError: if the workflow is invalid """ - if isinstance(record, Record): + if hasattr(record, "parent"): try: - parent = record.parent # noqa for typing: we do not have a better type for record with parent + 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 = parent.workflow + 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: - parent = record["parent"] + 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 = parent["workflow"] + workflow_id = dict_parent["workflow"] except KeyError as e: raise MissingWorkflowError( "Parent record does not have a workflow attribute.", record=record @@ -246,7 +251,7 @@ def get_workflow(self, record: Record | dict) -> Workflow: return self.record_workflows[workflow_id] except KeyError as e: raise InvalidWorkflowError( - f"Workflow {parent.workflow} doesn't exist in the configuration.", + f"Workflow {workflow_id} doesn't exist in the configuration.", record=record, ) from e @@ -273,3 +278,36 @@ def finalize_app(app: Flask) -> None: 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 36c1c76..c8296ec 100644 --- a/oarepo_workflows/ext_config.py +++ b/oarepo_workflows/ext_config.py @@ -25,3 +25,5 @@ WORKFLOWS: dict[str, Workflow] = {} """Configuration of workflows, must be provided by the user inside, for example, invenio.cfg.""" + +OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS = True diff --git a/oarepo_workflows/records/systemfields/workflow.py b/oarepo_workflows/records/systemfields/workflow.py index 88606f8..3018b10 100644 --- a/oarepo_workflows/records/systemfields/workflow.py +++ b/oarepo_workflows/records/systemfields/workflow.py @@ -9,10 +9,24 @@ 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.""" diff --git a/oarepo_workflows/requests/__init__.py b/oarepo_workflows/requests/__init__.py index 296bab3..925d14d 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -9,7 +9,7 @@ from __future__ import annotations -from .generators import AutoApprove, AutoRequest, RecipientGeneratorMixin +from .generators import AutoApprove, AutoRequest from .policy import ( WorkflowRequestPolicy, ) @@ -22,11 +22,10 @@ __all__ = ( "WorkflowRequestPolicy", - "AutoApprove", - "AutoRequest", - "RecipientGeneratorMixin", "RecipientEntityReference", "WorkflowRequest", "WorkflowRequestEscalation", "WorkflowTransitions", + "AutoApprove", + "AutoRequest", ) diff --git a/oarepo_workflows/requests/events.py b/oarepo_workflows/requests/events.py index 012bc5e..ff64c18 100644 --- a/oarepo_workflows/requests/events.py +++ b/oarepo_workflows/requests/events.py @@ -13,7 +13,7 @@ from functools import cached_property from typing import TYPE_CHECKING -from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator +from oarepo_workflows.requests.generators.multiple import MultipleGeneratorsGenerator if TYPE_CHECKING: from invenio_records_permissions.generators import Generator 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.py b/oarepo_workflows/requests/generators/auto.py similarity index 50% rename from oarepo_workflows/requests/generators.py rename to oarepo_workflows/requests/generators/auto.py index b1b57d6..8b73e59 100644 --- a/oarepo_workflows/requests/generators.py +++ b/oarepo_workflows/requests/generators/auto.py @@ -5,70 +5,21 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # -"""Need generators.""" +"""Auto request and auto approve generators.""" from __future__ import annotations -import dataclasses -from collections.abc import Iterable 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.request_types import RequestType - - -@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 - + from invenio_requests.customizations import RequestType auto_request_need = SystemRoleNeed("auto_request") auto_approve_need = SystemRoleNeed("auto_approve") @@ -86,27 +37,6 @@ def needs(self, **context: Any) -> list[Need]: return [auto_request_need] -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") - - class AutoApprove(RecipientGeneratorMixin, Generator): """Auto approve generator. 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/requests.py b/oarepo_workflows/requests/requests.py index 5e86a69..a2336fb 100644 --- a/oarepo_workflows/requests/requests.py +++ b/oarepo_workflows/requests/requests.py @@ -14,17 +14,20 @@ from logging import getLogger from typing import TYPE_CHECKING, Any, Optional -from flask_principal import Identity, Permission -from invenio_requests.proxies import current_request_type_registry, current_requests_service +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 import RecipientGeneratorMixin -from oarepo_workflows.requests.generators import MultipleGeneratorsGenerator +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 @@ -93,7 +96,11 @@ def is_applicable( ) else: return current_requests_service.check_permission( - identity, "create", record=record, request_type=self.request_type, **context + identity, + "create", + record=record, + request_type=self.request_type, + **context, ) except InvalidConfigurationError: raise diff --git a/oarepo_workflows/services/permissions/generators.py b/oarepo_workflows/services/permissions/generators.py index 48809dd..03d57e3 100644 --- a/oarepo_workflows/services/permissions/generators.py +++ b/oarepo_workflows/services/permissions/generators.py @@ -102,13 +102,13 @@ def _get_permissions_from_workflow( policy = current_oarepo_workflows.record_workflows[workflow_id].permissions return policy(action_name, record=record, **context) - def needs(self, record: Optional[Record] = None, **context: Any) -> set[Need]: + 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) -> set[Need]: + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 9c78d3e..12d5446 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,10 @@ class MyWorkflowRequests(WorkflowRequestPolicy): ) +class IsApplicableTestRequestPolicy(WorkflowRequestPolicy): + req = WorkflowRequest(requesters=[RecordOwners()], recipients=[]) + + WORKFLOWS = { "my_workflow": Workflow( label=_("Default workflow"), @@ -68,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, + ), } diff --git a/tests/test_auto_approve.py b/tests/test_auto_approve.py index c56b132..3a3f698 100644 --- a/tests/test_auto_approve.py +++ b/tests/test_auto_approve.py @@ -5,7 +5,7 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # -from oarepo_workflows.requests.generators import AutoApprove +from oarepo_workflows.requests import AutoApprove import pytest from invenio_requests.resolvers.registry import ResolverRegistry diff --git a/tests/test_auto_request.py b/tests/test_auto_request.py index be7a57d..02036af 100644 --- a/tests/test_auto_request.py +++ b/tests/test_auto_request.py @@ -5,8 +5,8 @@ # modify it under the terms of the MIT License; see LICENSE file for more # details. # -from oarepo_workflows import AutoRequest -from oarepo_workflows.requests.generators import auto_request_need +from oarepo_workflows.requests import AutoRequest +from oarepo_workflows.requests.generators.auto import auto_request_need def test_auto_request_needs(app): diff --git a/tests/test_workflow_requests.py b/tests/test_workflow_requests.py index 5dab92e..0fc056d 100644 --- a/tests/test_workflow_requests.py +++ b/tests/test_workflow_requests.py @@ -11,7 +11,8 @@ from oarepo_runtime.services.permissions import RecordOwners, UserWithRole from oarepo_workflows import WorkflowRequestPolicy, WorkflowTransitions -from oarepo_workflows.requests import RecipientGeneratorMixin, WorkflowRequest +from oarepo_workflows.requests import WorkflowRequest +from oarepo_workflows.requests.generators import RecipientGeneratorMixin from thesis.records.api import ThesisRecord from flask_principal import Identity, UserNeed, RoleNeed @@ -114,7 +115,11 @@ def test_is_applicable( id2 = Identity(id=2) id2.provides.add(UserNeed(2)) - record = SimpleNamespace(parent=SimpleNamespace(owners=[SimpleNamespace(id=1)])) + 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 @@ -131,15 +136,20 @@ def test_list_applicable_requests( id2.provides.add(UserNeed(2)) id2.provides.add(RoleNeed("administrator")) - record = SimpleNamespace(parent=SimpleNamespace(owners=[SimpleNamespace(id=1)])) + 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) - ) == {"req1"} + assert ( + set(x[0] for x in requests.applicable_workflow_requests(id2, record=record)) + == set() + ) def test_get_workflow_request_via_index( From 2c96313c9982be99f64fe0cb25c4cdea5f80d796 Mon Sep 17 00:00:00 2001 From: Mirek Simek <miroslav.simek@gmail.com> Date: Fri, 22 Nov 2024 07:39:39 +0100 Subject: [PATCH 21/21] Alias --- oarepo_workflows/requests/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oarepo_workflows/requests/__init__.py b/oarepo_workflows/requests/__init__.py index 925d14d..dab55e1 100644 --- a/oarepo_workflows/requests/__init__.py +++ b/oarepo_workflows/requests/__init__.py @@ -9,7 +9,7 @@ from __future__ import annotations -from .generators import AutoApprove, AutoRequest +from .generators import AutoApprove, AutoRequest, RecipientGeneratorMixin from .policy import ( WorkflowRequestPolicy, ) @@ -28,4 +28,5 @@ "WorkflowTransitions", "AutoApprove", "AutoRequest", + "RecipientGeneratorMixin", )