Skip to content

Commit

Permalink
Merge pull request #18 from oarepo/miroslavsimek/be-546-create-multip…
Browse files Browse the repository at this point in the history
…lerecipients-entity-reference-and-use-it-inside

Support for multiple entities inside a single entity reference
  • Loading branch information
mesemus authored Dec 10, 2024
2 parents 33da3d8 + 261121c commit eda3a54
Show file tree
Hide file tree
Showing 16 changed files with 360 additions and 33 deletions.
22 changes: 19 additions & 3 deletions oarepo_workflows/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
AutoApproveEntityService,
AutoApproveEntityServiceConfig,
)
from oarepo_workflows.services.multiple_entities import (
MultipleEntitiesEntityService,
MultipleEntitiesEntityServiceConfig,
)

if TYPE_CHECKING:
from flask import Flask
Expand Down Expand Up @@ -76,12 +80,19 @@ def init_config(self, app: Flask) -> None:
ext_config.OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS,
)

app.config.setdefault("REQUESTS_ALLOWED_RECEIVERS", []).extend(
ext_config.WORKFLOWS_ALLOWED_REQUEST_RECEIVERS
)

def init_services(self) -> None:
"""Initialize workflow services."""
# noinspection PyAttributeOutsideInit
self.auto_approve_service = AutoApproveEntityService(
config=AutoApproveEntityServiceConfig()
)
self.multiple_recipients_service = MultipleEntitiesEntityService(
config=MultipleEntitiesEntityServiceConfig()
)

@cached_property
def state_changed_notifiers(self) -> list[StateChangedNotifier]:
Expand Down Expand Up @@ -212,7 +223,7 @@ def default_workflow_events(self) -> dict:
"""
return self.app.config.get("DEFAULT_WORKFLOW_EVENTS", {})

def get_workflow(self, record: Record | dict) -> Workflow:
def get_workflow(self, record: Record | dict[str, Any]) -> Workflow:
"""Get the workflow for a record.
:param record: record to get the workflow for
Expand All @@ -235,7 +246,7 @@ def get_workflow(self, record: Record | dict) -> Workflow:
) from e
else:
try:
dict_parent: dict = record["parent"]
dict_parent: dict[str, Any] = record["parent"]
except KeyError as e:
raise MissingWorkflowError(
"Record does not have a parent attribute.", record=record
Expand Down Expand Up @@ -272,13 +283,18 @@ def finalize_app(app: Flask) -> None:
"""
records_resources = app.extensions["invenio-records-resources"]

ext = app.extensions["oarepo-workflows"]
ext: OARepoWorkflows = app.extensions["oarepo-workflows"]

records_resources.registry.register(
ext.auto_approve_service,
service_id=ext.auto_approve_service.config.service_id,
)

records_resources.registry.register(
ext.multiple_recipients_service,
service_id=ext.multiple_recipients_service.config.service_id,
)

if app.config["OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS"]:
patch_request_permissions(app)

Expand Down
2 changes: 2 additions & 0 deletions oarepo_workflows/ext_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@
"""Configuration of workflows, must be provided by the user inside, for example, invenio.cfg."""

OAREPO_WORKFLOWS_SET_REQUEST_PERMISSIONS = True

WORKFLOWS_ALLOWED_REQUEST_RECEIVERS = ["multiple"]
2 changes: 2 additions & 0 deletions oarepo_workflows/records/systemfields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#
"""Record layer, system fields."""

from __future__ import annotations

from .state import RecordStateField, RecordStateTimestampField
from .workflow import WorkflowField

Expand Down
6 changes: 4 additions & 2 deletions oarepo_workflows/requests/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from functools import cached_property
from typing import TYPE_CHECKING

from oarepo_workflows.requests.generators.multiple import MultipleGeneratorsGenerator
from oarepo_workflows.requests.generators.multiple_entities import (
MultipleEntitiesGenerator,
)

if TYPE_CHECKING:
from invenio_records_permissions.generators import Generator
Expand All @@ -33,4 +35,4 @@ class WorkflowEvent:
@cached_property
def submitter_generator(self) -> Generator:
"""Return the requesters as a single requester generator."""
return MultipleGeneratorsGenerator(self.submitters)
return MultipleEntitiesGenerator(self.submitters)
6 changes: 4 additions & 2 deletions oarepo_workflows/requests/generators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
#
"""Need generators."""

from __future__ import annotations

from .auto import AutoApprove, AutoRequest, auto_approve_need, auto_request_need
from .conditionals import IfEventType, IfRequestType, IfRequestTypeBase
from .multiple import MultipleGeneratorsGenerator
from .multiple_entities import MultipleEntitiesGenerator
from .recipient_generator import RecipientGeneratorMixin

__all__ = (
Expand All @@ -20,6 +22,6 @@
"IfEventType",
"IfRequestType",
"IfRequestTypeBase",
"MultipleGeneratorsGenerator",
"MultipleEntitiesGenerator",
"RecipientGeneratorMixin",
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,28 @@
from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING, Any, Iterable
import json
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, Optional, override

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.api import Record
from invenio_requests.customizations.request_types import RequestType


@dataclasses.dataclass
class MultipleGeneratorsGenerator(Generator):
class MultipleEntitiesGenerator(RecipientGeneratorMixin, Generator):
"""A generator that combines multiple generators with 'or' operation."""

generators: list[Generator] | tuple[Generator]
"""List of generators to be combined."""

@override
def needs(self, **context: Any) -> set[Need]:
"""Generate a set of needs from generators that a person needs to have.
Expand All @@ -35,6 +42,7 @@ def needs(self, **context: Any) -> set[Need]:
need for generator in self.generators for need in generator.needs(**context)
}

