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/__init__.py +75 -0
- dspace_api/_base.py +407 -0
- dspace_api/_containers.py +217 -0
- dspace_api/_version.py +4 -0
- dspace_api/auth.py +467 -0
- dspace_api/authorizations.py +486 -0
- dspace_api/bundles.py +1193 -0
- dspace_api/collections.py +1069 -0
- dspace_api/communities.py +363 -0
- dspace_api/config.py +254 -0
- dspace_api/discovery.py +920 -0
- dspace_api/entity_types.py +127 -0
- dspace_api/epersons.py +647 -0
- dspace_api/exceptions.py +110 -0
- dspace_api/groups.py +599 -0
- dspace_api/items.py +549 -0
- dspace_api/metadata.py +742 -0
- dspace_api/permissions.py +1052 -0
- dspace_api/projection.py +292 -0
- dspace_api/relationships.py +694 -0
- dspace_api/session.py +538 -0
- dspace_api/site.py +98 -0
- dspace_api/utils.py +519 -0
- dspace_api/versions.py +292 -0
- dspace_api/vocabulary.py +365 -0
- dspace_api/workflow.py +893 -0
- dspace_api/workspaces.py +1404 -0
- dspace_api_toolkit-0.1.1.dist-info/METADATA +211 -0
- dspace_api_toolkit-0.1.1.dist-info/RECORD +32 -0
- dspace_api_toolkit-0.1.1.dist-info/WHEEL +5 -0
- dspace_api_toolkit-0.1.1.dist-info/licenses/LICENSE +28 -0
- dspace_api_toolkit-0.1.1.dist-info/top_level.txt +1 -0
dspace_api/workspaces.py
ADDED
|
@@ -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
|