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,1069 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Collection management for DSpace API
4
+
5
+ GET|POST /core/collections/{uuid}/logo, DELETE via the logo bitstream
6
+ GET /core/collections/{uuid}/parentCommunity
7
+ GET /core/collections/search/findAdminAuthorized|findEditAuthorized
8
+ GET /core/collections/search/findSubmitAuthorized[ByEntityType|ByCommunity|ByCommunityAndEntityType]
9
+ GET /core/collections/{uuid}/license (custom license or the site default)
10
+ GET|POST /core/collections/{uuid}/itemtemplate PATCH|DELETE /core/itemtemplates/{uuid}
11
+ GET /core/collections/{uuid}/defaultAccessConditions
12
+ GET /core/collections/{uuid}/mappedItems POST|DELETE /core/items/{item}/mappedCollections[/{uuid}]
13
+ GET|PUT /core/collections/{uuid}/harvester GET /config/harvestermetadata
14
+
15
+ Logo, parent community, patch_metadata and the *Authorized searches are
16
+ shared with communities (_containers.py).
17
+ """
18
+
19
+ import logging
20
+ from typing import Any, Dict, Iterable, List, Optional, Union
21
+ from ._base import BaseManager
22
+ from ._containers import ContainerMixin
23
+ from .metadata import MetadataPatch
24
+ from .projection import EmbedArg, ProjectionArg
25
+ from .communities import build_dso_metadata
26
+ from .config import Endpoints
27
+ from .projection import view_params
28
+ from .exceptions import NotFoundError, handle_response_error
29
+
30
+ # logger for this module
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Collection role groups (see GroupManager)
34
+ ADMIN = "adminGroup"
35
+ SUBMITTERS = "submittersGroup"
36
+ ITEM_READ = "itemReadGroup"
37
+ BITSTREAM_READ = "bitstreamReadGroup"
38
+
39
+ Ref = Union[str, Dict[str, Any]]
40
+
41
+
42
+ def build_collection_metadata(title: str, description: Optional[str] = None, abstract: Optional[str] = None,
43
+ rights: Optional[str] = None, table_of_contents: Optional[str] = None,
44
+ license: Optional[str] = None, entity_type: Optional[str] = None,
45
+ submission_definition: Optional[str] = None,
46
+ submission_definition_correction: Optional[str] = None,
47
+ workspace_shared: Optional[bool] = None,
48
+ language: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
49
+ """
50
+ Metadata for a (DSpace-CRIS) collection: the community fields plus
51
+ dc.rights.license, dspace.entity.type, cris.submission.definition,
52
+ cris.submission.definition-correction and cris.workspace.shared.
53
+
54
+ Parameters:
55
+ title (str): The title of the collection.
56
+ description (Optional[str]): The description of the collection.
57
+ abstract (Optional[str]): The abstract of the collection.
58
+ rights (Optional[str]): The rights information of the collection.
59
+ table_of_contents (Optional[str]): The table of contents of the collection.
60
+ license (Optional[str]): The license of the collection.
61
+ entity_type (Optional[str]): The entity type of the collection.
62
+ submission_definition (Optional[str]): The submission definition of the collection.
63
+ submission_definition_correction (Optional[str]): The submission definition correction of the collection.
64
+ workspace_shared (Optional[bool]): Whether the workspace is shared.
65
+ language (Optional[str]): The language of the collection.
66
+
67
+ Returns:
68
+ Dict[str, List[Dict[str, Any]]]: The structured metadata for the collection.
69
+ """
70
+ return build_dso_metadata(title, description, abstract, rights, table_of_contents, language, **{
71
+ "dc.rights.license": license,
72
+ "dspace.entity.type": entity_type,
73
+ "cris.submission.definition": submission_definition,
74
+ "cris.submission.definition-correction": submission_definition_correction,
75
+ "cris.workspace.shared": workspace_shared,
76
+ })
77
+
78
+
79
+ class CollectionManager(ContainerMixin, BaseManager):
80
+ """Manages DSpace collections, their role groups, submission policies and settings"""
81
+
82
+ # The API endpoint and key for collections within DSpace
83
+ _CONTAINER_PATH = Endpoints.COLLECTIONS
84
+ _CONTAINER_KEY = "collections"
85
+
86
+ def __init__(self, auth):
87
+ """Initialize the CollectionManager.
88
+
89
+ Parameters:
90
+ auth: The authentication object used to interact with the DSpace API.
91
+
92
+ Returns:
93
+ None
94
+ """
95
+ logger.info("Initializing CollectionManager")
96
+ super().__init__(auth)
97
+ self._group_manager = None
98
+ self._permission_manager = None
99
+
100
+ @property
101
+ def groups(self):
102
+ """Get the group manager for collections.
103
+
104
+ Returns:
105
+ GroupManager: The group manager instance.
106
+ """
107
+ if self._group_manager is None:
108
+ from .groups import GroupManager
109
+ self._group_manager = GroupManager(self.auth)
110
+ return self._group_manager
111
+
112
+ @property
113
+ def discovery(self):
114
+ """Get the discovery manager for collections.
115
+
116
+ Returns:
117
+ DiscoveryManager: The discovery manager instance.
118
+ """
119
+ if getattr(self, "_discovery_manager", None) is None:
120
+ from .discovery import DiscoveryManager
121
+ self._discovery_manager = DiscoveryManager(self.auth)
122
+ return self._discovery_manager
123
+
124
+ @property
125
+ def permissions(self):
126
+ """Get the permission manager for collections.
127
+
128
+ Returns:
129
+ PermissionManager: The permission manager instance.
130
+ """
131
+ if self._permission_manager is None:
132
+ from .permissions import PermissionManager
133
+ self._permission_manager = PermissionManager(self.auth)
134
+ return self._permission_manager
135
+
136
+ def get_collections(self, page=0, size=20, sort=None, all_languages=None, projection=None, embed=None):
137
+ """
138
+ Get a list of collections
139
+
140
+ Parameters:
141
+ page (int): Page number (0-based)
142
+ size (int): Number of collections per page
143
+ sort (Optional[str]): Sort criteria
144
+ all_languages (Optional[bool]): Whether to include all languages
145
+ projection (Optional[str]): Projection parameter for the API
146
+ embed (Optional[str]): Embed parameter for the API
147
+
148
+ Returns:
149
+ dict: API response containing collections
150
+ """
151
+ logger.info("Getting collections (page=%d, size=%d)", page, size)
152
+
153
+ url = f"{self.config.dspace_url}{Endpoints.COLLECTIONS}"
154
+ params = {"page": page, "size": size}
155
+ if sort:
156
+ params["sort"] = sort
157
+ params = view_params(params, 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 collections")
169
+ return response.json()
170
+ else:
171
+ handle_response_error(response, "Get collections")
172
+
173
+ except Exception as e:
174
+ logger.error("Failed to get collections: %s", e)
175
+ raise
176
+
177
+ def get_collection(self, collection_uuid, all_languages=None, projection=None, embed=None):
178
+ """
179
+ Get a specific collection by UUID
180
+
181
+ Parameters:
182
+ collection_uuid (str): UUID of the collection
183
+
184
+ Returns:
185
+ dict: Collection data
186
+ """
187
+ logger.info("Getting collection %s", collection_uuid)
188
+
189
+ url = f"{self.config.dspace_url}{Endpoints.COLLECTIONS}/{collection_uuid}"
190
+ params = view_params(None, self.config, all_languages, projection, embed)
191
+
192
+ try:
193
+ response = self.session.get(
194
+ url,
195
+ headers=self.config.headers,
196
+ params=params,
197
+ timeout=self.config.timeout
198
+ )
199
+
200
+ if response.status_code == 200:
201
+ logger.info("Successfully retrieved collection %s", collection_uuid)
202
+ return response.json()
203
+ handle_response_error(response, "Get collection %s" % collection_uuid)
204
+
205
+ except Exception as e:
206
+ logger.error("Failed to get collection %s: %s", collection_uuid, e)
207
+ raise
208
+
209
+ def get_collection_items(self, collection_uuid, page=0, size=20, sort=None, all_languages=None, projection=None,
210
+ embed=None, query=None, filters=None):
211
+ """
212
+ One page of the items in a collection.
213
+
214
+ DSpace 7+ has no /core/collections/{uuid}/items endpoint; like the
215
+ Angular UI, this uses a discovery search scoped to the collection
216
+ (dsoType=item). The result keeps the familiar shape:
217
+
218
+ {"_embedded": {"items": [item, ...]}, "page": {"number", "size", "totalElements", "totalPages"}}
219
+
220
+ Parameters:
221
+ collection_uuid: UUID of the collection
222
+ page (int): 0-based page number
223
+ size (int): Page size
224
+ sort: discovery sort option, e.g. "dc.title,ASC", "dc.date.accessioned,DESC"
225
+ (see DiscoveryManager.get_sort_options())
226
+ query (Optional[str]): Optional discovery query
227
+ filters (Optional[dict]): Optional discovery filters (see DiscoveryManager.search)
228
+
229
+ Returns:
230
+ dict: A dictionary containing the items and pagination information.
231
+
232
+ Notes:
233
+ - Items mapped into the collection are included (discovery scope covers them).
234
+ - Discovery shows what the current user may see: withdrawn and
235
+ private (non-discoverable) items only appear for administrators.
236
+ - Newly created items appear once Solr has indexed them.
237
+ """
238
+ logger.info("Getting items for collection %s (page %s)", collection_uuid, page)
239
+ response = self.discovery.search(query=query, dso_type="item", scope=collection_uuid, filters=filters,
240
+ sort=sort, page=page, size=size, all_languages=all_languages,
241
+ projection=projection, embed=embed)
242
+ objects, info = self.discovery.search_result(response)
243
+ items = [(o.get("_embedded") or {}).get("indexableObject") for o in objects]
244
+ return {"_embedded": {"items": [i for i in items if i]},
245
+ "page": {"number": info.get("number", page), "size": info.get("size", size),
246
+ "totalElements": info.get("totalElements", 0),
247
+ "totalPages": info.get("totalPages", 0)}}
248
+
249
+ def iter_collection_items(self, collection_uuid, page_size=100, max_results=None, sort=None, query=None,
250
+ filters=None, all_languages=None, projection=None, embed=None):
251
+ """Every item in a collection, across all pages (a generator). See get_collection_items().
252
+
253
+ Parameters:
254
+ collection_uuid (str): UUID of the collection
255
+ page_size (int): Number of items per page
256
+ max_results (Optional[int]): Maximum number of items to return
257
+ sort (Optional[str]): Sort criteria
258
+ query (Optional[str]): Optional discovery query
259
+ filters (Optional[dict]): Optional discovery filters
260
+ all_languages (Optional[bool]): Whether to include all languages
261
+ projection (Optional[str]): Projection parameter for the API
262
+ embed (Optional[str]): Embed parameter for the API
263
+
264
+ Yields:
265
+ dict: Items in the collection
266
+ """
267
+ return self.discovery.iter_search(query=query, dso_type="item", scope=collection_uuid, filters=filters,
268
+ sort=sort, page_size=page_size, max_results=max_results,
269
+ all_languages=all_languages, projection=projection, embed=embed)
270
+
271
+ def count_collection_items(self, collection_uuid, query=None, filters=None):
272
+ """Number of items in a collection visible to the current user (one request).
273
+
274
+ Parameters:
275
+ collection_uuid (str): UUID of the collection
276
+ query (Optional[str]): Optional discovery query
277
+ filters (Optional[dict]): Optional discovery filters
278
+
279
+ Returns:
280
+ int: Number of items in the collection
281
+ """
282
+ return self.discovery.count(query=query, dso_type="item", scope=collection_uuid, filters=filters)
283
+
284
+ def create_collection(self, parent_community_uuid, name, metadata=None):
285
+ """
286
+ Create a new collection.
287
+
288
+ This uses POST /core/collections?parent={communityUUID}
289
+
290
+ Parameters:
291
+ parent_community_uuid (str): UUID of the parent community
292
+ name (str): Friendly name for the collection
293
+ metadata (Optional[dict]): Optional metadata dict. If 'dc.title' is missing, it will be set from `name`.
294
+
295
+ Returns:
296
+ dict: Created collection data
297
+ """
298
+ logger.info("Creating collection '%s' in community %s", name, parent_community_uuid)
299
+
300
+ # Ensure metadata exists and contains dc.title from name if not supplied
301
+ metadata = dict(metadata or {})
302
+ if "dc.title" not in metadata:
303
+ metadata["dc.title"] = [{"value": name}]
304
+ else:
305
+ # normalize metadata field variants via formatter
306
+ pass
307
+
308
+ collection_data = {
309
+ # Some DSpace installations accept/require this top-level name as well:
310
+ "name": name,
311
+ "metadata": self._format_metadata(metadata),
312
+ "type": "collection",
313
+ }
314
+
315
+ url = f"{self.config.dspace_url}{Endpoints.COLLECTIONS}?parent={parent_community_uuid}"
316
+
317
+ try:
318
+ response = self.session.post(
319
+ url,
320
+ headers=self.config.headers,
321
+ json=collection_data,
322
+ timeout=self.config.timeout
323
+ )
324
+ if response.status_code in (200, 201):
325
+ logger.info("Collection created successfully")
326
+ return response.json()
327
+ handle_response_error(response, "Create collection")
328
+ except Exception as e:
329
+ logger.error("Failed to create collection: %s", e)
330
+ raise
331
+
332
+ def update_collection(self, collection_uuid, name=None, metadata=None):
333
+ """
334
+ Update collection metadata.
335
+
336
+ Parameters:
337
+ collection_uuid (str): UUID of the collection
338
+ name (Optional[str]): New name for the collection (maps to metadata 'dc.title')
339
+ metadata (Optional[dict]): Dict of metadata fields to replace. NOTE: 'dspace.entity.type' will be ignored.
340
+
341
+ Returns:
342
+ dict: Updated collection data
343
+ """
344
+ logger.info("Updating collection %s", collection_uuid)
345
+
346
+ operations = []
347
+
348
+ # If a name is provided, update the dc.title field instead of /name
349
+ if name:
350
+ operations.append({
351
+ "op": "replace",
352
+ "path": "/metadata/dc.title",
353
+ "value": {"value": name}
354
+ })
355
+
356
+ if metadata:
357
+ # NEVER allow changing dspace.entity.type via this method
358
+ safe_metadata = {
359
+ k: v for k, v in metadata.items()
360
+ if k != "dspace.entity.type"
361
+ }
362
+
363
+ for field, values in safe_metadata.items():
364
+ # If caller passed a simple string or boolean, wrap once
365
+ if isinstance(values, (str, bool, int, float)):
366
+ value_obj = {"value": values}
367
+ operations.append({
368
+ "op": "replace",
369
+ "path": f"/metadata/{field}",
370
+ "value": value_obj
371
+ })
372
+ elif isinstance(values, list):
373
+ # If they passed a list of primitives/objects, normalize to list of {"value": ...}
374
+ normalized = []
375
+ for v in values:
376
+ if isinstance(v, dict) and "value" in v:
377
+ normalized.append(v)
378
+ else:
379
+ normalized.append({"value": v})
380
+ operations.append({
381
+ "op": "replace",
382
+ "path": f"/metadata/{field}",
383
+ "value": normalized if len(normalized) > 1 else normalized[0]
384
+ })
385
+ elif isinstance(values, dict):
386
+ # Assume already a MetadataValue-like object
387
+ operations.append({
388
+ "op": "replace",
389
+ "path": f"/metadata/{field}",
390
+ "value": values
391
+ })
392
+ else:
393
+ operations.append({
394
+ "op": "replace",
395
+ "path": f"/metadata/{field}",
396
+ "value": {"value": str(values)}
397
+ })
398
+
399
+ url = f"{self.config.dspace_url}{Endpoints.COLLECTIONS}/{collection_uuid}"
400
+
401
+ try:
402
+ headers = dict(self.config.headers)
403
+ headers["Content-Type"] = "application/json-patch+json"
404
+
405
+ response = self.session.patch(
406
+ url,
407
+ headers=headers,
408
+ json=operations,
409
+ timeout=self.config.timeout
410
+ )
411
+
412
+ if response.status_code == 200:
413
+ logger.info("Collection %s updated successfully", collection_uuid)
414
+ return response.json()
415
+
416
+ if response.status_code == 204:
417
+ logger.info("Collection %s updated successfully (204). Fetching resource...", collection_uuid)
418
+ return self.get_collection(collection_uuid)
419
+
420
+ handle_response_error(response, f"Update collection {collection_uuid}")
421
+
422
+ except Exception as e:
423
+ logger.error("Failed to update collection %s: %s", collection_uuid, e)
424
+ raise
425
+
426
+ def delete_collection(self, collection_uuid):
427
+ """
428
+ Delete a collection
429
+
430
+ Parameters:
431
+ collection_uuid (str): UUID of the collection to delete
432
+
433
+ Returns:
434
+ bool: True if deletion was successful
435
+ """
436
+ logger.info("Deleting collection %s", collection_uuid)
437
+
438
+ url = f"{self.config.dspace_url}{Endpoints.COLLECTIONS}/{collection_uuid}"
439
+
440
+ try:
441
+ response = self.session.delete(
442
+ url,
443
+ headers=self.config.headers,
444
+ timeout=self.config.timeout
445
+ )
446
+
447
+ if response.status_code == 204:
448
+ logger.info("Collection %s deleted successfully", collection_uuid)
449
+ return True
450
+ handle_response_error(response, f"Delete collection {collection_uuid}")
451
+
452
+ except Exception as e:
453
+ logger.error("Failed to delete collection %s: %s", collection_uuid, e)
454
+ raise
455
+
456
+ def _format_metadata(self, metadata_dict):
457
+ """
458
+ Format metadata dictionary for DSpace API
459
+
460
+ Parameters:
461
+ metadata_dict (dict): Dictionary of metadata fields
462
+
463
+ Returns:
464
+ dict: Formatted metadata
465
+ """
466
+ formatted = {}
467
+
468
+ for field, values in metadata_dict.items():
469
+ formatted[field] = self._format_metadata_field(values)
470
+
471
+ return formatted
472
+
473
+ def _format_metadata_field(self, values):
474
+ """
475
+ Format metadata field values for DSpace API
476
+
477
+ Parameters:
478
+ values (Union[str, List[str], List[dict]]): String, list of strings, or list of dicts
479
+
480
+ Returns:
481
+ list: Formatted metadata values
482
+ """
483
+ if isinstance(values, str):
484
+ return [{"value": values}]
485
+ elif isinstance(values, list):
486
+ formatted = []
487
+ for value in values:
488
+ if isinstance(value, str):
489
+ formatted.append({"value": value})
490
+ elif isinstance(value, dict):
491
+ formatted.append(value)
492
+ else:
493
+ formatted.append({"value": str(value)})
494
+ return formatted
495
+ else:
496
+ return [{"value": str(values)}]
497
+
498
+ # ------------------------------------------------------------------
499
+ # Finding collections
500
+ # ------------------------------------------------------------------
501
+
502
+ def search_editable_collections(self, query: str = "", configuration: str = "editCollection",
503
+ max_results: Optional[int] = 20,
504
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
505
+ """
506
+ Collections the current user can edit (discovery configuration).
507
+ If your discovery.xml names the configuration differently, pass it.
508
+
509
+ Parameters:
510
+ query (str): Discovery query string
511
+ configuration (str): Discovery configuration name
512
+ max_results (Optional[int]): Maximum number of results to return
513
+ all_languages (Optional[bool]): Whether to include all languages
514
+ projection (ProjectionArg): Projection argument for the API
515
+ embed (EmbedArg): Embed argument for the API
516
+
517
+ Returns:
518
+ List[Dict[str, Any]]: List of collections the current user can edit
519
+ """
520
+ return self._discover(query, dso_type="COLLECTION", configuration=configuration, sort="dc.title,ASC",
521
+ max_results=max_results, all_languages=all_languages, projection=projection, embed=embed)
522
+
523
+ def find_submit_authorized(self, query: Optional[str] = None, entity_type: Optional[str] = None,
524
+ max_results: Optional[int] = None,
525
+ all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None,
526
+ community_uuid: Optional[str] = None) -> List[Dict[str, Any]]:
527
+ """Collections the current user may submit to, optionally of one entity type and/or below one community.
528
+
529
+ Parameters:
530
+ query (Optional[str]): Optional discovery query
531
+ entity_type (Optional[str]): Optional entity type filter
532
+ max_results (Optional[int]): Maximum number of results to return
533
+ all_languages (Optional[bool]): Whether to include all languages
534
+ projection (ProjectionArg): Projection argument for the API
535
+ embed (EmbedArg): Embed argument for the API
536
+ community_uuid (Optional[str]): UUID of the community to filter by
537
+
538
+ Returns:
539
+ List[Dict[str, Any]]: List of collections the current user may submit to
540
+ """
541
+ method = "findSubmitAuthorized" + ("ByCommunity" if community_uuid else "") + \
542
+ (("AndEntityType" if community_uuid else "ByEntityType") if entity_type else "")
543
+ url = self._url(Endpoints.COLLECTIONS, f"/search/{method}")
544
+ params = [("uuid", community_uuid)] if community_uuid else []
545
+ if entity_type:
546
+ params.append(("entityType", entity_type))
547
+ if query:
548
+ params.append(("query", query))
549
+ return list(self._iter_embedded(url, "collections", "Find submit-authorized collections", params,
550
+ max_results=max_results, all_languages=all_languages, projection=projection, embed=embed))
551
+
552
+ # ------------------------------------------------------------------
553
+ # Role groups: admin, submitters, default item/bitstream read, workflow
554
+ # ------------------------------------------------------------------
555
+
556
+ def get_role_group(self, collection_uuid: str, role: str) -> Optional[Dict[str, Any]]:
557
+ """Get the role group for a collection.
558
+
559
+ Parameters:
560
+ collection_uuid (str): UUID of the collection
561
+ role (str): Role name (adminGroup, submittersGroup, itemReadGroup, bitstreamReadGroup or workflowGroups/<name>)
562
+
563
+ Returns:
564
+ Optional[Dict[str, Any]]: Role group information if it exists, otherwise None.
565
+ """
566
+ return self.groups.get_role_group("collection", collection_uuid, role)
567
+
568
+ def get_or_create_role_group(self, collection_uuid: str, role: str,
569
+ description: Optional[str] = None) -> Dict[str, Any]:
570
+ """Get or create the role group for a collection.
571
+
572
+ Parameters:
573
+ collection_uuid (str): UUID of the collection
574
+ role (str): Role name (adminGroup, submittersGroup, itemReadGroup, bitstreamReadGroup or workflowGroups/<name>)
575
+ description (Optional[str]): Description for the role group
576
+
577
+ Returns:
578
+ Dict[str, Any]: Role group information.
579
+ """
580
+ return self.groups.get_or_create_role_group("collection", collection_uuid, role, description)
581
+
582
+ def delete_role_group(self, collection_uuid: str, role: str) -> bool:
583
+ """Delete the role group for a collection.
584
+
585
+ Parameters:
586
+ collection_uuid (str): UUID of the collection
587
+ role (str): Role name
588
+
589
+ Returns:
590
+ bool: True if the role group was deleted, False otherwise.
591
+ """
592
+ return self.groups.delete_role_group("collection", collection_uuid, role)
593
+
594
+ def add_role_members(self, collection_uuid: str, role: str, epersons: Iterable[Ref] = (),
595
+ subgroups: Iterable[Ref] = (), description: Optional[str] = None) -> Dict[str, Any]:
596
+ """Create the role group if needed and add epersons and/or groups to it.
597
+
598
+ Parameters:
599
+ collection_uuid (str): UUID of the collection
600
+ role (str): Role name
601
+ epersons (Iterable[Ref]): Epersons to add to the role group
602
+ subgroups (Iterable[Ref]): Subgroups to add to the role group
603
+ description (Optional[str]): Description for the role group
604
+
605
+ Returns:
606
+ Dict[str, Any]: Role group information.
607
+ """
608
+ return self.groups.ensure_role_members("collection", collection_uuid, role, epersons, subgroups, description)
609
+
610
+ def get_role_members(self, collection_uuid: str, role: str) -> Dict[str, List[Dict[str, Any]]]:
611
+ """Get the members of a role group.
612
+
613
+ Parameters:
614
+ collection_uuid (str): UUID of the collection
615
+ role (str): Role name
616
+
617
+ Returns:
618
+ Dict[str, List[Dict[str, Any]]]: {"epersons": [...], "subgroups": [...]}; both empty if the role group doesn't exist.
619
+ """
620
+ group = self.get_role_group(collection_uuid, role)
621
+ if not group:
622
+ return {"epersons": [], "subgroups": []}
623
+ return {"epersons": self.groups.get_group_epersons(group["uuid"]),
624
+ "subgroups": self.groups.get_subgroups(group["uuid"])}
625
+
626
+ def add_admins(self, collection_uuid: str, epersons: Iterable[Ref] = (), subgroups: Iterable[Ref] = ()):
627
+ """Add epersons and/or subgroups to the admin role group.
628
+
629
+ Parameters:
630
+ collection_uuid (str): UUID of the collection
631
+ epersons (Iterable[Ref]): Epersons to add to the role group
632
+ subgroups (Iterable[Ref]): Subgroups to add to the role group
633
+
634
+ Returns:
635
+ Dict[str, Any]: Role group information.
636
+ """
637
+ return self.add_role_members(collection_uuid, ADMIN, epersons, subgroups)
638
+
639
+ def add_submitters(self, collection_uuid: str, epersons: Iterable[Ref] = (), subgroups: Iterable[Ref] = ()):
640
+ """Add epersons and/or subgroups to the submitters role group.
641
+
642
+ Parameters:
643
+ collection_uuid (str): UUID of the collection
644
+ epersons (Iterable[Ref]): Epersons to add to the role group
645
+ subgroups (Iterable[Ref]): Subgroups to add to the role group
646
+
647
+ Returns:
648
+ Dict[str, Any]: Role group information.
649
+ """
650
+ return self.add_role_members(collection_uuid, SUBMITTERS, epersons, subgroups)
651
+
652
+ def restrict_item_read(self, collection_uuid: str, epersons: Iterable[Ref] = (), subgroups: Iterable[Ref] = (),
653
+ description: Optional[str] = None) -> Dict[str, Any]:
654
+ """Make items deposited from now on readable only by the given members
655
+ (creates itemReadGroup, which replaces Anonymous as default reader).
656
+ Items already in the collection keep their policies - use
657
+ PermissionManager.remove_group_access_from_item() for those.
658
+
659
+ Parameters:
660
+ collection_uuid (str): UUID of the collection
661
+ epersons (Iterable[Ref]): Epersons to add to the role group
662
+ subgroups (Iterable[Ref]): Subgroups to add to the role group
663
+ description (Optional[str]): Description for the role group
664
+
665
+ Returns:
666
+ Dict[str, Any]: Role group information.
667
+ """
668
+ return self.add_role_members(collection_uuid, ITEM_READ, epersons, subgroups, description)
669
+
670
+ def restrict_bitstream_read(self, collection_uuid: str, epersons: Iterable[Ref] = (),
671
+ subgroups: Iterable[Ref] = (), description: Optional[str] = None) -> Dict[str, Any]:
672
+ """Same as restrict_item_read() for the files (bitstreams) of new items.
673
+
674
+ Parameters:
675
+ collection_uuid (str): UUID of the collection
676
+ epersons (Iterable[Ref]): Epersons to add to the role group
677
+ subgroups (Iterable[Ref]): Subgroups to add to the role group
678
+ description (Optional[str]): Description for the role group
679
+
680
+ Returns:
681
+ Dict[str, Any]: Role group information.
682
+ """
683
+ return self.add_role_members(collection_uuid, BITSTREAM_READ, epersons, subgroups, description)
684
+
685
+ def is_read_restricted(self, collection_uuid: str) -> Dict[str, bool]:
686
+ """Check if new items / new bitstreams are publicly readable, based on the
687
+ collection's DEFAULT_ITEM_READ / DEFAULT_BITSTREAM_READ policies.
688
+
689
+ Parameters:
690
+ collection_uuid (str): UUID of the collection
691
+
692
+ Returns:
693
+ Dict[str, bool]: Dictionary with keys "items" and "bitstreams" indicating readability.
694
+ """
695
+ from .permissions import ANONYMOUS, Actions
696
+ policies = self.permissions.get_resource_policies(collection_uuid)
697
+ result = {}
698
+ for key, action in (("items", Actions.DEFAULT_ITEM_READ), ("bitstreams", Actions.DEFAULT_BITSTREAM_READ)):
699
+ result[key] = not self.permissions.filter_policies(policies, group=ANONYMOUS, actions=[action])
700
+ return result
701
+
702
+ # ------------------------------------------------------------------
703
+ # Submission policies (TYPE_SUBMISSION resource policies on the collection)
704
+ # ------------------------------------------------------------------
705
+
706
+ def get_submission_policies(self, collection_uuid: str) -> List[Dict[str, Any]]:
707
+ """Get all TYPE_SUBMISSION policies for the collection.
708
+
709
+ Parameters:
710
+ collection_uuid (str): UUID of the collection
711
+
712
+ Returns:
713
+ List[Dict[str, Any]]: List of submission policies.
714
+ """
715
+ from .permissions import PolicyTypes
716
+ policies = self.permissions.get_resource_policies(collection_uuid)
717
+ return self.permissions.filter_policies(policies, policy_types=[PolicyTypes.SUBMISSION])
718
+
719
+ def add_submission_policy(self, collection_uuid: str, group: Ref, action: str = "ADD",
720
+ name: Optional[str] = None, start_date=None, end_date=None) -> Dict[str, Any]:
721
+ """Grant a group an action on the collection as a TYPE_SUBMISSION policy (admin only).
722
+
723
+ Parameters:
724
+ collection_uuid (str): UUID of the collection
725
+ group (Ref): Group to grant the action to
726
+ action (str): Action to grant (default "ADD")
727
+ name (Optional[str]): Name of the policy
728
+ start_date (Optional): Start date of the policy
729
+ end_date (Optional): End date of the policy
730
+
731
+ Returns:
732
+ Dict[str, Any]: Policy information.
733
+ """
734
+ from .permissions import PolicyTypes
735
+ group_uuid = self.groups.resolve_group(group)
736
+ return self.permissions.create_policy(collection_uuid, action=action, group=group_uuid,
737
+ policy_type=PolicyTypes.SUBMISSION, name=name,
738
+ start_date=start_date, end_date=end_date)
739
+
740
+ def delete_submission_policy(self, policy_id: int) -> bool:
741
+ """Delete a TYPE_SUBMISSION policy by its ID.
742
+
743
+ Parameters:
744
+ policy_id (int): ID of the policy to delete
745
+
746
+ Returns:
747
+ bool: True if the policy was successfully deleted, False otherwise.
748
+ """
749
+ return self.permissions.delete_policy(policy_id)
750
+
751
+ # ------------------------------------------------------------------
752
+ # License
753
+ # ------------------------------------------------------------------
754
+
755
+ def get_license(self, collection_uuid: str) -> Dict[str, Any]:
756
+ """
757
+ Get the license of the collection.
758
+
759
+ {"custom": bool, "text": ...} - the collection's license or the site default.
760
+
761
+ Parameters:
762
+ collection_uuid (str): UUID of the collection
763
+
764
+ Returns:
765
+ Dict[str, Any]: License information.
766
+ """
767
+ return self._get_json(self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/license"),
768
+ f"Get license of collection {collection_uuid}")
769
+
770
+ def set_license(self, collection_uuid: str, text: Optional[str]) -> Dict[str, Any]:
771
+ """Set a custom deposit license (dc.rights.license); text=None returns to the site default.
772
+
773
+ Parameters:
774
+ collection_uuid (str): UUID of the collection
775
+ text (Optional[str]): License text to set, or None to revert to the site default
776
+
777
+ Returns:
778
+ Dict[str, Any]: License information.
779
+ """
780
+ patch = MetadataPatch()
781
+ if text is None:
782
+ current = self.get_collection(collection_uuid, all_languages=True) or {}
783
+ if not (current.get("metadata") or {}).get("dc.rights.license"):
784
+ return current
785
+ patch.remove("dc.rights.license")
786
+ else:
787
+ patch.set("dc.rights.license", text)
788
+ return self.patch_metadata(collection_uuid, patch)
789
+
790
+ # ------------------------------------------------------------------
791
+ # Item template
792
+ # ------------------------------------------------------------------
793
+
794
+ def get_item_template(self, collection_uuid: str, all_languages: Optional[bool] = None) -> Optional[Dict[str, Any]]:
795
+ """
796
+ The template item (metadata copied into new submissions), or None.
797
+ DSpace answers 404 both for "no template" and for an unknown
798
+ collection; the second case still raises NotFoundError.
799
+
800
+ Parameters:
801
+ collection_uuid (str): UUID of the collection
802
+ all_languages (Optional[bool]): Whether to retrieve metadata in all languages
803
+
804
+ Returns:
805
+ Optional[Dict[str, Any]]: Item template metadata, or None if no template exists.
806
+ """
807
+ try:
808
+ return self._get_optional(self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/itemtemplate"),
809
+ f"Get item template of collection {collection_uuid}",
810
+ all_languages=all_languages)
811
+ except NotFoundError:
812
+ # raises NotFoundError itself if the collection does not exist
813
+ self._get_json(self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}"),
814
+ f"Get collection {collection_uuid}", all_languages=False)
815
+ return None
816
+
817
+ def create_item_template(self, collection_uuid: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
818
+ """Create an item template for the collection.
819
+
820
+ Parameters:
821
+ collection_uuid (str): UUID of the collection
822
+ metadata (Dict[str, Any]): Metadata for the item template
823
+
824
+ Returns:
825
+ Dict[str, Any]: Created item template
826
+
827
+ Raises:
828
+ ValueError: If the collection already has a template (HTTP 422)
829
+ """
830
+ body = {"metadata": self._format_metadata(metadata), "inArchive": False, "discoverable": False,
831
+ "withdrawn": False, "type": "item"}
832
+ template = self._request("POST", self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/itemtemplate"),
833
+ f"Create item template of collection {collection_uuid}", expected=(200, 201),
834
+ headers=self._headers(), json=body).json()
835
+ logger.info("Created item template %s for collection %s", template.get("uuid"), collection_uuid)
836
+ return template
837
+
838
+ def update_item_template(self, template_uuid: str, patch: Union[MetadataPatch, Dict[str, Any]]) -> Dict[str, Any]:
839
+ """Update an item template with the given metadata patch.
840
+
841
+ Parameters:
842
+ template_uuid (str): UUID of the item template
843
+ patch (Union[MetadataPatch, Dict[str, Any]]): Metadata patch to apply. Can be a MetadataPatch instance or a dictionary {field: values} where each replaces the field.
844
+
845
+ Returns:
846
+ Dict[str, Any]: Updated item template
847
+
848
+ Raises:
849
+ ValueError: If the patch is empty
850
+ """
851
+ if isinstance(patch, dict):
852
+ built = MetadataPatch()
853
+ for field, values in patch.items():
854
+ built.set(field, values)
855
+ patch = built
856
+ if not len(patch):
857
+ raise ValueError("Empty metadata patch")
858
+ return self._request("PATCH", self._url("/core/itemtemplates", f"/{template_uuid}"),
859
+ f"Update item template {template_uuid}", headers=self._headers(),
860
+ json=patch.operations).json()
861
+
862
+ def set_item_template(self, collection_uuid: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
863
+ """Create the template, or set these fields on the existing one (other fields stay).
864
+
865
+ Parameters:
866
+ collection_uuid (str): UUID of the collection
867
+ metadata (Dict[str, Any]): Metadata to set on the item template
868
+
869
+ Returns:
870
+ Dict[str, Any]: Created or updated item template
871
+ """
872
+ template = self.get_item_template(collection_uuid)
873
+ if template is None:
874
+ return self.create_item_template(collection_uuid, metadata)
875
+ return self.update_item_template(template["uuid"], metadata)
876
+
877
+ def delete_item_template(self, collection_uuid: str) -> bool:
878
+ """Remove the collection's template; False if it has none.
879
+
880
+ Parameters:
881
+ collection_uuid (str): UUID of the collection
882
+
883
+ Returns:
884
+ bool: True if the template was deleted, False if it had none.
885
+ """
886
+ template = self.get_item_template(collection_uuid)
887
+ if template is None:
888
+ return False
889
+ self._request("DELETE", self._url("/core/itemtemplates", f"/{template['uuid']}"),
890
+ f"Delete item template {template['uuid']}", expected=(204,))
891
+ return True
892
+
893
+ # ------------------------------------------------------------------
894
+ # Default access conditions
895
+ # ------------------------------------------------------------------
896
+
897
+ def get_default_access_conditions(self, collection_uuid: str, embed: EmbedArg = "group") -> List[Dict[str, Any]]:
898
+ """
899
+ The DEFAULT_BITSTREAM_READ policies new files inherit (a resource
900
+ policy search; DSpace 7+ has no /defaultAccessConditions endpoint).
901
+
902
+ Parameters:
903
+ collection_uuid (str): UUID of the collection
904
+ embed (EmbedArg): Embedding options for the response
905
+
906
+ Returns:
907
+ List[Dict[str, Any]]: List of default access condition policies
908
+ """
909
+ from .permissions import Actions
910
+ embeds = [embed] if isinstance(embed, str) else list(embed or [])
911
+ return self.permissions.get_resource_policies(collection_uuid, action=Actions.DEFAULT_BITSTREAM_READ,
912
+ embed=embeds)
913
+
914
+ # ------------------------------------------------------------------
915
+ # Mapped items
916
+ # ------------------------------------------------------------------
917
+
918
+ def iter_mapped_items(self, collection_uuid: str, all_languages: Optional[bool] = None,
919
+ embed: EmbedArg = None):
920
+ """Items mapped into (not owned by) the collection. A generator.
921
+
922
+ Parameters:
923
+ collection_uuid (str): UUID of the collection
924
+ all_languages (Optional[bool]): Whether to retrieve metadata in all languages
925
+ embed (EmbedArg): Embedding options for the response
926
+
927
+ Yields:
928
+ Dict[str, Any]: Mapped item metadata
929
+ """
930
+ url = self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/mappedItems")
931
+ return self._iter_embedded(url, ("mappedItems", "items"), f"Get mapped items of {collection_uuid}",
932
+ all_languages=all_languages, embed=embed)
933
+
934
+ def get_mapped_items(self, collection_uuid: str, all_languages: Optional[bool] = None) -> List[Dict[str, Any]]:
935
+ """Get all items mapped into (not owned by) the collection.
936
+
937
+ Parameters:
938
+ collection_uuid (str): UUID of the collection
939
+ all_languages (Optional[bool]): Whether to retrieve metadata in all languages
940
+
941
+ Returns:
942
+ List[Dict[str, Any]]: List of mapped item metadata
943
+ """
944
+ return list(self.iter_mapped_items(collection_uuid, all_languages))
945
+
946
+ def map_item(self, collection_uuid: str, item_uuid: str) -> bool:
947
+ """Show an item in this collection too (no-op if already mapped; 422 if it is the owning collection).
948
+
949
+ Parameters:
950
+ collection_uuid (str): UUID of the collection
951
+ item_uuid (str): UUID of the item to map
952
+
953
+ Returns:
954
+ bool: True if the item was mapped successfully
955
+ """
956
+ self._request("POST", self._url(Endpoints.ITEMS, f"/{item_uuid}/mappedCollections"),
957
+ f"Map item {item_uuid} into {collection_uuid}", expected=(200, 201, 204),
958
+ headers=self._headers("text/uri-list"),
959
+ data=self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}"))
960
+ return True
961
+
962
+ def unmap_item(self, collection_uuid: str, item_uuid: str) -> bool:
963
+ """Unmap an item from this collection.
964
+
965
+ Parameters:
966
+ collection_uuid (str): UUID of the collection
967
+ item_uuid (str): UUID of the item to unmap
968
+
969
+ Returns:
970
+ bool: True if the item was unmapped successfully
971
+ """
972
+ self._request("DELETE", self._url(Endpoints.ITEMS, f"/{item_uuid}/mappedCollections/{collection_uuid}"),
973
+ f"Unmap item {item_uuid} from {collection_uuid}", expected=(200, 204))
974
+ return True
975
+
976
+ def map_items(self, collection_uuid: str, item_uuids: Iterable[str]) -> Dict[str, str]:
977
+ """Map several items; {item uuid: "mapped" | error message} - one failure does not stop the rest.
978
+
979
+ Parameters:
980
+ collection_uuid (str): UUID of the collection
981
+ item_uuids (Iterable[str]): UUIDs of the items to map
982
+
983
+ Returns:
984
+ Dict[str, str]: Mapping of item UUIDs to "mapped" or error message
985
+ """
986
+ from .exceptions import DSpaceError
987
+ result = {}
988
+ for item_uuid in item_uuids:
989
+ try:
990
+ self.map_item(collection_uuid, item_uuid)
991
+ result[item_uuid] = "mapped"
992
+ except DSpaceError as exc:
993
+ result[item_uuid] = f"error: {exc}"
994
+ return result
995
+
996
+ # ------------------------------------------------------------------
997
+ # OAI-PMH harvesting
998
+ # ------------------------------------------------------------------
999
+
1000
+ # Harvest types for OAI-PMH harvesting
1001
+ HARVEST_TYPES = ("NONE", "METADATA_ONLY", "METADATA_AND_REF", "METADATA_AND_BITSTREAMS")
1002
+
1003
+ def get_harvester_metadata_configs(self) -> List[Dict[str, Any]]:
1004
+ """Get harvester metadata configurations.
1005
+
1006
+ Returns:
1007
+ List[Dict[str, Any]]: List of harvester metadata configurations, e.g.,
1008
+ [{"id": "dc", "label": "Simple Dublin Core", "nameSpace": ...}, ...]
1009
+ """
1010
+ return self._get_json(self._url("/config/harvestermetadata"), "Get harvester metadata configs").get(
1011
+ "configs") or []
1012
+
1013
+ def get_harvester(self, collection_uuid: str) -> Dict[str, Any]:
1014
+ """Harvest settings and status (collection admins only).
1015
+
1016
+ Parameters:
1017
+ collection_uuid (str): UUID of the collection
1018
+
1019
+ Returns:
1020
+ Dict[str, Any]: Harvester settings and status
1021
+ """
1022
+ return self._get_json(self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/harvester"),
1023
+ f"Get harvester settings of {collection_uuid}")
1024
+
1025
+ def set_harvester(self, collection_uuid: str, oai_source: str, oai_set_id: Optional[str] = None,
1026
+ metadata_config_id: str = "dc", harvest_type: str = "METADATA_ONLY") -> Dict[str, Any]:
1027
+ """Turn the collection into an OAI-PMH harvested collection (the server checks the source).
1028
+
1029
+ Parameters:
1030
+ collection_uuid (str): UUID of the collection
1031
+ oai_source (str): OAI-PMH source URL
1032
+ oai_set_id (Optional[str]): OAI-PMH set ID
1033
+ metadata_config_id (str): Metadata configuration ID
1034
+ harvest_type (str): Harvest type, one of HARVEST_TYPES
1035
+
1036
+ Returns:
1037
+ Dict[str, Any]: Updated harvester settings
1038
+ """
1039
+ if harvest_type not in self.HARVEST_TYPES:
1040
+ raise ValueError(f"harvest_type must be one of {self.HARVEST_TYPES}")
1041
+ body = {"harvest_type": harvest_type, "oai_source": oai_source, "oai_set_id": oai_set_id,
1042
+ "metadata_config_id": metadata_config_id}
1043
+ return self._put_harvester(collection_uuid, body)
1044
+
1045
+ def disable_harvesting(self, collection_uuid: str) -> Dict[str, Any]:
1046
+ """Disable OAI-PMH harvesting for the collection.
1047
+
1048
+ Parameters:
1049
+ collection_uuid (str): UUID of the collection
1050
+
1051
+ Returns:
1052
+ Dict[str, Any]: Updated harvester settings
1053
+ """
1054
+ return self._put_harvester(collection_uuid, {"harvest_type": "NONE"})
1055
+
1056
+ def _put_harvester(self, collection_uuid: str, body: Dict[str, Any]) -> Dict[str, Any]:
1057
+ """Update harvester settings for the collection.
1058
+
1059
+ Parameters:
1060
+ collection_uuid (str): UUID of the collection
1061
+ body (Dict[str, Any]): Request body containing harvester settings
1062
+
1063
+ Returns:
1064
+ Dict[str, Any]: Updated harvester settings
1065
+ """
1066
+ response = self._request("PUT", self._url(Endpoints.COLLECTIONS, f"/{collection_uuid}/harvester"),
1067
+ f"Update harvester settings of {collection_uuid}", expected=(200, 204),
1068
+ headers=self._headers(), json=body)
1069
+ return self._json_or_none(response) or self.get_harvester(collection_uuid)