dspace-api-toolkit 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dspace_api/workflow.py ADDED
@@ -0,0 +1,893 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Review process (configurable workflow) for DSpace API (DSpace 7+ / DSpace-CRIS)
4
+
5
+ A deposit into a collection with workflow groups becomes a workflow item.
6
+ Each step (reviewstep, editstep, finaleditstep by default) offers a *pool
7
+ task* to the members of the step's group; a member *claims* it (claimed
8
+ task) and performs an option: approve, reject (back to the submitter with a
9
+ reason), edit metadata, ... Supervision orders give a group access to an
10
+ in-progress submission.
11
+
12
+ GET|DELETE /workflow/workflowitems[/{id}] DELETE: back to workspace, ?expunge=true: delete
13
+ GET /workflow/workflowitems/{id}/item|collection|submissionDefinition|step
14
+ GET /workflow/workflowitems/search/findBySubmitter?uuid= /search/item?uuid=
15
+ PATCH /workflow/workflowitems/{id} section PATCH (edit_metadata option)
16
+ GET /workflow/pooltasks/{id}[/workflowitem|eperson|group|step|action]
17
+ GET /workflow/pooltasks/search/findByUser|findAllByItem|findByItem?uuid=
18
+ POST /workflow/claimedtasks text/uri-list: claim a pool task
19
+ GET /workflow/claimedtasks/{id}[/workflowitem|owner|step|action]
20
+ GET /workflow/claimedtasks/search/findByUser|findAllByItem|findByItem?uuid=
21
+ POST /workflow/claimedtasks/{id} form data: submit_approve, submit_reject&reason, ...
22
+ DELETE /workflow/claimedtasks/{id} unclaim (back to the pool)
23
+ GET /config/workflowdefinitions[/{name}[/collections|/steps]] /search/findByCollection?uuid=
24
+ GET /config/workflowsteps/{name} /config/workflowactions/{name}
25
+ GET|POST|DELETE /core/supervisionorders[/{id}] /search/byItem?uuid=
26
+
27
+ wf = client.workflow
28
+ for task in wf.pending_tasks(): # what is waiting for me
29
+ print(task["kind"], task["title"], task["step"], task["options"])
30
+ wf.review(item_uuid, "approve") # claim if needed, then approve
31
+ wf.review(item_uuid, "reject", reason="Please add an abstract")
32
+ wf.approve_all_steps(item_uuid) # walk through every step (bulk approval)
33
+
34
+ Claiming needs membership in the step's group (collection role
35
+ workflowGroups/<role>); being an administrator alone is not enough.
36
+ """
37
+
38
+ import logging
39
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
40
+
41
+ from ._base import BaseManager
42
+ from .exceptions import DSpaceError
43
+ from .metadata import MetadataPatch, as_operations
44
+ from .projection import EmbedArg
45
+
46
+ # Logger for the module
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # Workflow API endpoints and related constants
50
+ WORKFLOW_ITEMS = "/workflow/workflowitems"
51
+ POOL_TASKS = "/workflow/pooltasks"
52
+ CLAIMED_TASKS = "/workflow/claimedtasks"
53
+ DEFINITIONS = "/config/workflowdefinitions"
54
+ STEPS = "/config/workflowsteps"
55
+ ACTIONS = "/config/workflowactions"
56
+ SUPERVISION_ORDERS = "/core/supervisionorders"
57
+
58
+ # Links and embed constants for workflow items and tasks
59
+ POOL_TASK_LINKS = ("workflowitem", "eperson", "group", "step", "action")
60
+ CLAIMED_TASK_LINKS = ("workflowitem", "owner", "step", "action")
61
+ WORKFLOW_ITEM_LINKS = ("item", "collection", "submissionDefinition", "step")
62
+ SUPERVISION_TYPES = ("NONE", "EDITOR", "OBSERVER")
63
+ # tasks come with their workflow item and its item (title, uuid) in one request
64
+ TASK_EMBED = ["workflowitem", "workflowitem/item"]
65
+
66
+ # Type alias for references to workflow items, tasks, or other objects
67
+ Ref = Union[str, int, Dict[str, Any]]
68
+
69
+
70
+ class WorkflowError(DSpaceError):
71
+ """A review action could not be performed (no task, not authorized, validation)."""
72
+
73
+
74
+ def submit_param(option: str) -> str:
75
+ """Option name -> request parameter: "approve" -> "submit_approve" (already prefixed names are kept).
76
+
77
+ Parameters:
78
+ option: The option name to convert to a request parameter
79
+
80
+ Returns:
81
+ str: The corresponding request parameter name
82
+ """
83
+ return option if option.startswith("submit_") else f"submit_{option}"
84
+
85
+
86
+ class WorkflowManager(BaseManager):
87
+ """Workflow items, pool / claimed tasks, workflow configuration and supervision orders"""
88
+
89
+ def __init__(self, auth):
90
+ """Initialize the workflow manager with the given authentication context.
91
+
92
+ Parameters:
93
+ auth: The authentication context to use for API requests
94
+
95
+ """
96
+ super().__init__(auth)
97
+ self._epersons = None
98
+ self._action_cache: Dict[str, Dict[str, Any]] = {}
99
+
100
+ @property
101
+ def epersons(self):
102
+ """Get the EPerson manager, initializing it if necessary.
103
+
104
+ Returns:
105
+ EPersonManager: The EPerson manager instance
106
+
107
+ """
108
+ if self._epersons is None:
109
+ from .epersons import EPersonManager
110
+ self._epersons = EPersonManager(self.auth)
111
+ return self._epersons
112
+
113
+ def _id_of(self, ref: Ref) -> str:
114
+ """Get the ID of the given reference, which can be a string, integer, or dictionary containing an 'id' or 'uuid'.
115
+
116
+ Parameters:
117
+ ref: The reference to get the ID from
118
+
119
+ Returns:
120
+ str: The ID of the object
121
+
122
+ """
123
+ if isinstance(ref, dict):
124
+ ident = ref.get("id", ref.get("uuid"))
125
+ if ident is None:
126
+ raise ValueError(f"Object has no id: {ref!r}")
127
+ return str(ident)
128
+ return str(ref)
129
+
130
+ def _uuid(self, ref: Ref, what: str = "object") -> str:
131
+ """Get the UUID of the given reference, which can be a dictionary containing a 'uuid' or an integer (not allowed).
132
+
133
+ Parameters:
134
+ ref: The reference to get the UUID from
135
+ what: A description of the object type for error messages
136
+
137
+ Returns:
138
+ str: The UUID of the object
139
+
140
+ """
141
+ uuid = self._uuid_of(ref) if not isinstance(ref, int) else None
142
+ if not uuid:
143
+ raise ValueError(f"Not a {what} UUID or dict: {ref!r}")
144
+ return uuid
145
+
146
+ def _user_uuid(self, eperson: Optional[Ref]) -> str:
147
+ """Get the UUID of the given EPerson reference, or the current user if None is provided.
148
+
149
+ Parameters:
150
+ eperson: The EPerson reference, or None to use the current user
151
+
152
+ Returns:
153
+ str: The UUID of the EPerson
154
+
155
+ """
156
+ if eperson is not None:
157
+ return self._uuid(eperson, "eperson")
158
+ me = self.epersons.get_current_user()
159
+ if not me:
160
+ raise WorkflowError("Not logged in - pass an eperson")
161
+ return me["uuid"]
162
+
163
+ # ==================================================================
164
+ # Workflow items
165
+ # ==================================================================
166
+
167
+ def iter_workflow_items(self, page_size: Optional[int] = None, embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
168
+ """All workflow items (administrators). A generator.
169
+
170
+ Parameters:
171
+ page_size: The number of items per page for pagination
172
+ embed: The embed parameter to include related entities in the response
173
+
174
+ Yields:
175
+ Dict[str, Any]: A workflow item dictionary
176
+
177
+ """
178
+ return self._iter_embedded(self._url(WORKFLOW_ITEMS), "workflowitems", "List workflow items",
179
+ page_size=page_size, embed=embed)
180
+
181
+ def get_workflow_item(self, workflow_item: Ref, embed: EmbedArg = None) -> Dict[str, Any]:
182
+ """Retrieve a workflow item by its reference.
183
+
184
+ Parameters:
185
+ workflow_item: The reference to the workflow item
186
+ embed: The embed parameter to include related entities in the response
187
+
188
+ Returns:
189
+ Dict[str, Any]: A workflow item dictionary
190
+
191
+ """
192
+
193
+ ident = self._id_of(workflow_item)
194
+ return self._get_json(self._url(WORKFLOW_ITEMS, f"/{ident}"), f"Get workflow item {ident}", embed=embed)
195
+
196
+ def get_workflow_item_link(self, workflow_item: Ref, link: str, all_languages: Optional[bool] = None
197
+ ) -> Optional[Dict[str, Any]]:
198
+ """Linked entity: "item", "collection", "submissionDefinition" or "step".
199
+
200
+ Parameters:
201
+ workflow_item: The reference to the workflow item
202
+ link: The linked entity to retrieve ("item", "collection", "submissionDefinition" or "step")
203
+ all_languages: Whether to include all language variants
204
+
205
+ Returns:
206
+ Optional[Dict[str, Any]]: The linked entity dictionary, or None if not found
207
+
208
+ """
209
+ if link not in WORKFLOW_ITEM_LINKS:
210
+ raise ValueError(f"link must be one of {WORKFLOW_ITEM_LINKS}")
211
+ ident = self._id_of(workflow_item)
212
+ return self._get_optional(self._url(WORKFLOW_ITEMS, f"/{ident}/{link}"),
213
+ f"Get {link} of workflow item {ident}", all_languages=all_languages)
214
+
215
+ def find_by_submitter(self, eperson: Ref, embed: EmbedArg = None) -> List[Dict[str, Any]]:
216
+ """Find workflow items by the submitter.
217
+
218
+ Parameters:
219
+ eperson: The reference to the submitter (EPerson)
220
+ embed: The embed parameter to include related entities in the response
221
+
222
+ Returns:
223
+ List[Dict[str, Any]]: A list of workflow item dictionaries
224
+
225
+ """
226
+ uuid = self._uuid(eperson, "eperson")
227
+ return list(self._iter_embedded(self._url(WORKFLOW_ITEMS, "/search/findBySubmitter"), "workflowitems",
228
+ f"Workflow items of submitter {uuid}", [("uuid", uuid)], embed=embed))
229
+
230
+ def find_by_item(self, item: Ref, embed: EmbedArg = None) -> Optional[Dict[str, Any]]:
231
+ """The workflow item of an item, or None (not in workflow).
232
+
233
+ Parameters:
234
+ item: The reference to the item
235
+ embed: The embed parameter to include related entities in the response
236
+
237
+ Returns:
238
+ Optional[Dict[str, Any]]: The workflow item dictionary, or None if not found
239
+
240
+ """
241
+ uuid = self._uuid(item, "item")
242
+ return self._get_optional(self._url(WORKFLOW_ITEMS, "/search/item"), f"Find workflow item of {uuid}",
243
+ params={"uuid": uuid}, embed=embed)
244
+
245
+ def return_to_workspace(self, workflow_item: Ref) -> bool:
246
+ """Stop the workflow and send the submission back to the submitter's workspace (administrators)."""
247
+ ident = self._id_of(workflow_item)
248
+ self._request("DELETE", self._url(WORKFLOW_ITEMS, f"/{ident}"), f"Return workflow item {ident} to workspace",
249
+ expected=(204,))
250
+ logger.info("Workflow item %s returned to the workspace", ident)
251
+ return True
252
+
253
+ def delete_workflow_item(self, workflow_item: Ref) -> bool:
254
+ """Delete the submission while in workflow (?expunge=true, administrators).
255
+
256
+ Parameters:
257
+ workflow_item: The reference to the workflow item to delete
258
+
259
+ Returns:
260
+ bool: True if the workflow item was successfully deleted
261
+
262
+ """
263
+ ident = self._id_of(workflow_item)
264
+ self._request("DELETE", self._url(WORKFLOW_ITEMS, f"/{ident}"), f"Delete workflow item {ident}",
265
+ expected=(204,), params={"expunge": "true"})
266
+ logger.info("Workflow item %s deleted", ident)
267
+ return True
268
+
269
+ def patch_workflow_item(self, workflow_item: Ref,
270
+ operations: Union[MetadataPatch, Iterable[Dict[str, Any]]]) -> Dict[str, Any]:
271
+ """
272
+ PATCH the sections of a workflow item (needs a claimed task whose
273
+ action offers "edit_metadata"). Same operations as for workspace items.
274
+
275
+ Parameters:
276
+ workflow_item: The reference to the workflow item to patch
277
+ operations: The metadata patch operations to apply
278
+
279
+ Returns:
280
+ Dict[str, Any]: The updated workflow item dictionary
281
+
282
+ """
283
+ ops = as_operations(operations)
284
+ if not ops:
285
+ raise ValueError("Empty patch")
286
+ ident = self._id_of(workflow_item)
287
+ return self._request("PATCH", self._url(WORKFLOW_ITEMS, f"/{ident}"), f"Patch workflow item {ident}",
288
+ headers=self._headers("application/json-patch+json"), json=ops).json()
289
+
290
+ def patch_section_metadata(self, workflow_item: Ref, section: str,
291
+ metadata: Union[MetadataPatch, Dict[str, Any]]) -> Dict[str, Any]:
292
+ """Patch the metadata of a specific section of a workflow item.
293
+
294
+ The metadata can be provided as a dictionary of {field: values} (each replaces the field) or a MetadataPatch.for_section(section).
295
+
296
+ Parameters:
297
+ workflow_item: The reference to the workflow item to patch
298
+ section: The section of the workflow item to patch
299
+ metadata: The metadata patch or dictionary of field values
300
+
301
+ Returns:
302
+ Dict[str, Any]: The updated workflow item dictionary
303
+
304
+ """
305
+ if isinstance(metadata, dict):
306
+ patch = MetadataPatch.for_section(section)
307
+ for field, values in metadata.items():
308
+ patch.set(field, values)
309
+ metadata = patch
310
+ return self.patch_workflow_item(workflow_item, metadata)
311
+
312
+ # ==================================================================
313
+ # Tasks
314
+ # ==================================================================
315
+
316
+ def _task_search(self, base: str, key: str, method: str, uuid: str) -> List[Dict[str, Any]]:
317
+ """Search for tasks based on a specific method and UUID.
318
+
319
+ Parameters:
320
+ base: The base URL for the task type
321
+ key: The key to identify the tasks in the response
322
+ method: The search method to use
323
+ uuid: The UUID to search for
324
+
325
+ Returns:
326
+ List[Dict[str, Any]]: A list of task dictionaries
327
+
328
+ """
329
+ return list(self._iter_embedded(self._url(base, f"/search/{method}"), key, f"{key} {method} {uuid}",
330
+ [("uuid", uuid)], embed=TASK_EMBED))
331
+
332
+ def _task_link(self, base: str, links: Sequence[str], task: Ref, link: str) -> Optional[Dict[str, Any]]:
333
+ """Retrieve a linked entity of a task.
334
+
335
+ Parameters:
336
+ base: The base URL for the task type
337
+ links: The valid links for the task type
338
+ task: The reference to the task
339
+ link: The linked entity to retrieve
340
+
341
+ Returns:
342
+ Optional[Dict[str, Any]]: The linked entity dictionary, or None if not found
343
+
344
+ """
345
+ if link not in links:
346
+ raise ValueError(f"link must be one of {tuple(links)}")
347
+ ident = self._id_of(task)
348
+ return self._get_optional(self._url(base, f"/{ident}/{link}"), f"Get {link} of task {ident}")
349
+
350
+ # -- pool tasks --------------------------------------------------------
351
+
352
+ def get_pool_task(self, task: Ref, embed: EmbedArg = None) -> Dict[str, Any]:
353
+ """Retrieve a pool task by its reference.
354
+
355
+ Parameters:
356
+ task: The reference to the pool task
357
+ embed: Optional embedding instructions for the response
358
+
359
+ Returns:
360
+ Dict[str, Any]: The pool task dictionary
361
+
362
+ """
363
+ ident = self._id_of(task)
364
+ return self._get_json(self._url(POOL_TASKS, f"/{ident}"), f"Get pool task {ident}", embed=embed)
365
+
366
+ def get_pool_task_link(self, task: Ref, link: str) -> Optional[Dict[str, Any]]:
367
+ """Retrieve a linked entity of a pool task.
368
+
369
+ The link can be one of "workflowitem", "eperson", "group", "step" or "action".
370
+
371
+ Parameters:
372
+ task: The reference to the pool task
373
+ link: The linked entity to retrieve
374
+
375
+ Returns:
376
+ Optional[Dict[str, Any]]: The linked entity dictionary, or None if not found
377
+
378
+ """
379
+ return self._task_link(POOL_TASKS, POOL_TASK_LINKS, task, link)
380
+
381
+ def pool_tasks_for_user(self, eperson: Optional[Ref] = None) -> List[Dict[str, Any]]:
382
+ """Tasks the user can claim (default: the current user).
383
+
384
+ Parameters:
385
+ eperson: Optional reference to the user (default: the current user)
386
+
387
+ Returns:
388
+ List[Dict[str, Any]]: A list of pool task dictionaries
389
+
390
+ """
391
+ return self._task_search(POOL_TASKS, "pooltasks", "findByUser", self._user_uuid(eperson))
392
+
393
+ def pool_tasks_for_item(self, item: Ref) -> List[Dict[str, Any]]:
394
+ """All pool tasks of an item, for any user (administrators).
395
+
396
+ Parameters:
397
+ item: The reference to the item
398
+
399
+ Returns:
400
+ List[Dict[str, Any]]: A list of pool task dictionaries
401
+
402
+ """
403
+ return self._task_search(POOL_TASKS, "pooltasks", "findAllByItem", self._uuid(item, "item"))
404
+
405
+ def find_pool_task(self, item: Ref) -> Optional[Dict[str, Any]]:
406
+ """The pool task of an item the current user may claim, or None.
407
+
408
+ Parameters:
409
+ item: The reference to the item
410
+
411
+ Returns:
412
+ Optional[Dict[str, Any]]: The pool task dictionary, or None if not found
413
+
414
+ """
415
+ uuid = self._uuid(item, "item")
416
+ return self._get_optional(self._url(POOL_TASKS, "/search/findByItem"), f"Find pool task of {uuid}",
417
+ params={"uuid": uuid}, embed=TASK_EMBED)
418
+
419
+ def claim(self, pool_task: Ref) -> Dict[str, Any]:
420
+ """Claim a pool task.
421
+
422
+ The pool task disappears and is replaced by a claimed task.
423
+
424
+ Parameters:
425
+ pool_task: The reference to the pool task to claim
426
+
427
+ Returns:
428
+ Dict[str, Any]: The claimed task dictionary
429
+
430
+ """
431
+ ident = self._id_of(pool_task)
432
+ task = self._request("POST", self._url(CLAIMED_TASKS), f"Claim pool task {ident}", expected=(200, 201),
433
+ headers=self._headers("text/uri-list"), data=self._url(POOL_TASKS, f"/{ident}")).json()
434
+ logger.info("Claimed pool task %s -> claimed task %s", ident, task.get("id"))
435
+ return task
436
+
437
+ # -- claimed tasks -------------------------------------------------------
438
+
439
+ def get_claimed_task(self, task: Ref, embed: EmbedArg = None) -> Dict[str, Any]:
440
+ """Retrieve a claimed task by its reference.
441
+
442
+ Parameters:
443
+ task: The reference to the claimed task
444
+ embed: Optional embedding instructions for the response
445
+
446
+ Returns:
447
+ Dict[str, Any]: The claimed task dictionary
448
+
449
+ """
450
+ ident = self._id_of(task)
451
+ return self._get_json(self._url(CLAIMED_TASKS, f"/{ident}"), f"Get claimed task {ident}", embed=embed)
452
+
453
+ def get_claimed_task_link(self, task: Ref, link: str) -> Optional[Dict[str, Any]]:
454
+ """Retrieve a linked entity of a claimed task.
455
+
456
+ Parameters:
457
+ task: The reference to the claimed task
458
+ link: The linked entity to retrieve ("workflowitem", "owner", "step" or "action")
459
+
460
+ Returns:
461
+ Optional[Dict[str, Any]]: The linked entity dictionary, or None if not found
462
+
463
+ """
464
+ return self._task_link(CLAIMED_TASKS, CLAIMED_TASK_LINKS, task, link)
465
+
466
+ def claimed_tasks_for_user(self, eperson: Optional[Ref] = None) -> List[Dict[str, Any]]:
467
+ """Tasks the user has claimed (default: the current user).
468
+
469
+ Parameters:
470
+ eperson: Optional reference to the user (default: the current user)
471
+
472
+ Returns:
473
+ List[Dict[str, Any]]: A list of claimed task dictionaries
474
+
475
+ """
476
+ return self._task_search(CLAIMED_TASKS, "claimedtasks", "findByUser", self._user_uuid(eperson))
477
+
478
+ def claimed_tasks_for_item(self, item: Ref) -> List[Dict[str, Any]]:
479
+ """All claimed tasks of an item, for any user (administrators).
480
+
481
+ Parameters:
482
+ item: The reference to the item
483
+
484
+ Returns:
485
+ List[Dict[str, Any]]: A list of claimed task dictionaries
486
+
487
+ """
488
+ return self._task_search(CLAIMED_TASKS, "claimedtasks", "findAllByItem", self._uuid(item, "item"))
489
+
490
+ def find_claimed_task(self, item: Ref) -> Optional[Dict[str, Any]]:
491
+ """The current user's claimed task of an item, or None.
492
+
493
+ Parameters:
494
+ item: The reference to the item
495
+
496
+ Returns:
497
+ Optional[Dict[str, Any]]: The claimed task dictionary, or None if not found
498
+
499
+ """
500
+ uuid = self._uuid(item, "item")
501
+ return self._get_optional(self._url(CLAIMED_TASKS, "/search/findByItem"), f"Find claimed task of {uuid}",
502
+ params={"uuid": uuid}, embed=TASK_EMBED)
503
+
504
+ def task_options(self, task: Ref) -> List[str]:
505
+ """Options of the task's current action, e.g. ["approve", "reject", "edit_metadata"].
506
+
507
+ Parameters:
508
+ task: The reference to the task
509
+
510
+ Returns:
511
+ List[str]: A list of available options for the task's current action
512
+
513
+ """
514
+ if isinstance(task, dict) and task.get("action"):
515
+ return list(self.get_workflow_action(task["action"]).get("options") or [])
516
+ getter = self.get_claimed_task_link if (isinstance(task, dict) and task.get("type") == "claimedtask") \
517
+ or not isinstance(task, dict) else self.get_pool_task_link
518
+ action = getter(task, "action") or {}
519
+ return list(action.get("options") or [])
520
+
521
+ def perform(self, claimed_task: Ref, option: str, **params: Any) -> bool:
522
+ """
523
+ Perform an option of a claimed task: perform(task, "approve"),
524
+ perform(task, "reject", reason="..."). Extra keyword arguments become
525
+ form parameters; list values are repeated (eperson=[a, b]).
526
+
527
+ Parameters:
528
+ claimed_task: The reference to the claimed task
529
+ option: The option to perform
530
+ **params: Extra form parameters for the action
531
+
532
+ Returns:
533
+ bool: True if the action was successfully performed
534
+ """
535
+ ident = self._id_of(claimed_task)
536
+ form: List[Tuple[str, Any]] = [(submit_param(option), "true")]
537
+ for key, value in params.items():
538
+ if value is None:
539
+ continue
540
+ for v in (value if isinstance(value, (list, tuple)) else [value]):
541
+ form.append((key, v))
542
+ try:
543
+ self._request("POST", self._url(CLAIMED_TASKS, f"/{ident}"), f"Perform {option} on task {ident}",
544
+ expected=(200, 201, 204), headers=self._headers("application/x-www-form-urlencoded"),
545
+ data=form)
546
+ except DSpaceError as exc:
547
+ if getattr(exc, "status_code", None) in (403, 404, 422):
548
+ raise WorkflowError(f"Could not {option} claimed task {ident}: HTTP {exc.status_code} "
549
+ f"{(exc.response_text or '')[:300]}", status_code=exc.status_code,
550
+ response_text=exc.response_text) from exc
551
+ raise
552
+ logger.info("Performed %s on claimed task %s", option, ident)
553
+ return True
554
+
555
+ def approve(self, claimed_task: Ref) -> bool:
556
+ """
557
+ Approve a claimed task.
558
+
559
+ Parameters:
560
+ claimed_task: The reference to the claimed task
561
+
562
+ Returns:
563
+ bool: True if the action was successfully performed
564
+ """
565
+ return self.perform(claimed_task, "approve")
566
+
567
+ def reject(self, claimed_task: Ref, reason: str) -> bool:
568
+ """
569
+ Send the submission back to the submitter's workspace with a reason (required).
570
+
571
+ Parameters:
572
+ claimed_task: The reference to the claimed task
573
+ reason: The reason for rejection
574
+
575
+ Returns:
576
+ bool: True if the action was successfully performed
577
+ """
578
+ if not reason or not reason.strip():
579
+ raise ValueError("A rejection needs a reason")
580
+ return self.perform(claimed_task, "reject", reason=reason)
581
+
582
+ def select_reviewers(self, claimed_task: Ref, epersons: Sequence[Ref]) -> bool:
583
+ """
584
+ selectrevieweraction: choose who performs the next step (at least one eperson).
585
+ Sends submit_select_reviewer with eperson=<uuid> values (current DSpace);
586
+ for a single reviewer also the contract's submit_select_reviewer_<uuid>=true
587
+ form, which older versions read.
588
+
589
+ Parameters:
590
+ claimed_task: The reference to the claimed task
591
+ epersons: A sequence of references to the reviewers to select
592
+
593
+ Returns:
594
+ bool: True if the action was successfully performed
595
+ """
596
+ uuids = [self._uuid(e, "eperson") for e in epersons]
597
+ if not uuids:
598
+ raise ValueError("At least one reviewer is required")
599
+ extra = {f"submit_select_reviewer_{uuids[0]}": "true"} if len(uuids) == 1 else {}
600
+ return self.perform(claimed_task, "select_reviewer", eperson=uuids, **extra)
601
+
602
+ def score(self, claimed_task: Ref, score: int, review: Optional[str] = None) -> bool:
603
+ """
604
+ scorereviewaction: record a score (and a review text, required if the
605
+ action is configured with descriptionRequired). The contract names the
606
+ text "description"; DSpace's ScoreReviewAction reads "review" - both are sent.
607
+
608
+ Parameters:
609
+ claimed_task: The reference to the claimed task
610
+ score: The score to record
611
+ review: Optional review text
612
+
613
+ Returns:
614
+ bool: True if the action was successfully performed
615
+ """
616
+ return self.perform(claimed_task, "score", score=int(score), review=review, description=review)
617
+
618
+ def unclaim(self, claimed_task: Ref) -> bool:
619
+ """Give a claimed task back to the pool.
620
+
621
+ Parameters:
622
+ claimed_task: The reference to the claimed task
623
+
624
+ Returns:
625
+ bool: True if the action was successfully performed
626
+ """
627
+ ident = self._id_of(claimed_task)
628
+ self._request("DELETE", self._url(CLAIMED_TASKS, f"/{ident}"), f"Unclaim task {ident}", expected=(204,))
629
+ logger.info("Claimed task %s returned to the pool", ident)
630
+ return True
631
+
632
+ # ==================================================================
633
+ # Higher-level flows
634
+ # ==================================================================
635
+
636
+ def claim_for_item(self, item: Ref) -> Dict[str, Any]:
637
+ """The current user's claimed task of the item, claiming the pool task if needed.
638
+
639
+ Parameters:
640
+ item: The reference to the item
641
+
642
+ Returns:
643
+ Dict[str, Any]: The claimed task for the item
644
+ """
645
+ claimed = self.find_claimed_task(item)
646
+ if claimed:
647
+ return claimed
648
+ pool_task = self.find_pool_task(item)
649
+ if not pool_task:
650
+ raise WorkflowError(f"No task for item {self._uuid(item, 'item')} that the current user can claim "
651
+ "(not in workflow, or not a member of the step's group)")
652
+ return self.claim(pool_task)
653
+
654
+ def review(self, item: Ref, decision: str, reason: Optional[str] = None, **params: Any) -> Dict[str, Any]:
655
+ """
656
+ Claim (if needed) and decide on the item's current step:
657
+ decision "approve", "reject" (reason required) or any option of the
658
+ step's action. Returns {"task", "decision", "workflow_item"} -
659
+ workflow_item is the item's workflow item afterwards (None once archived
660
+ or returned to the submitter).
661
+
662
+ Parameters:
663
+ item: The reference to the item
664
+ decision: The decision to make ("approve", "reject", or any step action)
665
+ reason: Optional reason for the decision (required for "reject")
666
+ **params: Extra form parameters for the action
667
+
668
+ Returns:
669
+ Dict[str, Any]: The result of the review, including "task", "decision", and "workflow_item"
670
+ """
671
+ task = self.claim_for_item(item)
672
+ if decision == "reject":
673
+ self.reject(task, reason or "")
674
+ else:
675
+ self.perform(task, decision, reason=reason, **params)
676
+ return {"task": task, "decision": decision, "workflow_item": self.find_by_item(item)}
677
+
678
+ def approve_all_steps(self, item: Ref, max_steps: int = 10) -> int:
679
+ """
680
+ Approve step after step until the item leaves the workflow (archived).
681
+ Returns the number of approvals. Stops with WorkflowError if a step
682
+ cannot be claimed by the current user.
683
+
684
+ Parameters:
685
+ item: The reference to the item
686
+ max_steps: The maximum number of steps to approve before stopping
687
+
688
+ Returns:
689
+ int: The number of approvals performed
690
+ """
691
+ approvals = 0
692
+ while self.find_by_item(item) is not None:
693
+ if approvals >= max_steps:
694
+ raise WorkflowError(f"Item still in workflow after {max_steps} approvals")
695
+ self.approve(self.claim_for_item(item))
696
+ approvals += 1
697
+ return approvals
698
+
699
+ def pending_tasks(self, eperson: Optional[Ref] = None, claimed: bool = True, pool: bool = True
700
+ ) -> List[Dict[str, Any]]:
701
+ """
702
+ Pending tasks for the user, either claimed or in the pool.
703
+
704
+ Parameters:
705
+ eperson: The reference to the user (default: current user)
706
+ claimed: Include claimed tasks (default: True)
707
+ pool: Include pool tasks (default: True)
708
+
709
+ Returns:
710
+ List[Dict[str, Any]]: The list of pending tasks for the user
711
+
712
+ What waits for a user (default: current user), as a flat list:
713
+ [{"kind": "claimed"|"pool", "task_id", "step", "action", "options",
714
+ "workflow_item_id", "item_uuid", "title"}].
715
+ """
716
+ user = self._user_uuid(eperson)
717
+ tasks = [("claimed", t) for t in (self.claimed_tasks_for_user(user) if claimed else [])]
718
+ tasks += [("pool", t) for t in (self.pool_tasks_for_user(user) if pool else [])]
719
+ result = []
720
+ for kind, task in tasks:
721
+ wfi = (task.get("_embedded") or {}).get("workflowitem") or {}
722
+ item = (wfi.get("_embedded") or {}).get("item") or {}
723
+ titles = (item.get("metadata") or {}).get("dc.title") or []
724
+ result.append({
725
+ "kind": kind, "task_id": task.get("id"), "step": task.get("step"), "action": task.get("action"),
726
+ "options": self.task_options(task) if task.get("action") else [],
727
+ "workflow_item_id": wfi.get("id"), "item_uuid": item.get("uuid"),
728
+ "title": titles[0].get("value") if titles else item.get("name"),
729
+ })
730
+ return result
731
+
732
+ # ==================================================================
733
+ # Workflow configuration
734
+ # ==================================================================
735
+
736
+ def get_workflow_definitions(self) -> List[Dict[str, Any]]:
737
+ """List all workflow definitions.
738
+
739
+ Returns:
740
+ List[Dict[str, Any]]: The list of workflow definitions
741
+ """
742
+ return list(self._iter_embedded(self._url(DEFINITIONS), "workflowdefinitions", "List workflow definitions"))
743
+
744
+ def get_workflow_definition(self, name: str) -> Dict[str, Any]:
745
+ """Get a specific workflow definition by name.
746
+
747
+ Parameters:
748
+ name: The name of the workflow definition
749
+
750
+ Returns:
751
+ Dict[str, Any]: The workflow definition
752
+ """
753
+ return self._get_json(self._url(DEFINITIONS, f"/{name}"), f"Get workflow definition {name}")
754
+
755
+ def definition_for_collection(self, collection: Ref) -> Dict[str, Any]:
756
+ """The workflow definition a collection uses (falls back to the default one).
757
+
758
+ Parameters:
759
+ collection: The reference to the collection
760
+
761
+ Returns:
762
+ Dict[str, Any]: The workflow definition for the collection
763
+ """
764
+ uuid = self._uuid(collection, "collection")
765
+ return self._get_json(self._url(DEFINITIONS, "/search/findByCollection"),
766
+ f"Workflow definition of {uuid}", params={"uuid": uuid})
767
+
768
+ def definition_collections(self, name: str) -> List[Dict[str, Any]]:
769
+ """Collections that explicitly use a workflow definition.
770
+
771
+ Parameters:
772
+ name: The name of the workflow definition
773
+
774
+ Returns:
775
+ List[Dict[str, Any]]: The list of collections using the workflow definition
776
+ """
777
+ return list(self._iter_embedded(self._url(DEFINITIONS, f"/{name}/collections"), "collections",
778
+ f"Collections of workflow definition {name}"))
779
+
780
+ def definition_steps(self, name: str) -> List[Dict[str, Any]]:
781
+ """Steps of a workflow definition.
782
+
783
+ Parameters:
784
+ name: The name of the workflow definition
785
+
786
+ Returns:
787
+ List[Dict[str, Any]]: The list of steps for the workflow definition
788
+ """
789
+ return list(self._iter_embedded(self._url(DEFINITIONS, f"/{name}/steps"), ("steps", "workflowsteps"),
790
+ f"Steps of workflow definition {name}"))
791
+
792
+ def get_workflow_step(self, name: str) -> Dict[str, Any]:
793
+ """Get a specific workflow step by name.
794
+
795
+ Parameters:
796
+ name: The name of the workflow step
797
+
798
+ Returns:
799
+ Dict[str, Any]: The workflow step details, including embedded workflow actions
800
+ """
801
+ return self._get_json(self._url(STEPS, f"/{name}"), f"Get workflow step {name}")
802
+
803
+ def get_workflow_action(self, name: str) -> Dict[str, Any]:
804
+ """Get a specific workflow action by name.
805
+
806
+ Parameters:
807
+ name: The name of the workflow action
808
+
809
+ Returns:
810
+ Dict[str, Any]: The workflow action details (cached)
811
+ """
812
+ if name not in self._action_cache:
813
+ self._action_cache[name] = self._get_json(self._url(ACTIONS, f"/{name}"), f"Get workflow action {name}")
814
+ return self._action_cache[name]
815
+
816
+ # ==================================================================
817
+ # Supervision orders
818
+ # ==================================================================
819
+
820
+ def iter_supervision_orders(self, embed: EmbedArg = ("item", "group")) -> Iterator[Dict[str, Any]]:
821
+ """All supervision orders (administrators). A generator.
822
+
823
+ Parameters:
824
+ embed: The related entities to embed (default: ("item", "group"))
825
+
826
+ Returns:
827
+ Iterator[Dict[str, Any]]: The supervision orders
828
+ """
829
+ return self._iter_embedded(self._url(SUPERVISION_ORDERS), "supervisionorders", "List supervision orders",
830
+ embed=list(embed) if embed else None)
831
+
832
+ def get_supervision_order(self, order_id, embed: EmbedArg = None) -> Dict[str, Any]:
833
+ """Get a specific supervision order by ID.
834
+
835
+ Parameters:
836
+ order_id: The ID of the supervision order
837
+ embed: The related entities to embed (default: None)
838
+
839
+ Returns:
840
+ Dict[str, Any]: The supervision order details
841
+ """
842
+ return self._get_json(self._url(SUPERVISION_ORDERS, f"/{order_id}"), f"Get supervision order {order_id}",
843
+ embed=embed)
844
+
845
+ def supervision_orders_for_item(self, item: Ref) -> List[Dict[str, Any]]:
846
+ """Get all supervision orders for a specific item.
847
+
848
+ Parameters:
849
+ item: The reference to the item
850
+
851
+ Returns:
852
+ List[Dict[str, Any]]: The supervision orders for the item
853
+ """
854
+ uuid = self._uuid(item, "item")
855
+ return list(self._iter_embedded(self._url(SUPERVISION_ORDERS, "/search/byItem"), "supervisionorders",
856
+ f"Supervision orders of {uuid}", [("uuid", uuid)], embed=["group"]))
857
+
858
+ def create_supervision_order(self, item: Ref, group: Ref, order_type: str = "NONE") -> Dict[str, Any]:
859
+ """
860
+ Let a group supervise an in-progress submission (workspace or workflow).
861
+ order_type: NONE (no extra rights), EDITOR (READ + WRITE on the item and
862
+ its files) or OBSERVER (READ). 422: item not in progress, or the group
863
+ already has an order for it.
864
+
865
+ Parameters:
866
+ item: The reference to the item
867
+ group: The reference to the group
868
+ order_type: The type of supervision order (default: "NONE")
869
+
870
+ Returns:
871
+ Dict[str, Any]: The created supervision order
872
+ """
873
+ order_type = order_type.upper()
874
+ if order_type not in SUPERVISION_TYPES:
875
+ raise ValueError(f"order_type must be one of {SUPERVISION_TYPES}")
876
+ params = {"uuid": self._uuid(item, "item"), "group": self._uuid(group, "group"), "type": order_type}
877
+ order = self._request("POST", self._url(SUPERVISION_ORDERS), "Create supervision order", expected=(200, 201),
878
+ params=params, headers=self._headers()).json()
879
+ logger.info("Created %s supervision order %s on item %s", order_type, order.get("id"), params["uuid"])
880
+ return order
881
+
882
+ def delete_supervision_order(self, order_id) -> bool:
883
+ """Delete a specific supervision order by ID.
884
+
885
+ Parameters:
886
+ order_id: The ID of the supervision order
887
+
888
+ Returns:
889
+ bool: True if the deletion was successful
890
+ """
891
+ self._request("DELETE", self._url(SUPERVISION_ORDERS, f"/{order_id}"), f"Delete supervision order {order_id}",
892
+ expected=(204,))
893
+ return True