@override
def excludes(self, **context: Any) -> set[Need]:
"""Generate a set of needs that person must not have.
Expand All @@ -47,6 +55,7 @@ def excludes(self, **context: Any) -> set[Need]:
for exclude in generator.excludes(**context)
}

@override
def query_filter(self, **context: Any) -> list[dict]:
"""Generate a list of opensearch query filters.
Expand All @@ -64,3 +73,38 @@ def query_filter(self, **context: Any) -> list[dict]:
else:
ret.append(query_filter)
return ret

@override
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.
"""
references = []

for generator in self.generators:
if not isinstance(generator, RecipientGeneratorMixin):
raise ValueError(
f"Generator {generator} is not a recipient generator and can not be used in "
f"MultipleGeneratorsGenerator."
)

reference = generator.reference_receivers(record, request_type, **context)
if reference:
references.extend(reference)
if not references:
return []
if len(references) == 1:
return references

return [{"multiple": json.dumps(references)}]
2 changes: 2 additions & 0 deletions oarepo_workflows/requests/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#
"""Base permission policy that overwrites invenio-requests."""

from __future__ import annotations

from invenio_records_permissions.generators import SystemProcess
from invenio_requests.customizations.event_types import CommentEventType, LogEventType
from invenio_requests.services.generators import Creator, Receiver
Expand Down
23 changes: 7 additions & 16 deletions oarepo_workflows/requests/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@

from oarepo_workflows.errors import InvalidConfigurationError
from oarepo_workflows.proxies import current_oarepo_workflows
from oarepo_workflows.requests.generators import RecipientGeneratorMixin
from oarepo_workflows.requests.generators.multiple import MultipleGeneratorsGenerator
from oarepo_workflows.requests.generators.multiple_entities import (
MultipleEntitiesGenerator,
)

if TYPE_CHECKING:
from datetime import timedelta
Expand Down Expand Up @@ -68,7 +69,7 @@ class WorkflowRequest:
@cached_property
def requester_generator(self) -> Generator:
"""Return the requesters as a single requester generator."""
return MultipleGeneratorsGenerator(self.requesters)
return MultipleEntitiesGenerator(self.requesters)

def recipient_entity_reference(self, **context: Any) -> dict | None:
"""Return the reference receiver of the workflow request with the given context.
Expand Down Expand Up @@ -183,16 +184,6 @@ def RecipientEntityReference(request: WorkflowRequest, **context: Any) -> 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
generator = MultipleEntitiesGenerator(request.recipients)
receivers = generator.reference_receivers(**context)
return receivers[0] if receivers else None
86 changes: 86 additions & 0 deletions oarepo_workflows/resolvers/multiple_entities/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#
# 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.
#
"""Multiple entities entity and resolver."""

from __future__ import annotations

import dataclasses
import json
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_requests.resolvers.registry import ResolverRegistry

if TYPE_CHECKING:
from flask_principal import Identity, Need


@dataclasses.dataclass
class MultipleEntitiesEntity:
"""Entity representing multiple entities."""

entities: list[EntityProxy]


class MultipleEntitiesProxy(EntityProxy):
"""Proxy for multiple-entities entity."""

def _resolve(self) -> MultipleEntitiesEntity:
"""Resolve the entity reference into entity."""
values = json.loads(self._parse_ref_dict_id())
return MultipleEntitiesEntity(
entities=[
ResolverRegistry.resolve_entity_proxy(ref, raise_=True) # type: ignore
for ref in values
]
)

def get_needs(self, ctx: dict | None = None) -> list[Need]:
"""Get needs that the entity generate."""
ret = []
for entity in self._resolve().entities:
ret.extend(entity.get_needs(ctx) or [])
return ret

def pick_resolved_fields(self, identity: Identity, resolved_dict: dict) -> dict:
"""Pick resolved fields for serialization of the entity to json."""
return {"multiple": resolved_dict["id"]}


class MultipleEntitiesResolver(EntityResolver):
"""A resolver that resolves multiple entities entity."""

type_id = "multiple"

def __init__(self) -> None:
"""Initialize the resolver."""
super().__init__("multiple")

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: MultipleEntitiesEntity) -> dict[str, str]:
"""Return a reference dictionary for the entity."""
print("!!!! multiple_entities/__init__.py _reference_entity", entity.entities)
return {
self.type_id: json.dumps([part.reference_dict for part in entity.entities])
}

def matches_entity(self, entity: Any) -> bool:
"""Check if the entity can be serialized to a reference by this resolver."""
return isinstance(entity, MultipleEntitiesEntity)

def _get_entity_proxy(self, ref_dict: dict) -> MultipleEntitiesProxy:
"""Get the entity proxy for the reference dictionary.
Note: the proxy is returned to ensure the entity is loaded lazily, when needed.
:param ref_dict: Reference dictionary.
"""
return MultipleEntitiesProxy(self, ref_dict)
2 changes: 1 addition & 1 deletion oarepo_workflows/services/components/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class WorkflowComponent(ServiceComponent):
def create(
self,
identity: Identity,
data: Optional[dict] = None,
data: Optional[dict[str, Any]] = None,
record: Optional[Record] = None,
**kwargs: Any,
) -> None:
Expand Down
Loading

0 comments on commit eda3a54

Please sign in to comment.