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/items.py ADDED
@@ -0,0 +1,549 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Item management for DSpace API - handles archived items
4
+ """
5
+
6
+ import logging
7
+ import json
8
+ from typing import Dict, List, Optional
9
+ from .config import Endpoints
10
+ from .projection import view_params
11
+ from .exceptions import handle_response_error, BadRequestError
12
+
13
+ # Logger for the module
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ItemManager:
18
+ """Manages DSpace archived items"""
19
+
20
+ def __init__(self, auth):
21
+ """
22
+ Initialize the ItemManager with authentication and configuration.
23
+
24
+ Parameters:
25
+ auth: Authentication object containing session and configuration.
26
+ """
27
+ self.auth = auth
28
+ self.session = auth.session
29
+ self.config = auth.config
30
+
31
+ def get_items(self, page=0, size=20, sort=None, all_languages=None, projection=None, embed=None):
32
+ """
33
+ Get a list of items from DSpace
34
+
35
+ Parameters:
36
+ page: Page number (0-based)
37
+ size: Number of items per page
38
+ sort: Sort criteria
39
+ all_languages: projection=allLanguages for this call (None = client setting)
40
+ projection / embed: shape the response, e.g. embed="owningCollection" (see projection.py)
41
+
42
+ Returns:
43
+ dict: API response containing items
44
+ """
45
+ logger.info("Getting items (page=%d, size=%d)", page, size)
46
+
47
+ url = self.config.dspace_url + Endpoints.ITEMS
48
+ params = {"page": page, "size": size}
49
+ if sort:
50
+ params["sort"] = sort
51
+ params = view_params(params, self.config, all_languages, projection, embed)
52
+
53
+ try:
54
+ response = self.session.get(
55
+ url,
56
+ headers=self.config.headers,
57
+ params=params,
58
+ timeout=self.config.timeout
59
+ )
60
+
61
+ if response.status_code == 200:
62
+ logger.info("Successfully retrieved items")
63
+ return response.json()
64
+ else:
65
+ handle_response_error(response, "Get items")
66
+
67
+ except Exception as e:
68
+ logger.error("Failed to get items: %s", e)
69
+ raise
70
+
71
+ def get_item(self, item_uuid, all_languages=None, projection=None, embed=None):
72
+ """
73
+ Get a specific item by UUID
74
+
75
+ Parameters:
76
+ item_uuid: UUID of the item
77
+ all_languages: projection=allLanguages for this call (None = client setting)
78
+ projection / embed: shape the response, e.g. embed=["bundles/bitstreams", "owningCollection"]
79
+ (see projection.py; read results with projection.get_embedded)
80
+
81
+ Returns:
82
+ dict: Item data
83
+ """
84
+ logger.info("Getting item %s", item_uuid)
85
+
86
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}"
87
+ params = view_params(None, self.config, all_languages, projection, embed)
88
+
89
+ try:
90
+ response = self.session.get(
91
+ url,
92
+ headers=self.config.headers,
93
+ params=params,
94
+ timeout=self.config.timeout
95
+ )
96
+
97
+ if response.status_code == 200:
98
+ logger.info("Successfully retrieved item %s", item_uuid)
99
+ return response.json()
100
+ else:
101
+ handle_response_error(response, f"Get item {item_uuid}")
102
+
103
+ except Exception as e:
104
+ logger.error("Failed to get item %s: %s", item_uuid, e)
105
+ raise
106
+
107
+ def create_item(self, collection_uuid, metadata, entity_type,
108
+ in_archive=True, discoverable=True, withdrawn=False):
109
+ """
110
+ Create an archived item directly in a collection
111
+
112
+ Parameters:
113
+ collection_uuid: UUID of the owning collection
114
+ metadata: Dictionary of metadata fields
115
+ entity_type: Type of entity (e.g., "Publication"); None for a plain item
116
+ in_archive: Whether item should be archived immediately
117
+ discoverable: Whether item should be discoverable
118
+ withdrawn: Whether item is withdrawn
119
+
120
+ Returns:
121
+ dict: Created item data
122
+ """
123
+ logger.info("Creating item in collection %s", collection_uuid)
124
+
125
+ # Prepare item data
126
+ item_data = {
127
+ "metadata": self._format_metadata(metadata),
128
+ "inArchive": in_archive,
129
+ "discoverable": discoverable,
130
+ "withdrawn": withdrawn,
131
+ "type": "item"
132
+ }
133
+
134
+ # entity_type=None: plain item (repositories without configurable entities)
135
+ if entity_type:
136
+ item_data["entityType"] = entity_type
137
+ # Add entity type to metadata if not present
138
+ if "dspace.entity.type" not in item_data["metadata"]:
139
+ item_data["metadata"]["dspace.entity.type"] = [{"value": entity_type}]
140
+
141
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}?owningCollection={collection_uuid}"
142
+
143
+ try:
144
+ response = self.session.post(
145
+ url,
146
+ headers=self.config.headers,
147
+ data=json.dumps(item_data),
148
+ timeout=self.config.timeout
149
+ )
150
+
151
+ if response.status_code == 201:
152
+ logger.info("Item created successfully")
153
+ return response.json()
154
+ else:
155
+ handle_response_error(response, "Create item")
156
+
157
+ except Exception as e:
158
+ logger.error("Failed to create item: %s", e)
159
+ raise
160
+
161
+ def update_item(self, item_uuid, operation, metadata):
162
+ """
163
+ Update item metadata
164
+
165
+ Parameters:
166
+ item_uuid: UUID of the item to update
167
+ operation: JSON-Patch operation to perform (e.g., "add", "replace", "remove")
168
+ metadata: Dictionary of metadata fields to update
169
+
170
+ Returns:
171
+ dict: Updated item data
172
+ """
173
+ logger.info("Updating item %s", item_uuid)
174
+
175
+ # Create patch operations for metadata updates
176
+ patch_operations = []
177
+
178
+ for field, values in metadata.items():
179
+ patch_operations.append({
180
+ "op": operation, # add, replace, remove
181
+ "path": f"/metadata/{field}",
182
+ "value": self._format_metadata_field(values)
183
+ })
184
+
185
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}"
186
+
187
+ try:
188
+ response = self.session.patch(
189
+ url,
190
+ headers=self.config.headers,
191
+ json=patch_operations,
192
+ timeout=self.config.timeout
193
+ )
194
+
195
+ if response.status_code == 200:
196
+ logger.info("Item %s updated successfully", item_uuid)
197
+ return response.json()
198
+ else:
199
+ handle_response_error(response, f"Update item {item_uuid}")
200
+
201
+ except Exception as e:
202
+ logger.error("Failed to update item %s: %s", item_uuid, e)
203
+ raise
204
+
205
+ def upsert_metadata(self, item_uuid, metadata, existing_metadata=None):
206
+ """
207
+ Add-or-replace metadata fields on an already-archived item, choosing
208
+ the correct JSON-Patch op per field automatically.
209
+
210
+ Plain PATCH "replace" on a metadata path that doesn't exist yet on
211
+ the item can be rejected by DSpace, and plain "add" on a path that
212
+ already has values just appends (creating duplicates) instead of
213
+ overwriting. This method looks at what the item currently has and
214
+ picks "replace" for fields it already carries and "add" for fields
215
+ that are genuinely new, so a single call is safe to use both for
216
+ first-time enrichment and for repeat syncs.
217
+
218
+ Parameters:
219
+ item_uuid: UUID of the item to update
220
+ metadata: dict of field -> value(s). Values may be plain
221
+ strings, lists of strings, or lists of pre-formatted
222
+ metadata-value dicts (e.g. to set authority/confidence for
223
+ a controlled or linked field such as dc.relation.project).
224
+ existing_metadata: optional dict as returned by item["metadata"]
225
+ (field -> list of value dicts). If omitted, the item is
226
+ fetched first so the add/replace decision can be made
227
+ safely.
228
+
229
+ Returns:
230
+ dict: updated item data
231
+ """
232
+ if existing_metadata is None:
233
+ # all languages: a field with values only in another language still exists
234
+ current = self.get_item(item_uuid, all_languages=True)
235
+ existing_metadata = current.get("metadata", {}) or {}
236
+
237
+ logger.info("Upserting metadata on item %s (%d field(s))", item_uuid, len(metadata))
238
+
239
+ patch_operations = []
240
+ for field, values in metadata.items():
241
+ op = "replace" if existing_metadata.get(field) else "add"
242
+ patch_operations.append({
243
+ "op": op,
244
+ "path": f"/metadata/{field}",
245
+ "value": self._format_metadata_field(values),
246
+ })
247
+
248
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}"
249
+
250
+ try:
251
+ response = self.session.patch(
252
+ url,
253
+ headers=self.config.headers,
254
+ json=patch_operations,
255
+ timeout=self.config.timeout
256
+ )
257
+
258
+ if response.status_code == 200:
259
+ logger.info("Item %s upserted successfully", item_uuid)
260
+ return response.json()
261
+ else:
262
+ handle_response_error(response, f"Upsert item {item_uuid}")
263
+
264
+ except Exception as e:
265
+ logger.error("Failed to upsert item %s: %s", item_uuid, e)
266
+ raise
267
+
268
+
269
+ # STT -------------------------------------------------------------------------------------------------------------------------
270
+
271
+ def upsert_metadata_by_position(self, item_uuid, metadata):
272
+ """
273
+ IMPORTANT: This function is an adapted version of 'upsert_metadata' because as of 2026-07-29 there is an issue with patch when the field is a list.
274
+ https://github.com/DSpace/DSpace/issues/12419
275
+ https://github.com/DSpace/DSpace/pull/12428
276
+ https://github.com/DSpace/DSpace/pull/12467
277
+
278
+ Add-or-replace metadata fields on an already-archived item, choosing
279
+ the correct JSON-Patch op per field automatically depending on metadata place-value.
280
+
281
+ Plain PATCH "replace" on a metadata path that doesn't exist yet on
282
+ the item can be rejected by DSpace, and plain "add" on a path that
283
+ already has values just appends (creating duplicates) instead of
284
+ overwriting. This method looks at what the item currently has and
285
+ picks "replace" for fields it already carries and "add" for fields
286
+ that are genuinely new, so a single call is safe to use both for
287
+ first-time enrichment and for repeat syncs.
288
+
289
+ Parameters:
290
+ item_uuid: UUID of the item to update
291
+ metadata: dict of field -> value(s). Values has to be a pre-formatted
292
+ metadata-value dict (e.g. to set authority/confidence for
293
+ a controlled or linked field such as dc.relation.project).
294
+
295
+ Returns:
296
+ dict: updated item data
297
+ """
298
+
299
+ # the item is fetched first so the add/replace decision can be made safely
300
+ # existing_metadata is a dict as returned by item["metadata"] (field -> list of value dicts)
301
+ # all languages: PATCH positions refer to the complete value list, not the
302
+ # locale-filtered one, otherwise "place" would point at the wrong value
303
+ current = self.get_item(item_uuid, all_languages=True)
304
+ existing_metadata = current.get("metadata", {}) or {}
305
+
306
+ logger.info("Upserting metadata on item %s (%d field(s))", item_uuid, len(metadata))
307
+
308
+ patch_operations = []
309
+ for field, values in metadata.items():
310
+ metadata_position = values.get("place")
311
+
312
+ if not isinstance(metadata_position, int):
313
+ raise TypeError(f"Item={item_uuid}: place-value has to be a valid integer. place: {metadata_position}")
314
+
315
+ if existing_metadata.get(field):
316
+ # check place-value of metadata to see if we have an 'replace' or 'add' operation
317
+
318
+ # get number of entries in existing metadata field
319
+ num_entries_existing_metadata_field = len(existing_metadata.get(field))
320
+
321
+ if metadata_position < num_entries_existing_metadata_field:
322
+ # replace at metadata_position
323
+
324
+ patch_operations.append({
325
+ "op": "replace",
326
+ "path": f"/metadata/{field}/{metadata_position}",
327
+ "value": values if isinstance(values,dict) else self._format_metadata_field(values),
328
+ })
329
+
330
+ elif metadata_position == num_entries_existing_metadata_field:
331
+ # add at the end without changing existing data
332
+ patch_operations.append({
333
+ "op": "add",
334
+ "path": f"/metadata/{field}/-",
335
+ "value": values if isinstance(values,dict) else self._format_metadata_field(values),
336
+ })
337
+
338
+ else:
339
+ raise IndexError(f"Item={item_uuid}: The place-value of metadata is too big to be added as new metadata. metadata_place={metadata_position} next_valid_place={num_entries_existing_metadata_field}")
340
+ elif metadata_position == 0:
341
+ # add new metadata field
342
+ patch_operations.append({
343
+ "op": "add",
344
+ "path": f"/metadata/{field}",
345
+ "value": values if isinstance(values,dict) else self._format_metadata_field(values),
346
+ })
347
+ else:
348
+ raise IndexError(f"Item={item_uuid}: The place-value of metadata has to be 0 as it would be the first entry for this new metadata field. metadata_place={metadata_position}")
349
+
350
+
351
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}"
352
+
353
+ try:
354
+ response = self.session.patch(
355
+ url,
356
+ headers=self.config.headers,
357
+ json=patch_operations,
358
+ timeout=self.config.timeout
359
+ )
360
+
361
+ if response.status_code == 200:
362
+ logger.info("Item %s upserted successfully", item_uuid)
363
+ return response.json()
364
+ else:
365
+ handle_response_error(response, f"Upsert item {item_uuid}")
366
+
367
+ except Exception as e:
368
+ logger.error("Failed to upsert item %s: %s", item_uuid, e)
369
+ raise
370
+
371
+
372
+ def delete_item(self, item_uuid):
373
+ """
374
+ Delete an item
375
+
376
+ Parameters:
377
+ item_uuid: UUID of the item to delete
378
+
379
+ Returns:
380
+ bool: True if deletion was successful
381
+ """
382
+ logger.info("Deleting item %s", item_uuid)
383
+
384
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}"
385
+
386
+ try:
387
+ response = self.session.delete(
388
+ url,
389
+ headers=self.config.headers,
390
+ timeout=self.config.timeout
391
+ )
392
+
393
+ if response.status_code == 204:
394
+ logger.info("Item %s deleted successfully", item_uuid)
395
+ return True
396
+ else:
397
+ handle_response_error(response, f"Delete item {item_uuid}")
398
+
399
+ except Exception as e:
400
+ logger.error("Failed to delete item %s: %s", item_uuid, e)
401
+ raise
402
+
403
+ def change_owning_collection(self, item_uuid, new_collection_uuid, inherit_policies=False):
404
+ """
405
+ Change the owning collection of an item
406
+
407
+ Parameters:
408
+ item_uuid: UUID of the item
409
+ new_collection_uuid: UUID of the new owning collection
410
+ inherit_policies: Whether to inherit policies from new collection
411
+
412
+ Returns:
413
+ bool: True if successful
414
+ """
415
+ logger.info("Changing owning collection of item %s to %s", item_uuid, new_collection_uuid)
416
+
417
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}/owningCollection"
418
+ if inherit_policies:
419
+ url += "?inheritPolicies=true"
420
+
421
+ headers = {"Content-Type": "text/uri-list"}
422
+ data = f"{self.config.dspace_url}/core/collections/{new_collection_uuid}"
423
+
424
+ try:
425
+ response = self.session.put(
426
+ url,
427
+ headers=headers,
428
+ data=data,
429
+ timeout=self.config.timeout
430
+ )
431
+
432
+ if response.status_code in (200, 204):
433
+ logger.info("Owning collection updated successfully")
434
+ return True
435
+ else:
436
+ handle_response_error(response, "Change owning collection")
437
+
438
+ except Exception as e:
439
+ logger.error("Failed to change owning collection: %s", e)
440
+ raise
441
+
442
+ def add_mapped_collection(self, item_uuid, collection_uuid):
443
+ """
444
+ Add a mapped collection to an item
445
+
446
+ Parameters:
447
+ item_uuid: UUID of the item
448
+ collection_uuid: UUID of the collection to map
449
+
450
+ Returns:
451
+ bool: True if successful
452
+ """
453
+ logger.info("Mapping item %s to collection %s", item_uuid, collection_uuid)
454
+
455
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}/mappedCollections"
456
+ headers = {"Content-Type": "text/uri-list"}
457
+ data = f"{self.config.dspace_url}/core/collections/{collection_uuid}"
458
+
459
+ try:
460
+ response = self.session.post(
461
+ url,
462
+ headers=headers,
463
+ data=data,
464
+ timeout=self.config.timeout
465
+ )
466
+
467
+ if response.status_code == 204:
468
+ logger.info("Mapped collection added successfully")
469
+ return True
470
+ else:
471
+ handle_response_error(response, "Add mapped collection")
472
+
473
+ except Exception as e:
474
+ logger.error("Failed to add mapped collection: %s", e)
475
+ raise
476
+
477
+ def remove_mapped_collection(self, item_uuid, collection_uuid):
478
+ """
479
+ Remove a mapped collection from an item
480
+
481
+ Parameters:
482
+ item_uuid: UUID of the item
483
+ collection_uuid: UUID of the collection to unmap
484
+
485
+ Returns:
486
+ bool: True if successful
487
+ """
488
+ logger.info("Removing mapped collection %s from item %s", collection_uuid, item_uuid)
489
+
490
+ url = f"{self.config.dspace_url}{Endpoints.ITEMS}/{item_uuid}/mappedCollections/{collection_uuid}"
491
+
492
+ try:
493
+ response = self.session.delete(
494
+ url,
495
+ headers=self.config.headers,
496
+ timeout=self.config.timeout
497
+ )
498
+
499
+ if response.status_code == 204:
500
+ logger.info("Mapped collection removed successfully")
501
+ return True
502
+ else:
503
+ handle_response_error(response, "Remove mapped collection")
504
+
505
+ except Exception as e:
506
+ logger.error("Failed to remove mapped collection: %s", e)
507
+ raise
508
+
509
+ def _format_metadata(self, metadata_dict):
510
+ """
511
+ Format metadata dictionary for DSpace API
512
+
513
+ Parameters:
514
+ metadata_dict: Dictionary of metadata fields
515
+
516
+ Returns:
517
+ dict: Formatted metadata
518
+ """
519
+ formatted = {}
520
+
521
+ for field, values in metadata_dict.items():
522
+ formatted[field] = self._format_metadata_field(values)
523
+
524
+ return formatted
525
+
526
+ def _format_metadata_field(self, values):
527
+ """
528
+ Format metadata field values for DSpace API
529
+
530
+ Parameters:
531
+ values: String, list of strings, or list of dicts
532
+
533
+ Returns:
534
+ list: Formatted metadata values
535
+ """
536
+ if isinstance(values, str):
537
+ return [{"value": values}]
538
+ elif isinstance(values, list):
539
+ formatted = []
540
+ for value in values:
541
+ if isinstance(value, str):
542
+ formatted.append({"value": value})
543
+ elif isinstance(value, dict):
544
+ formatted.append(value)
545
+ else:
546
+ formatted.append({"value": str(value)})
547
+ return formatted
548
+ else:
549
+ return [{"value": str(values)}]