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.
@@ -0,0 +1,1404 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Workspace items (in-progress submissions) for DSpace API (DSpace 7+ / DSpace-CRIS)
4
+
5
+ POST /submission/workspaceitems?owningCollection= empty submission
6
+ POST /submission/workspaceitems?owningCollection= text/uri-list: import an external entry
7
+ POST /submission/workspaceitems?owningCollection= multipart: create from file(s)
8
+ GET /submission/workspaceitems[/{id}] list / single
9
+ GET /submission/workspaceitems/search/findBySubmitter?uuid=
10
+ GET /submission/workspaceitems/search/item?uuid= workspace item of an item
11
+ GET /submission/workspaceitems/{id}/item|collection|submissionDefinition
12
+ POST /submission/workspaceitems/{id} multipart: upload a file
13
+ PATCH /submission/workspaceitems/{id} sections (see below)
14
+ DELETE /submission/workspaceitems/{id}
15
+ POST /workflow/workflowitems deposit (text/uri-list)
16
+
17
+ Section PATCH helpers, by section type:
18
+ submission-form patch_section_metadata(), set_metadata(), fill_metadata() (routes fields to forms)
19
+ upload upload_file(), patch_file_metadata(), add/remove_file_access_condition(),
20
+ remove_file(), move_file(), set/clear_primary_file()
21
+ license grant_license()
22
+ access add/set/remove_access_condition(s), set_discoverable()
23
+ cclicense set_cc_license(), remove_cc_license(), get_cc_license_url()
24
+ sherpaPolicies refresh_sherpa_policies()
25
+
26
+ Submission configuration (config/submission*): definitions, sections, forms,
27
+ upload and access options, CC licenses - used to find section names by type
28
+ and to route metadata fields to the form that contains them.
29
+
30
+ submit() runs a whole deposit: create, fill metadata, upload files, grant the
31
+ license, deposit, and returns validation errors instead of a bare 422.
32
+ """
33
+
34
+ import logging
35
+ import json
36
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
37
+
38
+ from ._base import BaseManager
39
+ from .config import Endpoints
40
+ from .exceptions import DSpaceError
41
+ from .metadata import MetadataPatch, as_operations
42
+ from .projection import EmbedArg, ProjectionArg, view_params
43
+ from .exceptions import handle_response_error, BadRequestError
44
+
45
+ # Logger for the module
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # API endpoints and section types for workspace items
50
+ CONFIG = "/config"
51
+ SECTION_TYPES = ("submission-form", "upload", "license", "cclicense", "access", "collection",
52
+ "identifiers", "duplicates", "sherpaPolicies", "coarnotify")
53
+
54
+
55
+
56
+ def _embedded_list(value, *keys) -> Optional[List[Dict[str, Any]]]:
57
+ """A HAL embedded list: a plain list, or a page {"_embedded": {<key>: [...]}}; None if absent.
58
+
59
+ Parameters:
60
+ value: The value to extract the embedded list from
61
+ *keys: Keys to look for in the "_embedded" dictionary
62
+
63
+ Returns:
64
+ Optional[List[Dict[str, Any]]]: The extracted list or None if not found
65
+ """
66
+ if isinstance(value, list):
67
+ return value
68
+ if isinstance(value, dict):
69
+ inner = value.get("_embedded") or {}
70
+ for key in keys:
71
+ if isinstance(inner.get(key), list):
72
+ return inner[key]
73
+ return None
74
+
75
+ class SubmissionError(DSpaceError):
76
+ """A submission could not be completed; .errors holds DSpace's validation errors."""
77
+
78
+ def __init__(self, message, errors=None, workspace_item=None, status_code=None, response_text=None):
79
+ """Initialize a SubmissionError instance.
80
+
81
+ Args:
82
+ message: Error message
83
+ errors: List of validation errors from DSpace
84
+ workspace_item: The workspace item associated with the error
85
+ status_code: HTTP status code of the response
86
+ response_text: Text of the HTTP response
87
+ """
88
+ super().__init__(message, status_code=status_code, response_text=response_text)
89
+ self.errors = errors or []
90
+ self.workspace_item = workspace_item
91
+
92
+
93
+ def describe_errors(errors, limit=5):
94
+ """Short text of DSpace validation errors: "message (path, path); ..." - used in SubmissionError messages."""
95
+ parts = [f"{e.get('message')} ({', '.join(e.get('paths') or []) or 'no path'})" for e in (errors or [])[:limit]]
96
+ if len(errors or []) > limit:
97
+ parts.append(f"and {len(errors) - limit} more")
98
+ return "; ".join(parts)
99
+
100
+
101
+ class WorkspaceManager(BaseManager):
102
+ """Manages DSpace workspace items"""
103
+
104
+ def __init__(self, auth):
105
+ """Initialize a WorkspaceManager instance."""
106
+ super().__init__(auth)
107
+ self._definition_cache: Dict[str, Dict[str, Any]] = {}
108
+ self._form_fields_cache: Dict[str, List[str]] = {}
109
+
110
+ def create_workspace_item(self, collection_uuid, entity_type="Publication"):
111
+ """
112
+ Create a new workspace item in a collection
113
+
114
+ Parameters:
115
+ collection_uuid: UUID of the collection
116
+ entity_type: Type of entity (e.g., "Publication")
117
+
118
+ Returns:
119
+ dict: Created workspace item data
120
+ """
121
+ logger.info("Creating workspace item in collection %s", collection_uuid)
122
+
123
+ url = (f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}"
124
+ f"?projection=full&owningCollection={collection_uuid}&entityType={entity_type}")
125
+
126
+ try:
127
+ response = self.session.post(
128
+ url,
129
+ headers=self.config.headers,
130
+ data={},
131
+ timeout=self.config.timeout
132
+ )
133
+
134
+ if response.status_code == 201:
135
+ logger.info("Workspace item created successfully")
136
+ return response.json()
137
+ else:
138
+ handle_response_error(response, "Create workspace item")
139
+
140
+ except Exception as e:
141
+ logger.error("Failed to create workspace item: %s", e)
142
+ raise
143
+
144
+ def get_workspace_item(self, workspace_id, all_languages=None, projection=None, embed=None):
145
+ """
146
+ Get a specific workspace item by ID
147
+
148
+ Parameters:
149
+ workspace_id: ID of the workspace item
150
+
151
+ Returns:
152
+ dict: Workspace item data
153
+ """
154
+ logger.info("Getting workspace item %s", workspace_id)
155
+
156
+ url = f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}/{workspace_id}"
157
+ params = view_params(None, self.config, all_languages, projection, embed)
158
+
159
+ try:
160
+ response = self.session.get(
161
+ url,
162
+ headers=self.config.headers,
163
+ params=params,
164
+ timeout=self.config.timeout
165
+ )
166
+
167
+ if response.status_code == 200:
168
+ logger.info("Successfully retrieved workspace item %s", workspace_id)
169
+ return response.json()
170
+ if response.status_code == 422:
171
+ return {"error": "Workspace item cannot be deposited automatically."}
172
+ handle_response_error(response, "Get workspace item %s" % workspace_id)
173
+
174
+ except Exception as e:
175
+ logger.error("Failed to get workspace item %s: %s", workspace_id, e)
176
+ raise
177
+
178
+ def patch_workspace_item(self, workspace_id, operations):
179
+ """
180
+ Patch a workspace item with JSON Patch operations
181
+
182
+ Parameters:
183
+ workspace_id: ID of the workspace item
184
+ operations: List of JSON Patch operations
185
+
186
+ Returns:
187
+ dict: Updated workspace item data
188
+ """
189
+ logger.info("Patching workspace item %s", workspace_id)
190
+
191
+ url = f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}/{workspace_id}"
192
+
193
+ try:
194
+ response = self.session.patch(
195
+ url,
196
+ headers={**self.config.headers, "Content-Type": "application/json-patch+json"},
197
+ json=operations,
198
+ timeout=self.config.timeout
199
+ )
200
+
201
+ if response.status_code == 200:
202
+ logger.info(f"Workspace item {workspace_id} patched successfully")
203
+ return response.json()
204
+ if response.status_code == 422:
205
+ return {"error": "Workspace item cannot be deposited automatically."}
206
+ handle_response_error(response, f"Patch workspace item {workspace_id}")
207
+
208
+ except Exception as e:
209
+ logger.error(f"Failed to patch workspace item %s: %s", workspace_id, e)
210
+ raise
211
+
212
+ def add_metadata(self, workspace_id, section, field, values):
213
+ """
214
+ Add metadata to a workspace item
215
+
216
+ Parameters:
217
+ workspace_id: ID of the workspace item
218
+ section: Section name (e.g., "publication", "publication_type")
219
+ field: Metadata field name (e.g., "dc.title")
220
+ values: List of metadata values
221
+
222
+ Returns:
223
+ dict: Updated workspace item data
224
+ """
225
+ if isinstance(values, str):
226
+ values = [{"value": values}]
227
+ elif isinstance(values, list) and all(isinstance(v, str) for v in values):
228
+ values = [{"value": v} for v in values]
229
+
230
+ operation = {
231
+ "op": "add",
232
+ "path": f"/sections/{section}/{field}",
233
+ "value": values
234
+ }
235
+
236
+ return self.patch_workspace_item(workspace_id, [operation])
237
+
238
+ def set_metadata(self, workspace_id, metadata_sections):
239
+ """
240
+ Set multiple metadata fields at once
241
+
242
+ Parameters:
243
+ workspace_id: ID of the workspace item
244
+ metadata_sections: Dictionary of sections and their metadata
245
+ Example: {
246
+ "publication": {
247
+ "dc.title": [{"value": "Title", "language": "en"}],
248
+ "dc.date.issued": [{"value": "2025"}]
249
+ },
250
+ "publication_type": {
251
+ "dc.type": [{
252
+ "value": "still image",
253
+ "authority": "publication-coar-types:c_ecc8",
254
+ "confidence": 600
255
+ }]
256
+ }
257
+ }
258
+
259
+ Returns:
260
+ dict: Updated workspace item data
261
+ """
262
+ operations = []
263
+
264
+ for section, fields in metadata_sections.items():
265
+ for field, values in fields.items():
266
+ operations.append({
267
+ "op": "add",
268
+ "path": f"/sections/{section}/{field}",
269
+ "value": values
270
+ })
271
+
272
+ return self.patch_workspace_item(workspace_id, operations)
273
+
274
+ def delete_workspace_item(self, workspace_id):
275
+ """
276
+ Delete a workspace item
277
+
278
+ Parameters:
279
+ workspace_id: ID of the workspace item to delete
280
+
281
+ Returns:
282
+ bool: True if deletion was successful
283
+ """
284
+ logger.info("Deleting workspace item %s", workspace_id)
285
+
286
+ url = f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}/{workspace_id}"
287
+
288
+ try:
289
+ response = self.session.delete(
290
+ url,
291
+ headers=self.config.headers,
292
+ timeout=self.config.timeout
293
+ )
294
+
295
+ if response.status_code == 204:
296
+ logger.info("Workspace item %s deleted successfully", workspace_id)
297
+ return True
298
+ else:
299
+ handle_response_error(response, f"Delete workspace item {workspace_id}")
300
+
301
+ except Exception as e:
302
+ logger.error("Failed to delete workspace item %s: %s", workspace_id, e)
303
+ raise
304
+
305
+ def deposit_to_workflow(self, workspace_id):
306
+ """
307
+ Deposit a workspace item to the workflow (archive)
308
+
309
+ Parameters:
310
+ workspace_id: ID of the workspace item to deposit
311
+
312
+ Returns:
313
+ str: Response text (usually contains workflow item info)
314
+ """
315
+ logger.info("Depositing workspace item %s to workflow", workspace_id)
316
+
317
+ workspace_url = f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}/{workspace_id}"
318
+ deposit_url = f"{self.config.dspace_url}{Endpoints.WORKFLOW_ITEMS}?projection=full"
319
+
320
+ headers = {"Content-Type": "text/uri-list"}
321
+
322
+ try:
323
+ response = self.session.post(
324
+ deposit_url,
325
+ headers=headers,
326
+ data=workspace_url,
327
+ timeout=self.config.timeout
328
+ )
329
+
330
+ if response.status_code == 201:
331
+ logger.info("Workspace item deposited successfully")
332
+ return response.text
333
+ else:
334
+ handle_response_error(response, "Deposit workspace item")
335
+
336
+ except Exception as e:
337
+ logger.error("Failed to deposit workspace item: %s", e)
338
+ raise
339
+
340
+ def get_workspace_items(self, page=0, size=20, all_languages=None, projection=None, embed=None):
341
+ """
342
+ Get list of workspace items
343
+
344
+ Parameters:
345
+ page: Page number (0-based)
346
+ size: Number of items per page
347
+
348
+ Returns:
349
+ dict: API response containing workspace items
350
+ """
351
+ logger.info("Getting workspace items (page=%d, size=%d)", page, size)
352
+
353
+ url = f"{self.config.dspace_url}{Endpoints.WORKSPACE_ITEMS}"
354
+ params = {"page": page, "size": size}
355
+ params = view_params(params, self.config, all_languages, projection, embed)
356
+
357
+ try:
358
+ response = self.session.get(
359
+ url,
360
+ headers=self.config.headers,
361
+ params=params,
362
+ timeout=self.config.timeout
363
+ )
364
+
365
+ if response.status_code == 200:
366
+ logger.info("Successfully retrieved workspace items")
367
+ return response.json()
368
+ else:
369
+ handle_response_error(response, "Get workspace items")
370
+
371
+ except Exception as e:
372
+ logger.error("Failed to get workspace items: %s", e)
373
+ raise
374
+
375
+ # Specialized functions to ease project based development
376
+
377
+ def create_publication_item(self, collection_uuid, title, description=None,
378
+ date_issued=None, pub_type="still image",
379
+ pub_type_authority="publication-coar-types:c_ecc8"):
380
+ """
381
+ Convenience method to create a publication workspace item with basic metadata
382
+
383
+ Parameters:
384
+ collection_uuid: UUID of the collection
385
+ title: Title of the publication
386
+ description: Optional description
387
+ date_issued: Publication date (year)
388
+ pub_type: Publication type value
389
+ pub_type_authority: Authority for publication type
390
+
391
+ Returns:
392
+ dict: Created and populated workspace item
393
+ """
394
+ # Create workspace item
395
+ workspace_item = self.create_workspace_item(collection_uuid, "Publication")
396
+ workspace_id = workspace_item.get("id")
397
+
398
+ if not workspace_id:
399
+ raise BadRequestError("Failed to get workspace item ID")
400
+
401
+ # Prepare metadata sections
402
+ metadata_sections = {
403
+ "publication_type": {
404
+ "dc.type": [{
405
+ "value": pub_type,
406
+ "language": None,
407
+ "authority": pub_type_authority,
408
+ "securityLevel": 0,
409
+ "confidence": 600,
410
+ "place": 0
411
+ }]
412
+ },
413
+ "publication": {
414
+ "dc.title": [{"value": title, "language": "en"}]
415
+ }
416
+ }
417
+
418
+ if date_issued:
419
+ metadata_sections["publication"]["dc.date.issued"] = [{"value": str(date_issued)}]
420
+
421
+ if description:
422
+ metadata_sections["publication_indexing"] = {
423
+ "dc.description": [{"value": description, "language": "en"}]
424
+ }
425
+
426
+ # Set metadata
427
+ return self.set_metadata(workspace_id, metadata_sections)
428
+
429
+ # ======================================================================
430
+ # Everything below follows the BaseManager conventions (retries, paging,
431
+ # projection/embed) and raises SubmissionError with DSpace's validation
432
+ # errors where the older methods above return {"error": ...} on 422.
433
+ # ======================================================================
434
+
435
+ # Workspace item URL construction
436
+ WS = Endpoints.WORKSPACE_ITEMS
437
+
438
+ def _ws_url(self, ws_id, suffix: str = "") -> str:
439
+ """Construct the URL for a workspace item.
440
+
441
+ Parameters:
442
+ ws_id: The ID of the workspace item
443
+ suffix: Optional suffix to append to the URL
444
+
445
+ Returns:
446
+ str: The constructed URL
447
+ """
448
+ return self._url(self.WS, f"/{ws_id}{suffix}")
449
+
450
+ # ------------------------------------------------------------------
451
+ # Lookups
452
+ # ------------------------------------------------------------------
453
+
454
+ def get(self, ws_id, all_languages: Optional[bool] = None, projection: ProjectionArg = None,
455
+ embed: EmbedArg = None) -> Dict[str, Any]:
456
+ """A workspace item (raises on errors, unlike get_workspace_item()).
457
+
458
+ Parameters:
459
+ ws_id: The ID of the workspace item
460
+ all_languages: Whether to include all languages
461
+ projection: Projection argument for the request
462
+ embed: Embed argument for the request
463
+
464
+ Returns:
465
+ Dict[str, Any]: The workspace item
466
+ """
467
+ return self._get_json(self._ws_url(ws_id), f"Get workspace item {ws_id}", all_languages=all_languages,
468
+ projection=projection, embed=embed)
469
+
470
+ def iter_workspace_items(self, page_size: Optional[int] = None, embed: EmbedArg = None):
471
+ """All workspace items the user may see (admins: all). A generator.
472
+
473
+ Parameters:
474
+ page_size: The number of items per page
475
+ embed: Embed argument for the request
476
+
477
+ Yields:
478
+ Dict[str, Any]: Workspace items
479
+ """
480
+ return self._iter_embedded(self._url(self.WS), "workspaceitems", "List workspace items",
481
+ page_size=page_size, embed=embed)
482
+
483
+ def find_by_submitter(self, eperson_uuid: str, embed: EmbedArg = None) -> List[Dict[str, Any]]:
484
+ """Find workspace items submitted by a specific user.
485
+
486
+ Parameters:
487
+ eperson_uuid: The UUID of the submitter
488
+ embed: Embed argument for the request
489
+
490
+ Returns:
491
+ List[Dict[str, Any]]: Workspace items submitted by the user
492
+ """
493
+ url = self._url(self.WS, "/search/findBySubmitter")
494
+ return list(self._iter_embedded(url, "workspaceitems", f"Find workspace items of {eperson_uuid}",
495
+ [("uuid", eperson_uuid)], embed=embed))
496
+
497
+ def find_by_item(self, item_uuid: str, embed: EmbedArg = None) -> Optional[Dict[str, Any]]:
498
+ """The workspace item holding this (not yet archived) item, or None (204).
499
+
500
+ Parameters:
501
+ item_uuid: The UUID of the item
502
+ embed: Embed argument for the request
503
+
504
+ Returns:
505
+ Optional[Dict[str, Any]]: The workspace item or None if not found
506
+ """
507
+ return self._get_optional(self._url(self.WS, "/search/item"), f"Find workspace item of item {item_uuid}",
508
+ params={"uuid": item_uuid}, embed=embed)
509
+
510
+ def get_item_of(self, ws_id, all_languages: Optional[bool] = None) -> Dict[str, Any]:
511
+ """The item behind the submission (its uuid stays the same after deposit).
512
+
513
+ Parameters:
514
+ ws_id: The ID of the workspace item
515
+ all_languages: Whether to include all languages
516
+
517
+ Returns:
518
+ Dict[str, Any]: The item
519
+ """
520
+ return self._get_json(self._ws_url(ws_id, "/item"), f"Get item of workspace item {ws_id}",
521
+ all_languages=all_languages)
522
+
523
+ def get_collection_of(self, ws_id) -> Dict[str, Any]:
524
+ """The collection behind the workspace item.
525
+
526
+ Parameters:
527
+ ws_id: The ID of the workspace item
528
+
529
+ Returns:
530
+ Dict[str, Any]: The collection
531
+ """
532
+ return self._get_json(self._ws_url(ws_id, "/collection"), f"Get collection of workspace item {ws_id}")
533
+
534
+ def get_definition_of(self, ws_id) -> Dict[str, Any]:
535
+ """The submission definition behind the workspace item.
536
+
537
+ Parameters:
538
+ ws_id: The ID of the workspace item
539
+
540
+ Returns:
541
+ Dict[str, Any]: The submission definition
542
+ """
543
+ return self._get_json(self._ws_url(ws_id, "/submissionDefinition"),
544
+ f"Get submission definition of workspace item {ws_id}")
545
+
546
+ def delete(self, ws_id) -> bool:
547
+ """Delete the submission and its item.
548
+
549
+ Parameters:
550
+ ws_id: The ID of the workspace item
551
+
552
+ Returns:
553
+ bool: True if the deletion was successful
554
+ """
555
+ self._request("DELETE", self._ws_url(ws_id), f"Delete workspace item {ws_id}", expected=(204,))
556
+ return True
557
+
558
+ # ------------------------------------------------------------------
559
+ # Creating submissions
560
+ # ------------------------------------------------------------------
561
+
562
+ def start_submission(self, collection_uuid: str, entity_type: Optional[str] = None) -> Dict[str, Any]:
563
+ """Empty workspace item in the collection (entity_type only where entities are configured).
564
+
565
+ Parameters:
566
+ collection_uuid: The UUID of the collection
567
+ entity_type: The entity type (if applicable)
568
+
569
+ Returns:
570
+ Dict[str, Any]: The created workspace item
571
+ """
572
+ params = [("owningCollection", collection_uuid)] + ([("entityType", entity_type)] if entity_type else [])
573
+ ws = self._request("POST", self._url(self.WS), f"Start submission in {collection_uuid}",
574
+ expected=(200, 201), headers=self._headers(), params=params).json()
575
+ logger.info("Started workspace item %s in collection %s", ws.get("id"), collection_uuid)
576
+ return ws
577
+
578
+ def create_from_external_source(self, collection_uuid: str, entry_href: str) -> Dict[str, Any]:
579
+ """
580
+ Start a submission pre-filled from an external entry, e.g.
581
+ <api>/integration/externalsources/orcid/entryValues/0000-0002-4271-0436
582
+
583
+ Parameters:
584
+ collection_uuid: The UUID of the collection
585
+ entry_href: The href of the external entry
586
+
587
+ Returns:
588
+ Dict[str, Any]: The created workspace item
589
+ """
590
+ return self._request("POST", self._url(self.WS), "Create workspace item from external source",
591
+ expected=(200, 201), headers=self._headers("text/uri-list"),
592
+ params={"owningCollection": collection_uuid},
593
+ data=self.resolve_href(entry_href)).json()
594
+
595
+ def create_from_files(self, sources: Sequence[Any], collection_uuid: Optional[str] = None,
596
+ timeout: Optional[float] = None) -> List[Dict[str, Any]]:
597
+ """
598
+ Start a submission from file(s) (BibTeX, RIS, PubMed XML, ...): the first
599
+ parseable file pre-fills the metadata, all files are attached. Without a
600
+ collection, DSpace uses the first one the user may submit to.
601
+
602
+ Parameters:
603
+ sources: A sequence of file sources (file paths or tuples of name and content)
604
+ collection_uuid: The UUID of the collection (optional)
605
+ timeout: Request timeout in seconds (optional)
606
+
607
+ Returns:
608
+ List[Dict[str, Any]]: The created workspace items
609
+ """
610
+ from .bundles import BundleManager
611
+ files = []
612
+ for source in sources:
613
+ name, content = BundleManager._read_source(*(source if isinstance(source, tuple) else (source, None)))
614
+ files.append(("file", (name, content, "application/octet-stream")))
615
+ if not files:
616
+ raise ValueError("No files given")
617
+ params = {"owningCollection": collection_uuid} if collection_uuid else None
618
+ data = self._request("POST", self._url(self.WS), "Create workspace item from files",
619
+ expected=(200, 201), headers=self._headers(content_type=None), params=params,
620
+ files=files, timeout=timeout or 300).json()
621
+ embedded = (data.get("_embedded") or {}).get("workspaceitems")
622
+ return embedded if isinstance(embedded, list) else [data]
623
+
624
+ # ------------------------------------------------------------------
625
+ # Generic PATCH
626
+ # ------------------------------------------------------------------
627
+
628
+ def apply_patch(self, ws_id, operations: Union[MetadataPatch, Iterable[Dict[str, Any]]]) -> Dict[str, Any]:
629
+ """PATCH the workspace item; a 422 raises SubmissionError with the item's validation errors.
630
+
631
+ Parameters:
632
+ ws_id: The ID of the workspace item
633
+ operations: The patch operations to apply
634
+
635
+ Returns:
636
+ Dict[str, Any]: The updated workspace item
637
+
638
+ Raises:
639
+ SubmissionError: If the patch is rejected with validation errors
640
+ """
641
+ ops = as_operations(operations)
642
+ if not ops:
643
+ raise ValueError("Empty patch")
644
+ try:
645
+ return self._request("PATCH", self._ws_url(ws_id), f"Patch workspace item {ws_id}",
646
+ headers=self._headers("application/json-patch+json"), json=ops).json()
647
+ except DSpaceError as exc:
648
+ if getattr(exc, "status_code", None) != 422:
649
+ raise
650
+ raise SubmissionError(f"Workspace item {ws_id} rejected the patch: {exc.response_text or exc}",
651
+ errors=self._errors_from(exc.response_text), status_code=422,
652
+ response_text=exc.response_text) from exc
653
+
654
+ @staticmethod
655
+ def _errors_from(text) -> List[Dict[str, Any]]:
656
+ """Extracts validation errors from the response text.
657
+
658
+ Parameters:
659
+ text: The response text containing validation errors
660
+
661
+ Returns:
662
+ List[Dict[str, Any]]: A list of validation errors
663
+ """
664
+ try:
665
+ data = json.loads(text or "")
666
+ except ValueError:
667
+ return []
668
+ return data.get("errors", []) if isinstance(data, dict) else []
669
+
670
+ @staticmethod
671
+ def validation_errors(ws: Dict[str, Any]) -> List[Dict[str, Any]]:
672
+ """Extracts validation errors from a workspace item.
673
+
674
+ Parameters:
675
+ ws: The workspace item containing validation errors
676
+
677
+ Returns:
678
+ List[Dict[str, Any]]: A list of validation errors in the format
679
+ [{"message": "error.validation.required", "paths": ["/sections/traditionalpageone/dc.title"]}, ...]
680
+ """
681
+ return list(ws.get("errors") or [])
682
+
683
+ # ------------------------------------------------------------------
684
+ # Submission configuration
685
+ # ------------------------------------------------------------------
686
+
687
+ def get_submission_definitions(self) -> List[Dict[str, Any]]:
688
+ """Retrieves all submission definitions.
689
+
690
+ Returns:
691
+ List[Dict[str, Any]]: A list of submission definitions
692
+ """
693
+ return list(self._iter_embedded(self._url(CONFIG, "/submissiondefinitions"), "submissiondefinitions",
694
+ "List submission definitions"))
695
+
696
+ def get_submission_definition(self, name: str) -> Dict[str, Any]:
697
+ """Retrieves a submission definition by name.
698
+
699
+ Parameters:
700
+ name: The name of the submission definition
701
+
702
+ Returns:
703
+ Dict[str, Any]: The submission definition
704
+ """
705
+ return self._get_json(self._url(CONFIG, f"/submissiondefinitions/{name}"), f"Get submission definition {name}")
706
+
707
+ def get_definition_for_collection(self, collection_uuid: str) -> Dict[str, Any]:
708
+ return self._get_json(self._url(CONFIG, "/submissiondefinitions/search/findByCollection"),
709
+ f"Find submission definition of {collection_uuid}", params={"uuid": collection_uuid})
710
+
711
+ def get_definition_sections(self, definition_name: str) -> List[Dict[str, Any]]:
712
+ """
713
+ Sections of a definition in order: [{"id", "header", "mandatory", "sectionType", "scope", ...}]
714
+
715
+ Read from the definition itself (DSpace embeds them): the
716
+ .../submissiondefinitions/{name}/sections sub-resource answers
717
+ 204 No Content on DSpace 8-10.
718
+
719
+ Parameters:
720
+ definition_name: The name of the submission definition
721
+
722
+ Returns:
723
+ List[Dict[str, Any]]: A list of sections in the submission definition
724
+ """
725
+ if definition_name not in self._definition_cache:
726
+ definition = self._get_json(self._url(CONFIG, f"/submissiondefinitions/{definition_name}"),
727
+ f"Get submission definition {definition_name}",
728
+ embed={"sections": 500})
729
+ sections = _embedded_list((definition.get("_embedded") or {}).get("sections"), "sections")
730
+ if sections is None:
731
+ url = self._url(CONFIG, f"/submissiondefinitions/{definition_name}/sections")
732
+ page = self._get_optional(url, f"Sections of {definition_name}")
733
+ sections = _embedded_list(page, "sections", "submissionsections") or []
734
+ self._definition_cache[definition_name] = sections
735
+ return self._definition_cache[definition_name]
736
+
737
+ def get_submission_section(self, name: str) -> Dict[str, Any]:
738
+ """Retrieves a submission section by name.
739
+
740
+ Parameters:
741
+ name: The name of the submission section
742
+
743
+ Returns:
744
+ Dict[str, Any]: The submission section
745
+ """
746
+ return self._get_json(self._url(CONFIG, f"/submissionsections/{name}"), f"Get submission section {name}")
747
+
748
+ def get_submission_form(self, name: str) -> Dict[str, Any]:
749
+ """Retrieves a submission form by name.
750
+
751
+ Parameters:
752
+ name: The name of the submission form
753
+
754
+ Returns:
755
+ Dict[str, Any]: The submission form
756
+ """
757
+ return self._get_json(self._url(CONFIG, f"/submissionforms/{name}"), f"Get submission form {name}")
758
+
759
+ def get_submission_upload(self, name: str) -> Dict[str, Any]:
760
+ """Upload section config: accessConditionOptions, maxSize, required, ..
761
+
762
+ Parameters:
763
+ name: The name of the submission upload section
764
+
765
+ Returns:
766
+ Dict[str, Any]: The submission upload section configuration
767
+ """
768
+ return self._get_json(self._url(CONFIG, f"/submissionuploads/{name}"), f"Get submission upload {name}")
769
+
770
+ def get_access_options(self, name: str = "default") -> Dict[str, Any]:
771
+ """Retrieves item-level access conditions offered by an 'access' section.
772
+
773
+ Parameters:
774
+ name: The name of the access options section (default is "default")
775
+
776
+ Returns:
777
+ Dict[str, Any]: The access options
778
+ """
779
+ return self._get_json(self._url(CONFIG, f"/submissionaccessoptions/{name}"), f"Get access options {name}")
780
+
781
+ def get_cc_licenses(self) -> List[Dict[str, Any]]:
782
+ """Retrieves all Creative Commons licenses.
783
+
784
+ Returns:
785
+ List[Dict[str, Any]]: A list of CC licenses
786
+ """
787
+ return list(self._iter_embedded(self._url(CONFIG, "/submissioncclicenses"), "submissioncclicenses",
788
+ "List CC licenses"))
789
+
790
+ def get_cc_license(self, name: str) -> Dict[str, Any]:
791
+ """Retrieves a specific Creative Commons license by name.
792
+
793
+ Parameters:
794
+ name: The name of the CC license
795
+
796
+ Returns:
797
+ Dict[str, Any]: The CC license
798
+ """
799
+ return self._get_json(self._url(CONFIG, f"/submissioncclicenses/{name}"), f"Get CC license {name}")
800
+
801
+ def get_cc_license_url(self, license_name: str, answers: Optional[Dict[str, str]] = None) -> str:
802
+ """Retrieves the license URI for a CC license with optional answers.
803
+
804
+ Parameters:
805
+ license_name: The name of the CC license
806
+ answers: Optional dictionary of answers to license questions
807
+
808
+ Returns:
809
+ str: The license URI
810
+
811
+ The contract spells the resource both "submissioncclicenseUrls" and
812
+ "submissioncclicenseurls"; both are tried.
813
+ """
814
+ params = [("license", license_name)] + [(f"answer_{k}", v) for k, v in (answers or {}).items()]
815
+ last = None
816
+ for resource in ("submissioncclicenseUrls", "submissioncclicenseurls"):
817
+ try:
818
+ url = self._url(CONFIG, f"/{resource}/search/rightsByQuestions")
819
+ return self._get_json(url, f"CC license URL for {license_name}", params=params)["url"]
820
+ except DSpaceError as exc:
821
+ if getattr(exc, "status_code", None) != 404:
822
+ raise
823
+ last = exc
824
+ raise last
825
+
826
+ # ------------------------------------------------------------------
827
+ # Sections of a workspace item
828
+ # ------------------------------------------------------------------
829
+
830
+ def section_types(self, ws_id) -> Dict[str, str]:
831
+ """Retrieves the section types for the item's submission definition.
832
+
833
+ Parameters:
834
+ ws_id: The workspace item ID
835
+
836
+ Returns:
837
+ Dict[str, str]: A dictionary mapping section names to section types, in definition order
838
+ """
839
+ definition = self.get_definition_of(ws_id)
840
+ return {s["id"]: s.get("sectionType") for s in self.get_definition_sections(definition["name"])}
841
+
842
+ def section_name(self, ws_id, section_type: str) -> str:
843
+ """Name of the (first) section of a type, e.g. 'upload' -> 'upload' or 'uploadThesis'.
844
+
845
+ Parameters:
846
+ ws_id: The workspace item ID
847
+ section_type: The type of the section to find
848
+
849
+ Returns:
850
+ str: The name of the first section of the given type
851
+
852
+ Raises:
853
+ ValueError: If no section of the given type is found
854
+ """
855
+ for name, kind in self.section_types(ws_id).items():
856
+ if kind == section_type:
857
+ return name
858
+ raise ValueError(f"Workspace item {ws_id} has no {section_type!r} section in its submission definition")
859
+
860
+ def form_fields(self, section_name: str) -> List[str]:
861
+ """Retrieves the metadata fields offered by a submission-form section (all selectableMetadata of all rows).
862
+
863
+ Parameters:
864
+ section_name: The name of the submission form section
865
+
866
+ Returns:
867
+ List[str]: A list of metadata field names
868
+ """
869
+ if section_name not in self._form_fields_cache:
870
+ section = self.get_submission_section(section_name)
871
+ href = ((section.get("_links") or {}).get("config") or {}).get("href")
872
+ form = self.get_by_href(href) if href else self.get_submission_form(section_name)
873
+ fields: List[str] = []
874
+ for row in form.get("rows") or []:
875
+ for field in row.get("fields") or []:
876
+ for selectable in field.get("selectableMetadata") or []:
877
+ name = selectable.get("metadata")
878
+ if name and name not in fields:
879
+ fields.append(name)
880
+ self._form_fields_cache[section_name] = fields
881
+ return self._form_fields_cache[section_name]
882
+
883
+ def route_metadata(self, ws_id, metadata: Dict[str, Any], ws: Optional[Dict[str, Any]] = None
884
+ ) -> Tuple[Dict[str, Dict[str, Any]], List[str]]:
885
+ """Routes metadata fields to the submission-form sections that offer them.
886
+
887
+ Parameters:
888
+ ws_id: The workspace item ID
889
+ metadata: A dictionary of metadata fields and their values
890
+ ws: Optional workspace item dictionary (retrieved if not provided)
891
+
892
+ Returns:
893
+ Tuple[Dict[str, Dict[str, Any]], List[str]]: A tuple containing:
894
+ - A dictionary mapping section names to dictionaries of field-value pairs
895
+ - A list of fields that could not be routed to any section
896
+
897
+ Only sections active in the current submission phase are considered.
898
+ """
899
+ ws = ws or self.get(ws_id)
900
+ active = set((ws.get("sections") or {}).keys())
901
+ forms = [name for name, kind in self.section_types(ws_id).items()
902
+ if kind == "submission-form" and (not active or name in active)]
903
+ routed: Dict[str, Dict[str, Any]] = {}
904
+ unroutable: List[str] = []
905
+ for field, values in metadata.items():
906
+ section = next((f for f in forms if field in self.form_fields(f)), None)
907
+ if section is None:
908
+ unroutable.append(field)
909
+ else:
910
+ routed.setdefault(section, {})[field] = values
911
+ return routed, unroutable
912
+
913
+ def fill_metadata(self, ws_id, metadata: Dict[str, Any], strict: bool = True) -> Dict[str, Any]:
914
+ """
915
+ Sets metadata fields on a submission, each field in the form section
916
+ that offers it (replacing existing values). Fields no form offers raise
917
+ ValueError (strict) or are skipped with a warning.
918
+
919
+ Parameters:
920
+ ws_id: The workspace item ID
921
+ metadata: A dictionary of metadata fields and their values
922
+ strict: If True, raise an error for unroutable fields; otherwise, log a warning
923
+
924
+ Returns:
925
+ Dict[str, Any]: The updated workspace item
926
+ """
927
+ ws = self.get(ws_id)
928
+ routed, unroutable = self.route_metadata(ws_id, metadata, ws)
929
+ if unroutable:
930
+ message = f"No submission form of workspace item {ws_id} offers {unroutable}"
931
+ if strict:
932
+ raise ValueError(message + " - add them to the form, or set them on the item after deposit")
933
+ logger.warning("%s - skipped", message)
934
+ patch = MetadataPatch()
935
+ for section, fields in routed.items():
936
+ section_patch = MetadataPatch.for_section(section)
937
+ for field, values in fields.items():
938
+ section_patch.set(field, values)
939
+ patch.extend(section_patch)
940
+ return self.apply_patch(ws_id, patch) if len(patch) else ws
941
+
942
+ def patch_section_metadata(self, ws_id, section: str, patch: Union[MetadataPatch, Dict[str, Any]]
943
+ ) -> Dict[str, Any]:
944
+ """Applies a metadata patch to a specific section of the workspace item.
945
+
946
+ Parameters:
947
+ ws_id: The workspace item ID
948
+ section: The name of the section to patch
949
+ patch: Either a MetadataPatch object for the section or a dictionary of field-value pairs (each replaces the field)
950
+
951
+ Returns:
952
+ Dict[str, Any]: The updated workspace item
953
+ """
954
+ if isinstance(patch, dict):
955
+ built = MetadataPatch.for_section(section)
956
+ for field, values in patch.items():
957
+ built.set(field, values)
958
+ patch = built
959
+ return self.apply_patch(ws_id, patch)
960
+
961
+ # ------------------------------------------------------------------
962
+ # upload section
963
+ # ------------------------------------------------------------------
964
+
965
+ def upload_file(self, ws_id, source: Any, name: Optional[str] = None, mime_type: Optional[str] = None,
966
+ timeout: Optional[Union[float, Tuple[float, float]]] = None, progress=None,
967
+ chunk_size: Optional[int] = None) -> Dict[str, Any]:
968
+ """
969
+ Uploads a file to the workspace item (multipart POST); returns the updated workspace item.
970
+
971
+ Parameters:
972
+ ws_id: The workspace item ID
973
+ source: The file source (path, file-like object, etc.)
974
+ name: Optional name of the file
975
+ mime_type: Optional MIME type of the file
976
+ timeout: Optional timeout for the upload
977
+ progress: Optional progress callback (bytes_sent, total)
978
+ chunk_size: Optional chunk size for the upload
979
+
980
+ Returns:
981
+ Dict[str, Any]: The updated workspace item
982
+
983
+ Notes:
984
+ The upload is streamed like BundleManager.upload_bitstream: any size, constant memory,
985
+ read timeout scaled to the file size, progress(bytes_sent, total) per chunk.
986
+ """
987
+ import mimetypes
988
+ from .bundles import BundleManager, _MultipartStream, _OpenedSource
989
+ with _OpenedSource(source, name) as src:
990
+ mime_type = mime_type or mimetypes.guess_type(src.name)[0] or "application/octet-stream"
991
+ body = _MultipartStream(src.file, src.size, src.name, mime_type, None,
992
+ chunk_size=chunk_size or BundleManager.UPLOAD_CHUNK_SIZE, progress=progress)
993
+ headers = self._headers(content_type=None)
994
+ headers["Content-Type"] = body.content_type
995
+ headers["Content-Length"] = str(len(body))
996
+ logger.info("Uploading %s (%d bytes) to workspace item %s", src.name, src.size, ws_id)
997
+ return self._request("POST", self._ws_url(ws_id), f"Upload {src.name} to workspace item {ws_id}",
998
+ expected=(200, 201), headers=headers, data=body,
999
+ timeout=timeout or BundleManager(self.auth)._upload_timeout(src.size)).json()
1000
+
1001
+ @staticmethod
1002
+ def uploaded_files(ws: Dict[str, Any]) -> List[Tuple[str, int, Dict[str, Any]]]:
1003
+ """Returns a list of uploaded files for a workspace item.
1004
+
1005
+ Each item in the list is a tuple (section, index, file) where:
1006
+ section: The section name in the workspace item
1007
+ index: The index of the file within the section
1008
+ file: The file dictionary from the workspace item JSON
1009
+
1010
+ Parameters:
1011
+ ws: The workspace item JSON dictionary
1012
+
1013
+ Returns:
1014
+ List[Tuple[str, int, Dict[str, Any]]]: A list of uploaded files with their section and index.
1015
+ """
1016
+ result = []
1017
+ for section, data in (ws.get("sections") or {}).items():
1018
+ if isinstance(data, dict) and isinstance(data.get("files"), list):
1019
+ result.extend((section, i, f) for i, f in enumerate(data["files"]))
1020
+ return result
1021
+
1022
+ @classmethod
1023
+ def find_file(cls, ws: Dict[str, Any], name_or_uuid: str) -> Tuple[str, int, Dict[str, Any]]:
1024
+ """Locate an uploaded file by bitstream uuid or title (dc.title).
1025
+
1026
+ Parameters:
1027
+ ws: The workspace item JSON dictionary
1028
+ name_or_uuid: The bitstream UUID or title to search for
1029
+
1030
+ Returns:
1031
+ Tuple[str, int, Dict[str, Any]]: A tuple containing the section name, file index, and file dictionary
1032
+
1033
+ Raises:
1034
+ LookupError: If no matching file is found
1035
+ """
1036
+ for section, index, file in cls.uploaded_files(ws):
1037
+ titles = [v.get("value") for v in (file.get("metadata") or {}).get("dc.title") or []]
1038
+ if file.get("uuid") == name_or_uuid or name_or_uuid in titles:
1039
+ return section, index, file
1040
+ raise LookupError(f"No uploaded file {name_or_uuid!r} in workspace item {ws.get('id')}")
1041
+
1042
+ def _upload_section(self, ws_id, section: Optional[str]) -> str:
1043
+ """Determine the upload section for a workspace item.
1044
+
1045
+ Parameters:
1046
+ ws_id: The workspace item ID
1047
+ section: The section name, if specified
1048
+
1049
+ Returns:
1050
+ str: The section name to use for uploads
1051
+ """
1052
+ return section or self.section_name(ws_id, "upload")
1053
+
1054
+ def patch_file_metadata(self, ws_id, file_index: int, patch: Union[MetadataPatch, Dict[str, Any]],
1055
+ section: Optional[str] = None) -> Dict[str, Any]:
1056
+ """MetadataPatch.for_file(section, i)... or {field: values} for the uploaded file at file_index.
1057
+
1058
+ Parameters:
1059
+ ws_id: The workspace item ID
1060
+ file_index: The index of the uploaded file to patch
1061
+ patch: The metadata patch to apply (MetadataPatch or dictionary)
1062
+ section: The section name, if specified
1063
+
1064
+ Returns:
1065
+ Dict[str, Any]: The updated workspace item after applying the patch
1066
+ """
1067
+ section = self._upload_section(ws_id, section)
1068
+ if isinstance(patch, dict):
1069
+ built = MetadataPatch.for_file(section, file_index)
1070
+ for field, values in patch.items():
1071
+ built.set(field, values)
1072
+ patch = built
1073
+ return self.apply_patch(ws_id, patch)
1074
+
1075
+ @staticmethod
1076
+ def access_condition(name: str, start_date=None, end_date=None) -> Dict[str, Any]:
1077
+ """Create an access condition dictionary.
1078
+
1079
+ Parameters:
1080
+ name: The name of the access condition (e.g., "embargo")
1081
+ start_date: The start date of the access condition (optional)
1082
+ end_date: The end date of the access condition (optional)
1083
+
1084
+ Returns:
1085
+ Dict[str, Any]: The access condition dictionary
1086
+ """
1087
+ from .permissions import _fmt_date
1088
+ condition = {"name": name}
1089
+ if start_date:
1090
+ condition["startDate"] = _fmt_date(start_date)
1091
+ if end_date:
1092
+ condition["endDate"] = _fmt_date(end_date)
1093
+ return condition
1094
+
1095
+ def add_file_access_condition(self, ws_id, file_index: int, name: str, start_date=None, end_date=None,
1096
+ section: Optional[str] = None) -> Dict[str, Any]:
1097
+ """Add an access condition to a file in the workspace item.
1098
+
1099
+ Parameters:
1100
+ ws_id: The workspace item ID
1101
+ file_index: The index of the uploaded file
1102
+ name: The name of the access condition (e.g., "embargo")
1103
+ start_date: The start date of the access condition (optional)
1104
+ end_date: The end date of the access condition (optional)
1105
+ section: The section name, if specified
1106
+
1107
+ Returns:
1108
+ Dict[str, Any]: The updated workspace item after adding the access condition
1109
+
1110
+ Example:
1111
+ add_file_access_condition(ws_id, 0, "embargo", start_date="2027-01-01")
1112
+ """
1113
+ section = self._upload_section(ws_id, section)
1114
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/files/{file_index}/accessConditions/-",
1115
+ "value": self.access_condition(name, start_date, end_date)}])
1116
+
1117
+ def set_file_access_conditions(self, ws_id, file_index: int, conditions: Sequence[Dict[str, Any]],
1118
+ section: Optional[str] = None) -> Dict[str, Any]:
1119
+ """Replace all access conditions of one file.
1120
+
1121
+ Parameters:
1122
+ ws_id: The workspace item ID
1123
+ file_index: The index of the uploaded file
1124
+ conditions: A sequence of access condition dictionaries
1125
+ section: The section name, if specified
1126
+
1127
+ Returns:
1128
+ Dict[str, Any]: The updated workspace item after setting the access conditions
1129
+ """
1130
+ section = self._upload_section(ws_id, section)
1131
+ value = [self.access_condition(c["name"], c.get("startDate"), c.get("endDate")) for c in conditions]
1132
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/files/{file_index}/accessConditions",
1133
+ "value": value}])
1134
+
1135
+ def remove_file_access_condition(self, ws_id, file_index: int, index: Optional[int] = None,
1136
+ section: Optional[str] = None) -> Dict[str, Any]:
1137
+ """Remove one access condition, or all of them (index=None)."""
1138
+ section = self._upload_section(ws_id, section)
1139
+ path = f"/sections/{section}/files/{file_index}/accessConditions" + ("" if index is None else f"/{index}")
1140
+ return self.apply_patch(ws_id, [{"op": "remove", "path": path}])
1141
+
1142
+ def remove_file(self, ws_id, file_index: int, section: Optional[str] = None) -> Dict[str, Any]:
1143
+ """Remove an uploaded file from the workspace item.
1144
+
1145
+ Parameters:
1146
+ ws_id: The workspace item ID
1147
+ file_index: The index of the uploaded file to remove
1148
+ section: The section name, if specified
1149
+
1150
+ Returns:
1151
+ Dict[str, Any]: The updated workspace item after removing the file
1152
+ """
1153
+ section = self._upload_section(ws_id, section)
1154
+ return self.apply_patch(ws_id, [{"op": "remove", "path": f"/sections/{section}/files/{file_index}"}])
1155
+
1156
+ def move_file(self, ws_id, from_index: int, to_index: int, section: Optional[str] = None) -> Dict[str, Any]:
1157
+ section = self._upload_section(ws_id, section)
1158
+ return self.apply_patch(ws_id, [{"op": "move", "from": f"/sections/{section}/files/{from_index}",
1159
+ "path": f"/sections/{section}/files/{to_index}"}])
1160
+
1161
+ def set_primary_file(self, ws_id, bitstream_uuid: str, section: Optional[str] = None) -> Dict[str, Any]:
1162
+ """Mark an uploaded file as primary ('add' both sets and changes it).
1163
+
1164
+ Parameters:
1165
+ ws_id: The workspace item ID
1166
+ bitstream_uuid: The UUID of the bitstream to set as primary
1167
+ section: The section name, if specified
1168
+
1169
+ Returns:
1170
+ Dict[str, Any]: The updated workspace item after setting the primary file
1171
+ """
1172
+ section = self._upload_section(ws_id, section)
1173
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/primary", "value": bitstream_uuid}])
1174
+
1175
+ def clear_primary_file(self, ws_id, section: Optional[str] = None) -> Dict[str, Any]:
1176
+ """Clear the primary file of the workspace item.
1177
+
1178
+ Parameters:
1179
+ ws_id: The workspace item ID
1180
+ section: The section name, if specified
1181
+
1182
+ Returns:
1183
+ Dict[str, Any]: The updated workspace item after clearing the primary file
1184
+ """
1185
+ section = self._upload_section(ws_id, section)
1186
+ return self.apply_patch(ws_id, [{"op": "remove", "path": f"/sections/{section}/primary"}])
1187
+
1188
+ # ------------------------------------------------------------------
1189
+ # license / access / cclicense / sherpa sections
1190
+ # ------------------------------------------------------------------
1191
+
1192
+ def grant_license(self, ws_id, granted: bool = True, section: Optional[str] = None) -> Dict[str, Any]:
1193
+ """Accept (or with granted=False reject) the deposit license.
1194
+
1195
+ Parameters:
1196
+ ws_id: The workspace item ID
1197
+ granted: Whether the license is granted (True) or rejected (False)
1198
+ section: The section name, if specified
1199
+
1200
+ Returns:
1201
+ Dict[str, Any]: The updated workspace item after granting or rejecting the license
1202
+ """
1203
+ section = section or self.section_name(ws_id, "license")
1204
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/granted", "value": bool(granted)}])
1205
+
1206
+ def add_access_condition(self, ws_id, name: str, start_date=None, end_date=None,
1207
+ section: Optional[str] = None) -> Dict[str, Any]:
1208
+ """Item-level access condition (access section); options: get_access_options().
1209
+
1210
+ Parameters:
1211
+ ws_id: The workspace item ID
1212
+ name: The name of the access condition (e.g., "embargo")
1213
+ start_date: The start date of the access condition (optional)
1214
+ end_date: The end date of the access condition (optional)
1215
+ section: The section name, if specified
1216
+
1217
+ Returns:
1218
+ Dict[str, Any]: The updated workspace item after adding the access condition
1219
+ """
1220
+ section = section or self.section_name(ws_id, "access")
1221
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/accessConditions/-",
1222
+ "value": self.access_condition(name, start_date, end_date)}])
1223
+
1224
+ def set_access_conditions(self, ws_id, conditions: Sequence[Dict[str, Any]],
1225
+ section: Optional[str] = None) -> Dict[str, Any]:
1226
+ """Replace all access conditions of the workspace item.
1227
+
1228
+ Parameters:
1229
+ ws_id: The workspace item ID
1230
+ conditions: A sequence of access condition dictionaries
1231
+ section: The section name, if specified
1232
+
1233
+ Returns:
1234
+ Dict[str, Any]: The updated workspace item after setting the access conditions
1235
+ """
1236
+ section = section or self.section_name(ws_id, "access")
1237
+ value = [self.access_condition(c["name"], c.get("startDate"), c.get("endDate")) for c in conditions]
1238
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/accessConditions", "value": value}])
1239
+
1240
+ def remove_access_condition(self, ws_id, index: Optional[int] = None, section: Optional[str] = None
1241
+ ) -> Dict[str, Any]:
1242
+ """Remove an access condition from the workspace item.
1243
+
1244
+ Parameters:
1245
+ ws_id: The workspace item ID
1246
+ index: The index of the access condition to remove (optional)
1247
+ section: The section name, if specified
1248
+
1249
+ Returns:
1250
+ Dict[str, Any]: The updated workspace item after removing the access condition
1251
+ """
1252
+ section = section or self.section_name(ws_id, "access")
1253
+ path = f"/sections/{section}/accessConditions" + ("" if index is None else f"/{index}")
1254
+ return self.apply_patch(ws_id, [{"op": "remove", "path": path}])
1255
+
1256
+ def set_discoverable(self, ws_id, discoverable: bool, section: Optional[str] = None) -> Dict[str, Any]:
1257
+ section = section or self.section_name(ws_id, "access")
1258
+ return self.apply_patch(ws_id, [{"op": "replace", "path": f"/sections/{section}/discoverable",
1259
+ "value": bool(discoverable)}])
1260
+
1261
+ def set_cc_license(self, ws_id, license_uri: str, section: Optional[str] = None) -> Dict[str, Any]:
1262
+ """Apply a Creative Commons license by URI (see get_cc_license_url).
1263
+
1264
+ Parameters:
1265
+ ws_id: The workspace item ID
1266
+ license_uri: The URI of the Creative Commons license
1267
+ section: The section name, if specified
1268
+
1269
+ Returns:
1270
+ Dict[str, Any]: The updated workspace item after setting the CC license
1271
+ """
1272
+ section = section or self.section_name(ws_id, "cclicense")
1273
+ return self.apply_patch(ws_id, [{"op": "add", "path": f"/sections/{section}/uri", "value": license_uri}])
1274
+
1275
+ def remove_cc_license(self, ws_id, section: Optional[str] = None) -> Dict[str, Any]:
1276
+ """Remove the Creative Commons license from the workspace item.
1277
+
1278
+ Parameters:
1279
+ ws_id: The workspace item ID
1280
+ section: The section name, if specified
1281
+
1282
+ Returns:
1283
+ Dict[str, Any]: The updated workspace item after removing the CC license
1284
+ """
1285
+ section = section or self.section_name(ws_id, "cclicense")
1286
+ return self.apply_patch(ws_id, [{"op": "remove", "path": f"/sections/{section}/uri"}])
1287
+
1288
+ def refresh_sherpa_policies(self, ws_id, section: Optional[str] = None) -> Dict[str, Any]:
1289
+ """Drop the cached Sherpa data so it is fetched again.
1290
+
1291
+ Parameters:
1292
+ ws_id: The workspace item ID
1293
+ section: The section name, if specified
1294
+
1295
+ Returns:
1296
+ Dict[str, Any]: The updated workspace item after refreshing Sherpa policies
1297
+ """
1298
+ section = section or self.section_name(ws_id, "sherpaPolicies")
1299
+ return self.apply_patch(ws_id, [{"op": "remove", "path": f"/sections/{section}/retrievalTime"}])
1300
+
1301
+ # ------------------------------------------------------------------
1302
+ # Deposit
1303
+ # ------------------------------------------------------------------
1304
+
1305
+ def deposit(self, ws_id) -> Dict[str, Any]:
1306
+ """
1307
+ Send the submission into workflow (or straight to the archive when the
1308
+ collection has no workflow). Returns the workflow item JSON ({} if the
1309
+ server sent no body). Validation problems raise SubmissionError with
1310
+ the workspace item's errors.
1311
+
1312
+ Parameters:
1313
+ ws_id: The workspace item ID
1314
+
1315
+ Returns:
1316
+ Dict[str, Any]: The workflow item JSON after deposit ({} if the server sent no body)
1317
+ """
1318
+ url = self._url(Endpoints.WORKFLOW_ITEMS)
1319
+ try:
1320
+ response = self._request("POST", url, f"Deposit workspace item {ws_id}", expected=(200, 201),
1321
+ headers=self._headers("text/uri-list"), data=self._ws_url(ws_id))
1322
+ except DSpaceError as exc:
1323
+ if getattr(exc, "status_code", None) != 422:
1324
+ raise
1325
+ ws = self.get(ws_id)
1326
+ errors = self.validation_errors(ws)
1327
+ raise SubmissionError(f"Workspace item {ws_id} is not complete: {describe_errors(errors)}", errors=errors,
1328
+ workspace_item=ws, status_code=422, response_text=exc.response_text) from exc
1329
+ logger.info("Deposited workspace item %s", ws_id)
1330
+ return self._json_or_none(response) or {}
1331
+
1332
+ # ------------------------------------------------------------------
1333
+ # One call
1334
+ # ------------------------------------------------------------------
1335
+
1336
+ def submit(self, collection_uuid: str, metadata: Dict[str, Any], files: Sequence[Any] = (),
1337
+ file_access_conditions: Optional[Sequence[Dict[str, Any]]] = None,
1338
+ access_conditions: Optional[Sequence[Dict[str, Any]]] = None,
1339
+ grant_license: bool = True, deposit: bool = True, entity_type: Optional[str] = None,
1340
+ strict_metadata: bool = True, delete_on_error: bool = False) -> Dict[str, Any]:
1341
+ """
1342
+ Complete submission in one call:
1343
+
1344
+ result = ws.submit(coll, {"dc.title": "Thesis", "dc.contributor.author": ["Doe, Jane"],
1345
+ "dc.date.issued": "2026"},
1346
+ files=["thesis.pdf"],
1347
+ file_access_conditions=[{"name": "embargo", "startDate": "2027-01-01"}])
1348
+ result["item_uuid"]
1349
+
1350
+ files: paths, bytes with a name as (bytes, "name.pdf"), or binary file objects.
1351
+ file_access_conditions apply to every uploaded file; access_conditions
1352
+ need an 'access' section. The license is granted if the definition has
1353
+ a license section. With deposit=False the workspace item is left for review.
1354
+
1355
+ Returns {"workspace_item", "workflow_item", "item_uuid", "errors"}.
1356
+ Validation errors raise SubmissionError (deposit=True) - the workspace
1357
+ item is kept for inspection unless delete_on_error=True.
1358
+
1359
+ Parameters:
1360
+ collection_uuid: The UUID of the collection to submit to
1361
+ metadata: A dictionary of metadata for the submission
1362
+ files: A sequence of file paths, bytes with a name, or binary file objects
1363
+ file_access_conditions: A sequence of access condition dictionaries for the files
1364
+ access_conditions: A sequence of access condition dictionaries for the workspace item
1365
+ grant_license: Whether to grant the license if applicable
1366
+ deposit: Whether to deposit the item immediately
1367
+ entity_type: The entity type of the submission, if specified
1368
+ strict_metadata: Whether to enforce strict metadata validation
1369
+ delete_on_error: Whether to delete the workspace item on error
1370
+
1371
+ Returns:
1372
+ Dict[str, Any]: A dictionary containing the workspace item, workflow item, item UUID, and any validation errors
1373
+ """
1374
+ ws = self.start_submission(collection_uuid, entity_type)
1375
+ ws_id = ws["id"]
1376
+ try:
1377
+ ws = self.fill_metadata(ws_id, metadata, strict=strict_metadata)
1378
+ types = self.section_types(ws_id)
1379
+ for source in files:
1380
+ ws = self.upload_file(ws_id, *(source if isinstance(source, tuple) else (source,)))
1381
+ if file_access_conditions:
1382
+ for section, index, _file in self.uploaded_files(ws):
1383
+ ws = self.set_file_access_conditions(ws_id, index, file_access_conditions, section=section)
1384
+ if access_conditions:
1385
+ ws = self.set_access_conditions(ws_id, access_conditions)
1386
+ if grant_license and "license" in types.values():
1387
+ ws = self.grant_license(ws_id)
1388
+ item_uuid = self.get_item_of(ws_id).get("uuid")
1389
+ result = {"workspace_item": ws, "workflow_item": None, "item_uuid": item_uuid,
1390
+ "errors": self.validation_errors(ws)}
1391
+ if deposit:
1392
+ if result["errors"]:
1393
+ raise SubmissionError(f"Workspace item {ws_id} is not complete: {describe_errors(result['errors'])}",
1394
+ errors=result["errors"],
1395
+ workspace_item=ws)
1396
+ result["workflow_item"] = self.deposit(ws_id)
1397
+ return result
1398
+ except Exception:
1399
+ if delete_on_error:
1400
+ try:
1401
+ self.delete(ws_id)
1402
+ except DSpaceError: # pragma: no cover - best effort
1403
+ logger.warning("Could not delete workspace item %s after a failed submission", ws_id)
1404
+ raise