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/groups.py ADDED
@@ -0,0 +1,599 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ EPerson group management for DSpace API (DSpace 7+ / DSpace-CRIS 7+)
4
+
5
+ Groups
6
+ GET /eperson/groups list
7
+ POST /eperson/groups create
8
+ GET /eperson/groups/{uuid}
9
+ PATCH /eperson/groups/{uuid} rename (/name), metadata
10
+ DELETE /eperson/groups/{uuid}
11
+ GET /eperson/groups/{uuid}/object linked community/collection (role groups)
12
+ GET /eperson/groups/search/byMetadata?query=
13
+ GET /eperson/groups/search/isNotMemberOf?group=&query=
14
+
15
+ Members
16
+ GET|POST /eperson/groups/{uuid}/subgroups (POST body: text/uri-list)
17
+ DELETE /eperson/groups/{uuid}/subgroups/{subgroupUuid}
18
+ GET|POST /eperson/groups/{uuid}/epersons (POST body: text/uri-list)
19
+ DELETE /eperson/groups/{uuid}/epersons/{epersonUuid}
20
+
21
+ Role groups of communities and collections
22
+ GET|POST|DELETE /core/communities/{uuid}/adminGroup
23
+ GET|POST|DELETE /core/collections/{uuid}/adminGroup
24
+ /core/collections/{uuid}/submittersGroup
25
+ /core/collections/{uuid}/itemReadGroup default READ for new items
26
+ /core/collections/{uuid}/bitstreamReadGroup default READ for new files
27
+ /core/collections/{uuid}/workflowGroups/{role} e.g. reviewer, editor, finaleditor
28
+
29
+ Groups and epersons can be referenced by UUID, by HAL href, by the object
30
+ dict the API returned, or by name (groups) / email (epersons).
31
+ """
32
+
33
+ import logging
34
+ from typing import Any, Dict, Iterable, List, Optional, Union
35
+
36
+ from ._base import BaseManager
37
+ from .projection import EmbedArg, ProjectionArg
38
+ from .config import Endpoints
39
+ from .exceptions import NotFoundError
40
+
41
+ # Logger for the module
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # API endpoints for DSpace groups and role groups
45
+ GROUPS = getattr(Endpoints, "GROUPS", "/eperson/groups")
46
+ EPERSONS = getattr(Endpoints, "EPERSONS", "/eperson/epersons")
47
+ COMMUNITIES = getattr(Endpoints, "COMMUNITIES", "/core/communities")
48
+ COLLECTIONS = getattr(Endpoints, "COLLECTIONS", "/core/collections")
49
+
50
+ # Role group endpoints per DSpace object type
51
+ ADMIN_GROUP = "adminGroup"
52
+ SUBMITTERS_GROUP = "submittersGroup"
53
+ ITEM_READ_GROUP = "itemReadGroup"
54
+ BITSTREAM_READ_GROUP = "bitstreamReadGroup"
55
+ WORKFLOW_GROUP_PREFIX = "workflowGroups/"
56
+
57
+ # Role groups for communities and collections
58
+ COMMUNITY_ROLES = (ADMIN_GROUP,)
59
+ COLLECTION_ROLES = (ADMIN_GROUP, SUBMITTERS_GROUP, ITEM_READ_GROUP, BITSTREAM_READ_GROUP)
60
+ _DSO_PATHS = {"community": COMMUNITIES, "collection": COLLECTIONS}
61
+
62
+ Ref = Union[str, Dict[str, Any]]
63
+
64
+
65
+ class GroupManager(BaseManager):
66
+ """Manages DSpace groups, their members, and community/collection role groups"""
67
+
68
+ def __init__(self, auth):
69
+ """Initialize the GroupManager with authentication and configuration.
70
+
71
+ Parameters:
72
+ auth: Authentication object containing session and configuration.
73
+ """
74
+ super().__init__(auth)
75
+ self._name_cache: Dict[str, str] = {}
76
+ self._eperson_manager = None
77
+
78
+ @property
79
+ def epersons(self):
80
+ """EPersonManager used to resolve eperson references given by email."""
81
+ if self._eperson_manager is None:
82
+ from .epersons import EPersonManager
83
+ self._eperson_manager = EPersonManager(self.auth)
84
+ return self._eperson_manager
85
+
86
+ # ------------------------------------------------------------------
87
+ # Groups
88
+ # ------------------------------------------------------------------
89
+
90
+ def get_group(self, group: Ref, embed_object: bool = False, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
91
+ """Retrieve a group by its UUID or reference.
92
+
93
+ Parameters:
94
+ group (Ref): UUID or reference of the group.
95
+ embed_object (bool): Whether to embed the full object.
96
+ all_languages (Optional[bool]): Whether to include all languages.
97
+ projection (ProjectionArg): Projection argument for the request.
98
+ embed (EmbedArg): Embed argument for the request.
99
+
100
+ Returns:
101
+ Dict[str, Any]: The group details.
102
+ """
103
+ uuid = self.resolve_group(group)
104
+ params = [("embed", "object")] if embed_object else None
105
+ return self._get_json(self._url(GROUPS, f"/{uuid}"), f"Get group {uuid}", params, all_languages=all_languages, projection=projection, embed=embed)
106
+
107
+ def iter_groups(self, page_size: Optional[int] = None, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None):
108
+ """Every group in the repository (admin only). Can be a lot.
109
+
110
+ Parameters:
111
+ page_size (Optional[int]): Number of groups per page.
112
+ all_languages (Optional[bool]): Whether to include all languages.
113
+ projection (ProjectionArg): Projection argument for the request.
114
+ embed (EmbedArg): Embed argument for the request.
115
+
116
+ Returns:
117
+ Iterator[Dict[str, Any]]: An iterator over the groups.
118
+ """
119
+ return self._iter_embedded(self._url(GROUPS), "groups", "List groups", page_size=page_size,
120
+ all_languages=all_languages, projection=projection, embed=embed)
121
+
122
+ def search_groups(self, query: str, max_results: Optional[int] = None,
123
+ embed_object: bool = False, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
124
+ """Search by name or exact UUID (byMetadata).
125
+
126
+ Parameters:
127
+ query (str): Search query for group names or exact UUID.
128
+ max_results (Optional[int]): Maximum number of results to return.
129
+ embed_object (bool): Whether to embed the full object.
130
+ all_languages (Optional[bool]): Whether to include all languages.
131
+ projection (ProjectionArg): Projection argument for the request.
132
+ embed (EmbedArg): Embed argument for the request.
133
+
134
+ Returns:
135
+ List[Dict[str, Any]]: A list of groups matching the search query.
136
+ """
137
+ params = [("query", query)] + ([("embed", "object")] if embed_object else [])
138
+ url = self._url(GROUPS, "/search/byMetadata")
139
+ return list(self._iter_embedded(url, "groups", f"Search groups '{query}'", params, max_results=max_results,
140
+ all_languages=all_languages, projection=projection, embed=embed))
141
+
142
+ def search_groups_not_member_of(self, group: Ref, query: str = "", max_results: Optional[int] = None,
143
+ embed_object: bool = True) -> List[Dict[str, Any]]:
144
+ """Groups that could be added as subgroups of `group` (isNotMemberOf).
145
+
146
+ Parameters:
147
+ group (Ref): UUID or reference of the parent group.
148
+ query (str): Search query for group names.
149
+ max_results (Optional[int]): Maximum number of results to return.
150
+ embed_object (bool): Whether to embed the full object.
151
+
152
+ Returns:
153
+ List[Dict[str, Any]]: A list of groups that can be added as subgroups.
154
+ """
155
+ params = [("group", self.resolve_group(group)), ("query", query)]
156
+ if embed_object:
157
+ params.append(("embed", "object"))
158
+ url = self._url(GROUPS, "/search/isNotMemberOf")
159
+ return list(self._iter_embedded(url, "groups", "Search addable groups", params, max_results=max_results))
160
+
161
+ def find_group_by_name(self, name: str) -> Optional[Dict[str, Any]]:
162
+ """Exact (case-insensitive) name match, or None."""
163
+ for group in self.search_groups(name):
164
+ if (group.get("name") or "").lower() == name.lower():
165
+ return group
166
+ return None
167
+
168
+ def resolve_group(self, group: Ref) -> str:
169
+ """UUID for a group given as UUID, href, dict or name (names are cached).
170
+
171
+ Parameters:
172
+ group (Ref): UUID, href, dict, or name of the group.
173
+
174
+ Returns:
175
+ str: UUID of the group.
176
+ """
177
+ uuid = self._uuid_of(group)
178
+ if uuid:
179
+ return uuid
180
+ if not isinstance(group, str) or not group:
181
+ raise ValueError(f"Cannot resolve group reference {group!r}")
182
+ key = group.lower()
183
+ if key not in self._name_cache:
184
+ found = self.find_group_by_name(group)
185
+ if not found:
186
+ raise NotFoundError(f"Group {group!r} not found")
187
+ self._name_cache[key] = found["uuid"]
188
+ return self._name_cache[key]
189
+
190
+ def create_group(self, name: str, description: Optional[str] = None,
191
+ metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
192
+ """Create a standalone group (admin only). 422 if the name already exists.
193
+
194
+ Parameters:
195
+ name (str): Name of the group to create.
196
+ description (Optional[str]): Description of the group.
197
+ metadata (Optional[Dict[str, Any]]): Metadata for the group.
198
+
199
+ Returns:
200
+ Dict[str, Any]: The created group object.
201
+ """
202
+ md = dict(metadata or {})
203
+ if description and "dc.description" not in md:
204
+ md["dc.description"] = description
205
+ body = {"name": name, "metadata": self._format_metadata(md)}
206
+ group = self._request("POST", self._url(GROUPS), f"Create group '{name}'", expected=(200, 201),
207
+ headers=self._headers(), json=body).json()
208
+ logger.info("Created group %s (%s)", group.get("uuid"), name)
209
+ return group
210
+
211
+ def get_or_create_group(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
212
+ """Get a group by name, or create it if it doesn't exist.
213
+
214
+ Parameters:
215
+ name (str): Name of the group to get or create.
216
+ description (Optional[str]): Description of the group if it needs to be created.
217
+
218
+ Returns:
219
+ Dict[str, Any]: The found or created group object.
220
+ """
221
+ return self.find_group_by_name(name) or self.create_group(name, description)
222
+
223
+ def patch_group(self, group: Ref, operations: List[Dict[str, Any]]) -> Dict[str, Any]:
224
+ """Apply a series of JSON Patch operations to a group.
225
+
226
+ Parameters:
227
+ group (Ref): UUID or reference of the group to patch.
228
+ operations (List[Dict[str, Any]]): List of JSON Patch operations.
229
+
230
+ Returns:
231
+ Dict[str, Any]: The updated group object.
232
+ """
233
+ uuid = self.resolve_group(group)
234
+ response = self._request("PATCH", self._url(GROUPS, f"/{uuid}"), f"Patch group {uuid}",
235
+ headers=self._headers(), json=operations)
236
+ self._name_cache.clear()
237
+ return response.json()
238
+
239
+ def rename_group(self, group: Ref, new_name: str) -> Dict[str, Any]:
240
+ """Permanent groups and role groups (part of a DSO) cannot be renamed (422).
241
+
242
+ Parameters:
243
+ group (Ref): UUID or reference of the group to rename.
244
+ new_name (str): New name for the group.
245
+
246
+ Returns:
247
+ Dict[str, Any]: The updated group object.
248
+ """
249
+ return self.patch_group(group, [{"op": "replace", "path": "/name", "value": new_name}])
250
+
251
+ def update_group_metadata(self, group: Ref, operation: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
252
+ """Update the metadata of a group using JSON Patch operations.
253
+
254
+ Parameters:
255
+ group (Ref): UUID or reference of the group to update.
256
+ operation (str): JSON Patch operation (e.g., "add", "replace", "remove").
257
+ metadata (Dict[str, Any]): Metadata to apply with the operation.
258
+
259
+ Returns:
260
+ Dict[str, Any]: The updated group object.
261
+ """
262
+ return self.patch_group(group, self._metadata_patch_ops(operation, metadata))
263
+
264
+ def delete_group(self, group: Ref) -> bool:
265
+ """Delete a standalone group. Role groups must be removed via delete_role_group()."""
266
+ uuid = self.resolve_group(group)
267
+ self._request("DELETE", self._url(GROUPS, f"/{uuid}"), f"Delete group {uuid}", expected=(204,))
268
+ self._name_cache.clear()
269
+ logger.info("Deleted group %s", uuid)
270
+ return True
271
+
272
+ def get_group_object(self, group: Ref) -> Optional[Dict[str, Any]]:
273
+ """The community/collection a role group belongs to, or None for standalone groups.
274
+
275
+ Parameters:
276
+ group (Ref): UUID or reference of the group.
277
+
278
+ Returns:
279
+ Optional[Dict[str, Any]]: The linked community/collection object, or None.
280
+ """
281
+ uuid = self.resolve_group(group)
282
+ return self._get_optional(self._url(GROUPS, f"/{uuid}/object"), f"Get object of group {uuid}")
283
+
284
+ # ------------------------------------------------------------------
285
+ # Members: subgroups
286
+ # ------------------------------------------------------------------
287
+
288
+ def get_subgroups(self, group: Ref, embed_object: bool = True, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
289
+ """Direct subgroups (with their linked community/collection embedded by default).
290
+
291
+ Parameters:
292
+ group (Ref): UUID or reference of the parent group.
293
+ embed_object (bool): Whether to embed the full object of the subgroups.
294
+ all_languages (Optional[bool]): Whether to include all languages.
295
+ projection (ProjectionArg): Projection argument for the request.
296
+ embed (EmbedArg): Embed argument for the request.
297
+
298
+ Returns:
299
+ List[Dict[str, Any]]: A list of direct subgroups.
300
+ """
301
+ uuid = self.resolve_group(group)
302
+ params = [("embed", "object")] if embed_object else None
303
+ # The REST contract documents "_embedded.groups"; some versions use "subgroups"
304
+ url = self._url(GROUPS, f"/{uuid}/subgroups")
305
+ return list(self._iter_embedded(url, ("subgroups", "groups"), f"Get subgroups of {uuid}", params,
306
+ all_languages=all_languages, projection=projection, embed=embed))
307
+
308
+ def add_subgroup(self, group: Ref, subgroup: Ref) -> bool:
309
+ """
310
+ Add a subgroup to a parent group.
311
+
312
+ Parameters:
313
+ group (Ref): UUID or reference of the parent group.
314
+ subgroup (Ref): UUID or reference of the subgroup to add.
315
+
316
+ Returns:
317
+ bool: True if the operation was successful.
318
+
319
+ Raises:
320
+ HTTPError: If the subgroup doesn't exist or the link would create a cycle (422).
321
+ """
322
+ parent, child = self.resolve_group(group), self.resolve_group(subgroup)
323
+ self._request("POST", self._url(GROUPS, f"/{parent}/subgroups"), f"Add subgroup {child} to {parent}",
324
+ expected=(200, 204), headers=self._headers("text/uri-list"),
325
+ data=self._uri_list(GROUPS, f"/{child}"))
326
+ logger.info("Added subgroup %s to group %s", child, parent)
327
+ return True
328
+
329
+ def remove_subgroup(self, group: Ref, subgroup: Ref) -> bool:
330
+ """No-op (still 204) if it wasn't a subgroup.
331
+
332
+ Parameters:
333
+ group (Ref): UUID or reference of the parent group.
334
+ subgroup (Ref): UUID or reference of the subgroup to remove.
335
+
336
+ Returns:
337
+ bool: True if the operation was successful.
338
+ """
339
+ parent, child = self.resolve_group(group), self.resolve_group(subgroup)
340
+ self._request("DELETE", self._url(GROUPS, f"/{parent}/subgroups/{child}"),
341
+ f"Remove subgroup {child} from {parent}", expected=(204,))
342
+ logger.info("Removed subgroup %s from group %s", child, parent)
343
+ return True
344
+
345
+ # ------------------------------------------------------------------
346
+ # Members: epersons
347
+ # ------------------------------------------------------------------
348
+
349
+ def get_group_epersons(self, group: Ref, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
350
+ """Direct eperson members.
351
+
352
+ Parameters:
353
+ group (Ref): UUID or reference of the group.
354
+ all_languages (Optional[bool]): Whether to include all languages.
355
+ projection (ProjectionArg): Projection argument for the request.
356
+ embed (EmbedArg): Embed argument for the request.
357
+
358
+ Returns:
359
+ List[Dict[str, Any]]: A list of direct eperson members.
360
+ """
361
+ uuid = self.resolve_group(group)
362
+ return list(self._iter_embedded(self._url(GROUPS, f"/{uuid}/epersons"), "epersons",
363
+ f"Get epersons of group {uuid}", all_languages=all_languages, projection=projection, embed=embed))
364
+
365
+ def add_eperson(self, group: Ref, eperson: Ref) -> bool:
366
+ """Add an eperson to a group.
367
+
368
+ Parameters:
369
+ group (Ref): UUID or reference of the group.
370
+ eperson (Ref): UUID or reference of the eperson to add.
371
+
372
+ Returns:
373
+ bool: True if the operation was successful.
374
+ """
375
+ group_uuid, person_uuid = self.resolve_group(group), self.epersons.resolve_eperson(eperson)
376
+ self._request("POST", self._url(GROUPS, f"/{group_uuid}/epersons"),
377
+ f"Add eperson {person_uuid} to {group_uuid}", expected=(200, 204),
378
+ headers=self._headers("text/uri-list"), data=self._uri_list(EPERSONS, f"/{person_uuid}"))
379
+ logger.info("Added eperson %s to group %s", person_uuid, group_uuid)
380
+ return True
381
+
382
+ def remove_eperson(self, group: Ref, eperson: Ref) -> bool:
383
+ """Remove an eperson from a group.
384
+
385
+ Parameters:
386
+ group (Ref): UUID or reference of the group.
387
+ eperson (Ref): UUID or reference of the eperson to remove.
388
+
389
+ Returns:
390
+ bool: True if the operation was successful.
391
+ """
392
+ group_uuid, person_uuid = self.resolve_group(group), self.epersons.resolve_eperson(eperson)
393
+ self._request("DELETE", self._url(GROUPS, f"/{group_uuid}/epersons/{person_uuid}"),
394
+ f"Remove eperson {person_uuid} from {group_uuid}", expected=(204,))
395
+ logger.info("Removed eperson %s from group %s", person_uuid, group_uuid)
396
+ return True
397
+
398
+ # ------------------------------------------------------------------
399
+ # Members: bulk
400
+ # ------------------------------------------------------------------
401
+
402
+ def add_members(self, group: Ref, epersons: Iterable[Ref] = (), subgroups: Iterable[Ref] = ()) -> Dict[str, List[str]]:
403
+ """Add epersons and subgroups, skipping those that are already direct members.
404
+
405
+ Parameters:
406
+ group (Ref): UUID or reference of the group.
407
+ epersons (Iterable[Ref], optional): Epersons to add. Defaults to ().
408
+ subgroups (Iterable[Ref], optional): Subgroups to add. Defaults to ().
409
+
410
+ Returns:
411
+ Dict[str, List[str]]: A dictionary with keys "added_epersons" and "added_subgroups" listing the added members.
412
+ """
413
+ uuid = self.resolve_group(group)
414
+ result = {"added_epersons": [], "added_subgroups": []}
415
+ epersons, subgroups = list(epersons), list(subgroups)
416
+ if epersons:
417
+ current = {e["uuid"] for e in self.get_group_epersons(uuid)}
418
+ for ref in epersons:
419
+ person = self.epersons.resolve_eperson(ref)
420
+ if person not in current:
421
+ self.add_eperson(uuid, person)
422
+ current.add(person)
423
+ result["added_epersons"].append(person)
424
+ if subgroups:
425
+ current = {g["uuid"] for g in self.get_subgroups(uuid, embed_object=False)}
426
+ for ref in subgroups:
427
+ child = self.resolve_group(ref)
428
+ if child not in current:
429
+ self.add_subgroup(uuid, child)
430
+ current.add(child)
431
+ result["added_subgroups"].append(child)
432
+ return result
433
+
434
+ def sync_members(self, group: Ref, epersons: Optional[Iterable[Ref]] = None,
435
+ subgroups: Optional[Iterable[Ref]] = None, dry_run: bool = False) -> Dict[str, List[str]]:
436
+ """
437
+ Make the direct members exactly the given lists. None leaves that kind
438
+ of member untouched; an empty list removes all of them.
439
+
440
+ Parameters:
441
+ group (Ref): UUID or reference of the group.
442
+ epersons (Optional[Iterable[Ref]]): Desired list of eperson members. None leaves unchanged.
443
+ subgroups (Optional[Iterable[Ref]]): Desired list of subgroup members. None leaves unchanged.
444
+ dry_run (bool): If True, only simulate the changes without applying them.
445
+
446
+ Returns:
447
+ Dict[str, List[str]]: A dictionary with keys "added_epersons", "removed_epersons", "added_subgroups", and "removed_subgroups" listing the changes made.
448
+ """
449
+ uuid = self.resolve_group(group)
450
+ result = {"added_epersons": [], "removed_epersons": [], "added_subgroups": [], "removed_subgroups": []}
451
+ if epersons is not None:
452
+ wanted = {self.epersons.resolve_eperson(e) for e in epersons}
453
+ current = {e["uuid"] for e in self.get_group_epersons(uuid)}
454
+ result["added_epersons"] = sorted(wanted - current)
455
+ result["removed_epersons"] = sorted(current - wanted)
456
+ if subgroups is not None:
457
+ wanted = {self.resolve_group(g) for g in subgroups}
458
+ current = {g["uuid"] for g in self.get_subgroups(uuid, embed_object=False)}
459
+ result["added_subgroups"] = sorted(wanted - current)
460
+ result["removed_subgroups"] = sorted(current - wanted)
461
+ if dry_run:
462
+ logger.info("[dry-run] sync_members %s: %s", uuid, result)
463
+ return result
464
+ for e in result["added_epersons"]:
465
+ self.add_eperson(uuid, e)
466
+ for e in result["removed_epersons"]:
467
+ self.remove_eperson(uuid, e)
468
+ for g in result["added_subgroups"]:
469
+ self.add_subgroup(uuid, g)
470
+ for g in result["removed_subgroups"]:
471
+ self.remove_subgroup(uuid, g)
472
+ return result
473
+
474
+ # ------------------------------------------------------------------
475
+ # Role groups of communities and collections
476
+ # ------------------------------------------------------------------
477
+
478
+ def _role_url(self, dso_type: str, dso_uuid: str, role: str) -> str:
479
+ """Construct the URL for a role group of a community or collection.
480
+
481
+ Parameters:
482
+ dso_type (str): Either "community" or "collection".
483
+ dso_uuid (str): UUID of the community or collection.
484
+ role (str): Role name.
485
+
486
+ Returns:
487
+ str: The URL for the role group.
488
+
489
+ Raises:
490
+ ValueError: If the dso_type or role is invalid.
491
+ """
492
+ if dso_type not in _DSO_PATHS:
493
+ raise ValueError(f"dso_type must be 'community' or 'collection', not {dso_type!r}")
494
+ allowed = COMMUNITY_ROLES if dso_type == "community" else COLLECTION_ROLES
495
+ if role not in allowed and not (dso_type == "collection" and role.startswith(WORKFLOW_GROUP_PREFIX)
496
+ and len(role) > len(WORKFLOW_GROUP_PREFIX)):
497
+ raise ValueError(f"Unknown {dso_type} role {role!r}; expected one of {allowed}"
498
+ + (" or 'workflowGroups/<role>'" if dso_type == "collection" else ""))
499
+ return self._url(_DSO_PATHS[dso_type], f"/{dso_uuid}/{role}")
500
+
501
+ def get_role_group(self, dso_type: str, dso_uuid: str, role: str) -> Optional[Dict[str, Any]]:
502
+ """
503
+ The role group, or None if it hasn't been created (204).
504
+
505
+ For itemReadGroup / bitstreamReadGroup DSpace returns the group of
506
+ the collection's DEFAULT_*_READ policy - on an unrestricted
507
+ collection that is the Anonymous group itself. That is reported as
508
+ None ("no read group yet"), so nobody adds members to Anonymous by
509
+ accident (which would affect every object readable by Anonymous).
510
+
511
+ Parameters:
512
+ dso_type (str): Either "community" or "collection".
513
+ dso_uuid (str): UUID of the community or collection.
514
+ role (str): Role name.
515
+
516
+ Returns:
517
+ Optional[Dict[str, Any]]: The role group if it exists, otherwise None.
518
+ """
519
+ group = self._get_optional(self._role_url(dso_type, dso_uuid, role), f"Get {role} of {dso_type} {dso_uuid}")
520
+ if group and role in (ITEM_READ_GROUP, BITSTREAM_READ_GROUP) and \
521
+ str(group.get("name", "")).lower() == "anonymous":
522
+ return None
523
+ return group
524
+
525
+ def create_role_group(self, dso_type: str, dso_uuid: str, role: str, description: Optional[str] = None,
526
+ metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
527
+ """
528
+ Create a role group; DSpace names it itself (e.g. COLLECTION_<uuid>_SUBMIT).
529
+ For itemReadGroup / bitstreamReadGroup this replaces the Anonymous
530
+ DEFAULT_*_READ policy of the collection with the new group.
531
+
532
+ Parameters:
533
+ dso_type (str): Either "community" or "collection".
534
+ dso_uuid (str): UUID of the community or collection.
535
+ role (str): Role name.
536
+ description (Optional[str]): Description for the role group.
537
+ metadata (Optional[Dict[str, Any]]): Additional metadata for the role group.
538
+
539
+ Returns:
540
+ Dict[str, Any]: The created role group.
541
+ """
542
+ md = dict(metadata or {})
543
+ if description and "dc.description" not in md:
544
+ md["dc.description"] = description
545
+ url = self._role_url(dso_type, dso_uuid, role)
546
+ group = self._request("POST", url, f"Create {role} of {dso_type} {dso_uuid}", expected=(200, 201),
547
+ headers=self._headers(), json={"metadata": self._format_metadata(md)}).json()
548
+ logger.info("Created %s %s for %s %s", role, group.get("uuid"), dso_type, dso_uuid)
549
+ return group
550
+
551
+ def get_or_create_role_group(self, dso_type: str, dso_uuid: str, role: str,
552
+ description: Optional[str] = None) -> Dict[str, Any]:
553
+ """Get the role group if it exists, otherwise create it.
554
+
555
+ Parameters:
556
+ dso_type (str): Either "community" or "collection".
557
+ dso_uuid (str): UUID of the community or collection.
558
+ role (str): Role name.
559
+ description (Optional[str], optional): Description for the role group. Defaults to None.
560
+
561
+ Returns:
562
+ Dict[str, Any]: The role group.
563
+ """
564
+ return (self.get_role_group(dso_type, dso_uuid, role)
565
+ or self.create_role_group(dso_type, dso_uuid, role, description))
566
+
567
+ def delete_role_group(self, dso_type: str, dso_uuid: str, role: str) -> bool:
568
+ """Delete the role group.
569
+
570
+ Parameters:
571
+ dso_type (str): Either "community" or "collection".
572
+ dso_uuid (str): UUID of the community or collection.
573
+ role (str): Role name.
574
+
575
+ Returns:
576
+ bool: True if the role group was deleted successfully.
577
+ """
578
+ self._request("DELETE", self._role_url(dso_type, dso_uuid, role), f"Delete {role} of {dso_type} {dso_uuid}",
579
+ expected=(204,))
580
+ logger.info("Deleted %s of %s %s", role, dso_type, dso_uuid)
581
+ return True
582
+
583
+ def ensure_role_members(self, dso_type: str, dso_uuid: str, role: str, epersons: Iterable[Ref] = (),
584
+ subgroups: Iterable[Ref] = (), description: Optional[str] = None) -> Dict[str, Any]:
585
+ """Create the role group if needed and add the members. Returns {"group", "added_*"}.
586
+
587
+ Parameters:
588
+ dso_type (str): Either "community" or "collection".
589
+ dso_uuid (str): UUID of the community or collection.
590
+ role (str): Role name.
591
+ epersons (Iterable[Ref], optional): Epersons to add. Defaults to ().
592
+ subgroups (Iterable[Ref], optional): Subgroups to add. Defaults to ().
593
+ description (Optional[str], optional): Description for the role group. Defaults to None.
594
+
595
+ Returns:
596
+ Dict[str, Any]: A dictionary containing the role group and the added members.
597
+ """
598
+ group = self.get_or_create_role_group(dso_type, dso_uuid, role, description)
599
+ return {"group": group, **self.add_members(group["uuid"], epersons, subgroups)}