Skip to content

Commit

Permalink
Moved AutoApprove to workflows
Browse files Browse the repository at this point in the history
  • Loading branch information
mesemus committed Nov 15, 2024
1 parent 1f46660 commit bf5f7d7
Show file tree
Hide file tree
Showing 13 changed files with 356 additions and 52 deletions.
14 changes: 12 additions & 2 deletions oarepo_workflows/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

19 changes: 19 additions & 0 deletions oarepo_workflows/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""
Expand All @@ -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"
Expand Down Expand Up @@ -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,
)

8 changes: 8 additions & 0 deletions oarepo_workflows/requests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
WorkflowRequestEscalation,
WorkflowRequestPolicy,
WorkflowTransitions,
AutoApprove,
AutoRequest,
RequesterGenerator,
RecipientEntityReference
)

__all__ = (
Expand All @@ -12,4 +16,8 @@
"WorkflowTransitions",
"RecipientGeneratorMixin",
"WorkflowRequestEscalation",
"AutoApprove",
"AutoRequest",
"RequesterGenerator",
"RecipientEntityReference"
)
224 changes: 176 additions & 48 deletions oarepo_workflows/requests/policy.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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
Empty file.
42 changes: 42 additions & 0 deletions oarepo_workflows/resolvers/auto_approve/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit bf5f7d7

Please sign in to comment.