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
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Permission (resource policy) management for DSpace API (DSpace 7+ / DSpace-CRIS 7+)
|
|
4
|
+
|
|
5
|
+
In DSpace every permission is a *resource policy*: "group G (or eperson E)
|
|
6
|
+
may perform ACTION on resource R, optionally between startDate and endDate".
|
|
7
|
+
Items, bundles and bitstreams each carry their own policies, so e.g. hiding
|
|
8
|
+
an item from the public means removing the Anonymous READ policy from the
|
|
9
|
+
item AND, if the files should be hidden too, from its bundles/bitstreams.
|
|
10
|
+
|
|
11
|
+
Resource policies
|
|
12
|
+
GET /authz/resourcepolicies/{id}
|
|
13
|
+
GET /authz/resourcepolicies/search/resource?uuid=&action=
|
|
14
|
+
GET /authz/resourcepolicies/search/group?uuid=&resource=
|
|
15
|
+
GET /authz/resourcepolicies/search/eperson?uuid=&resource=
|
|
16
|
+
POST /authz/resourcepolicies?resource=&group=|eperson=
|
|
17
|
+
PATCH /authz/resourcepolicies/{id} name, description, dates
|
|
18
|
+
DELETE /authz/resourcepolicies/{id}
|
|
19
|
+
GET|PUT /authz/resourcepolicies/{id}/group|eperson
|
|
20
|
+
GET /authz/resourcepolicies/{id}/resource
|
|
21
|
+
|
|
22
|
+
Groups (lookup only)
|
|
23
|
+
GET /eperson/groups/{uuid}
|
|
24
|
+
GET /eperson/groups/search/byMetadata?query=
|
|
25
|
+
|
|
26
|
+
Bulk access control (DSpace 7.6+)
|
|
27
|
+
GET /config/bulkaccessconditionoptions[/{name}]
|
|
28
|
+
POST /system/scripts/bulk-access-control/processes
|
|
29
|
+
GET /system/processes/{id}
|
|
30
|
+
|
|
31
|
+
Typical use
|
|
32
|
+
-----------
|
|
33
|
+
perms = PermissionManager(client.auth)
|
|
34
|
+
|
|
35
|
+
# Dry run: what would be removed?
|
|
36
|
+
rows = perms.remove_group_access_from_item(item_uuid, include_bitstreams=True)
|
|
37
|
+
|
|
38
|
+
# Really remove Anonymous READ from item, bundles and bitstreams
|
|
39
|
+
rows = perms.remove_group_access_from_item(item_uuid, include_bitstreams=True, dry_run=False)
|
|
40
|
+
|
|
41
|
+
# Undo, using the rows (e.g. re-read from a CSV report)
|
|
42
|
+
perms.restore_policies(rows, dry_run=False)
|
|
43
|
+
|
|
44
|
+
Notes
|
|
45
|
+
-----
|
|
46
|
+
- Removing Anonymous READ from existing items does not change the
|
|
47
|
+
collection's DEFAULT_ITEM_READ / DEFAULT_BITSTREAM_READ policies, so new
|
|
48
|
+
items deposited into that collection will still be public.
|
|
49
|
+
- A bitstream's download permission is its own READ policy. Removing READ
|
|
50
|
+
from the item alone hides the item page, not necessarily its files.
|
|
51
|
+
- All write methods default to dry_run=True where they remove things.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import csv
|
|
55
|
+
import json
|
|
56
|
+
import logging
|
|
57
|
+
import time
|
|
58
|
+
from datetime import date, datetime
|
|
59
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
|
|
60
|
+
|
|
61
|
+
from ._base import BaseManager, XSRF_RESPONSE_HEADER
|
|
62
|
+
from .config import Endpoints
|
|
63
|
+
from .exceptions import DSpaceError, NotFoundError
|
|
64
|
+
from .utils import validate_uuid
|
|
65
|
+
|
|
66
|
+
# Logger for the module
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
# API endpoints for DSpace permissions and groups
|
|
70
|
+
RESOURCE_POLICIES = getattr(Endpoints, "RESOURCE_POLICIES", "/authz/resourcepolicies")
|
|
71
|
+
GROUPS = getattr(Endpoints, "GROUPS", "/eperson/groups")
|
|
72
|
+
EPERSONS = getattr(Endpoints, "EPERSONS", "/eperson/epersons")
|
|
73
|
+
BULK_ACCESS_CONDITION_OPTIONS = getattr(Endpoints, "BULK_ACCESS_CONDITION_OPTIONS",
|
|
74
|
+
"/config/bulkaccessconditionoptions")
|
|
75
|
+
SCRIPTS = getattr(Endpoints, "SCRIPTS", "/system/scripts")
|
|
76
|
+
PROCESSES = getattr(Endpoints, "PROCESSES", "/system/processes")
|
|
77
|
+
DISCOVER_SEARCH = getattr(Endpoints, "DISCOVER_SEARCH", "/discover/search/objects")
|
|
78
|
+
|
|
79
|
+
# Built-in group names
|
|
80
|
+
ANONYMOUS = "Anonymous"
|
|
81
|
+
ADMINISTRATOR = "Administrator"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Actions:
|
|
85
|
+
"""Resource policy actions (org.dspace.core.Constants)"""
|
|
86
|
+
READ = "READ"
|
|
87
|
+
WRITE = "WRITE"
|
|
88
|
+
ADD = "ADD"
|
|
89
|
+
REMOVE = "REMOVE"
|
|
90
|
+
ADMIN = "ADMIN"
|
|
91
|
+
DELETE = "DELETE"
|
|
92
|
+
WITHDRAWN_READ = "WITHDRAWN_READ"
|
|
93
|
+
DEFAULT_BITSTREAM_READ = "DEFAULT_BITSTREAM_READ"
|
|
94
|
+
DEFAULT_ITEM_READ = "DEFAULT_ITEM_READ"
|
|
95
|
+
|
|
96
|
+
ALL = (READ, WRITE, ADD, REMOVE, ADMIN, DELETE, WITHDRAWN_READ,
|
|
97
|
+
DEFAULT_BITSTREAM_READ, DEFAULT_ITEM_READ)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PolicyTypes:
|
|
101
|
+
# Policy types used in DSpace resource policies
|
|
102
|
+
SUBMISSION = "TYPE_SUBMISSION"
|
|
103
|
+
WORKFLOW = "TYPE_WORKFLOW"
|
|
104
|
+
INHERITED = "TYPE_INHERITED"
|
|
105
|
+
CUSTOM = "TYPE_CUSTOM"
|
|
106
|
+
|
|
107
|
+
ALL = (SUBMISSION, WORKFLOW, INHERITED, CUSTOM)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Columns of the rows returned by remove_*/restore_* (and of CSV reports)
|
|
111
|
+
POLICY_ROW_FIELDS = [
|
|
112
|
+
"item_uuid", "resource_type", "resource_uuid", "resource_name",
|
|
113
|
+
"policy_id", "action", "policy_type", "group_name", "group_uuid",
|
|
114
|
+
"name", "description", "start_date", "end_date", "status",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Internal helpers and type definitions for permission management
|
|
118
|
+
_UNSET = object()
|
|
119
|
+
DateLike = Union[None, str, date, datetime]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fmt_date(value: DateLike) -> Optional[str]:
|
|
123
|
+
"""Normalise to YYYY-MM-DD (the format DSpace expects for policy dates).
|
|
124
|
+
|
|
125
|
+
Parameters:
|
|
126
|
+
value (DateLike): The date value to normalise.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Optional[str]: The normalised date string in YYYY-MM-DD format, or None if the input is None or empty.
|
|
130
|
+
"""
|
|
131
|
+
if value in (None, ""):
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(value, (date, datetime)):
|
|
134
|
+
return value.strftime("%Y-%m-%d")
|
|
135
|
+
value = str(value).strip()
|
|
136
|
+
return value[:10]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _as_list(value) -> Optional[List[str]]:
|
|
140
|
+
"""Convert a value to a list of strings.
|
|
141
|
+
|
|
142
|
+
Parameters:
|
|
143
|
+
value (Any): The value to convert to a list.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Optional[List[str]]: A list of strings if the input is not None, otherwise None.
|
|
147
|
+
"""
|
|
148
|
+
if value is None:
|
|
149
|
+
return None
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
return [value]
|
|
152
|
+
return list(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PermissionManager(BaseManager):
|
|
156
|
+
"""Manages DSpace resource policies, group lookups and bulk access control"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, auth):
|
|
159
|
+
"""Initialize the PermissionManager with the given authentication context."""
|
|
160
|
+
super().__init__(auth)
|
|
161
|
+
self._group_cache: Dict[str, Dict[str, Any]] = {}
|
|
162
|
+
self._bundle_manager = None
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def bundles(self):
|
|
166
|
+
"""Get the bundle manager, initializing it if necessary."""
|
|
167
|
+
if self._bundle_manager is None:
|
|
168
|
+
from .bundles import BundleManager # local import avoids a hard dependency at import time
|
|
169
|
+
self._bundle_manager = BundleManager(self.auth)
|
|
170
|
+
return self._bundle_manager
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Resource policies: read
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def get_policy(self, policy_id: int, embed: Sequence[str] = ("group", "eperson")) -> Dict[str, Any]:
|
|
177
|
+
"""Retrieve a specific resource policy by its ID, optionally embedding related group and eperson information.
|
|
178
|
+
|
|
179
|
+
Parameters:
|
|
180
|
+
policy_id (int): The ID of the resource policy to retrieve.
|
|
181
|
+
embed (Sequence[str], optional): The related entities to embed in the response.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dict[str, Any]: The resource policy data.
|
|
185
|
+
"""
|
|
186
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}")
|
|
187
|
+
params = [("embed", e) for e in embed]
|
|
188
|
+
return self._request("GET", url, f"Get resource policy {policy_id}", params=params).json()
|
|
189
|
+
|
|
190
|
+
def get_resource_policies(self, resource_uuid: str, action: Optional[str] = None,
|
|
191
|
+
embed: Sequence[str] = ("group", "eperson")) -> List[Dict[str, Any]]:
|
|
192
|
+
"""All policies attached to a DSpace object (item, bundle, bitstream, collection, ...).
|
|
193
|
+
|
|
194
|
+
Parameters:
|
|
195
|
+
resource_uuid (str): The UUID of the resource.
|
|
196
|
+
action (Optional[str], optional): The action to filter policies by.
|
|
197
|
+
embed (Sequence[str], optional): The related entities to embed in the response.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List[Dict[str, Any]]: The list of resource policies for the specified resource.
|
|
201
|
+
"""
|
|
202
|
+
params: List[Tuple[str, Any]] = [("uuid", resource_uuid)]
|
|
203
|
+
if action:
|
|
204
|
+
params.append(("action", action))
|
|
205
|
+
params += [("embed", e) for e in embed]
|
|
206
|
+
url = self._url(RESOURCE_POLICIES, "/search/resource")
|
|
207
|
+
return list(self._iter_embedded(url, "resourcepolicies",
|
|
208
|
+
f"Get policies of resource {resource_uuid}", params))
|
|
209
|
+
|
|
210
|
+
def get_group_policies(self, group_uuid: str, resource_uuid: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
211
|
+
"""Policies granted directly to a group (optionally limited to one resource).
|
|
212
|
+
|
|
213
|
+
Parameters:
|
|
214
|
+
group_uuid (str): The UUID of the group.
|
|
215
|
+
resource_uuid (Optional[str], optional): The UUID of the resource to filter by.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List[Dict[str, Any]]: The list of resource policies for the specified group.
|
|
219
|
+
"""
|
|
220
|
+
params: List[Tuple[str, Any]] = [("uuid", group_uuid)]
|
|
221
|
+
if resource_uuid:
|
|
222
|
+
params.append(("resource", resource_uuid))
|
|
223
|
+
url = self._url(RESOURCE_POLICIES, "/search/group")
|
|
224
|
+
return list(self._iter_embedded(url, "resourcepolicies", f"Get policies of group {group_uuid}", params))
|
|
225
|
+
|
|
226
|
+
def get_eperson_policies(self, eperson_uuid: str, resource_uuid: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
227
|
+
"""Policies granted directly to an eperson (not via group membership).
|
|
228
|
+
|
|
229
|
+
Parameters:
|
|
230
|
+
eperson_uuid (str): The UUID of the eperson.
|
|
231
|
+
resource_uuid (Optional[str], optional): The UUID of the resource to filter by.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
List[Dict[str, Any]]: The list of resource policies for the specified eperson.
|
|
235
|
+
"""
|
|
236
|
+
params: List[Tuple[str, Any]] = [("uuid", eperson_uuid)]
|
|
237
|
+
if resource_uuid:
|
|
238
|
+
params.append(("resource", resource_uuid))
|
|
239
|
+
url = self._url(RESOURCE_POLICIES, "/search/eperson")
|
|
240
|
+
return list(self._iter_embedded(url, "resourcepolicies", f"Get policies of eperson {eperson_uuid}", params))
|
|
241
|
+
|
|
242
|
+
def get_policy_group(self, policy_id: int) -> Optional[Dict[str, Any]]:
|
|
243
|
+
"""Get the group associated with a specific resource policy.
|
|
244
|
+
|
|
245
|
+
Parameters:
|
|
246
|
+
policy_id (int): The ID of the resource policy.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Optional[Dict[str, Any]]: The group data if available, otherwise None.
|
|
250
|
+
"""
|
|
251
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}/group")
|
|
252
|
+
r = self._request("GET", url, f"Get group of policy {policy_id}", expected=(200, 204))
|
|
253
|
+
return r.json() if r.status_code == 200 and r.content else None
|
|
254
|
+
|
|
255
|
+
def get_policy_eperson(self, policy_id: int) -> Optional[Dict[str, Any]]:
|
|
256
|
+
"""Get the eperson associated with a specific resource policy.
|
|
257
|
+
|
|
258
|
+
Parameters:
|
|
259
|
+
policy_id (int): The ID of the resource policy.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Optional[Dict[str, Any]]: The eperson data if available, otherwise None.
|
|
263
|
+
"""
|
|
264
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}/eperson")
|
|
265
|
+
r = self._request("GET", url, f"Get eperson of policy {policy_id}", expected=(200, 204))
|
|
266
|
+
return r.json() if r.status_code == 200 and r.content else None
|
|
267
|
+
|
|
268
|
+
def get_policy_resource(self, policy_id: int) -> Dict[str, Any]:
|
|
269
|
+
"""Get the resource associated with a specific resource policy.
|
|
270
|
+
|
|
271
|
+
Parameters:
|
|
272
|
+
policy_id (int): The ID of the resource policy.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Dict[str, Any]: The resource data.
|
|
276
|
+
"""
|
|
277
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}/resource")
|
|
278
|
+
return self._request("GET", url, f"Get resource of policy {policy_id}").json()
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# Resource policies: write
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def create_policy(self, resource_uuid: str, action: str = Actions.READ,
|
|
285
|
+
group: Optional[str] = None, eperson_uuid: Optional[str] = None,
|
|
286
|
+
policy_type: Optional[str] = PolicyTypes.CUSTOM,
|
|
287
|
+
name: Optional[str] = None, description: Optional[str] = None,
|
|
288
|
+
start_date: DateLike = None, end_date: DateLike = None) -> Dict[str, Any]:
|
|
289
|
+
"""
|
|
290
|
+
Create a policy on a resource for exactly one of group / eperson.
|
|
291
|
+
|
|
292
|
+
group may be a group UUID or a group name (e.g. "Anonymous").
|
|
293
|
+
start_date / end_date: 'YYYY-MM-DD', date or datetime (None = unlimited).
|
|
294
|
+
Only system administrators can create policies.
|
|
295
|
+
|
|
296
|
+
Parameters:
|
|
297
|
+
resource_uuid (str): The UUID of the resource to create the policy on.
|
|
298
|
+
action (str, optional): The action for the policy (default is Actions.READ).
|
|
299
|
+
group (Optional[str], optional): The UUID or name of the group to assign the policy to.
|
|
300
|
+
eperson_uuid (Optional[str], optional): The UUID of the eperson to assign the policy to.
|
|
301
|
+
policy_type (Optional[str], optional): The type of the policy (default is PolicyTypes.CUSTOM).
|
|
302
|
+
name (Optional[str], optional): The name of the policy.
|
|
303
|
+
description (Optional[str], optional): The description of the policy.
|
|
304
|
+
start_date (DateLike, optional): The start date of the policy.
|
|
305
|
+
end_date (DateLike, optional): The end date of the policy.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Dict[str, Any]: The created policy data.
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
if bool(group) == bool(eperson_uuid):
|
|
312
|
+
raise ValueError("Pass exactly one of group or eperson_uuid")
|
|
313
|
+
if action not in Actions.ALL:
|
|
314
|
+
raise ValueError(f"Unknown action {action!r}; expected one of {Actions.ALL}")
|
|
315
|
+
if policy_type is not None and policy_type not in PolicyTypes.ALL:
|
|
316
|
+
raise ValueError(f"Unknown policy_type {policy_type!r}; expected one of {PolicyTypes.ALL}")
|
|
317
|
+
|
|
318
|
+
params: List[Tuple[str, Any]] = [("resource", resource_uuid)]
|
|
319
|
+
if group:
|
|
320
|
+
# a UUID needs no lookup; names are resolved (and cached) via the API
|
|
321
|
+
params.append(("group", group if validate_uuid(group) else self.resolve_group(group)["uuid"]))
|
|
322
|
+
else:
|
|
323
|
+
params.append(("eperson", eperson_uuid))
|
|
324
|
+
|
|
325
|
+
body = {
|
|
326
|
+
"name": name,
|
|
327
|
+
"description": description,
|
|
328
|
+
"policyType": policy_type,
|
|
329
|
+
"action": action,
|
|
330
|
+
"startDate": _fmt_date(start_date),
|
|
331
|
+
"endDate": _fmt_date(end_date),
|
|
332
|
+
"type": "resourcepolicy",
|
|
333
|
+
}
|
|
334
|
+
url = self._url(RESOURCE_POLICIES)
|
|
335
|
+
policy = self._request("POST", url, f"Create {action} policy on {resource_uuid}",
|
|
336
|
+
expected=(200, 201), headers=self._headers(),
|
|
337
|
+
params=params, json=body).json()
|
|
338
|
+
logger.info("Created policy %s (%s) on %s", policy.get("id"), action, resource_uuid)
|
|
339
|
+
return policy
|
|
340
|
+
|
|
341
|
+
def patch_policy(self, policy_id: int, operations: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
342
|
+
"""Raw JSON Patch on a policy (paths: /name, /description, /startDate, /endDate).
|
|
343
|
+
|
|
344
|
+
Parameters:
|
|
345
|
+
policy_id (int): The ID of the resource policy to patch.
|
|
346
|
+
operations (List[Dict[str, Any]]): The list of JSON Patch operations to apply.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Dict[str, Any]: The updated policy data.
|
|
350
|
+
"""
|
|
351
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}")
|
|
352
|
+
return self._request("PATCH", url, f"Patch resource policy {policy_id}",
|
|
353
|
+
headers=self._headers("application/json"), json=operations).json()
|
|
354
|
+
|
|
355
|
+
def update_policy(self, policy_id: int, name=_UNSET, description=_UNSET,
|
|
356
|
+
start_date=_UNSET, end_date=_UNSET) -> Dict[str, Any]:
|
|
357
|
+
"""
|
|
358
|
+
Set or clear policy name / description / dates. Arguments left out are
|
|
359
|
+
untouched; None clears the field (e.g. end_date=None lifts a lease,
|
|
360
|
+
start_date=None lifts an embargo).
|
|
361
|
+
|
|
362
|
+
Parameters:
|
|
363
|
+
policy_id (int): The ID of the resource policy to update.
|
|
364
|
+
name (Optional[str], optional): The new name for the policy.
|
|
365
|
+
description (Optional[str], optional): The new description for the policy.
|
|
366
|
+
start_date (DateLike, optional): The new start date for the policy.
|
|
367
|
+
end_date (DateLike, optional): The new end date for the policy.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Dict[str, Any]: The updated policy data.
|
|
371
|
+
"""
|
|
372
|
+
current = self.get_policy(policy_id, embed=())
|
|
373
|
+
ops = []
|
|
374
|
+
for path, value in (("name", name), ("description", description),
|
|
375
|
+
("startDate", start_date), ("endDate", end_date)):
|
|
376
|
+
if value is _UNSET:
|
|
377
|
+
continue
|
|
378
|
+
if path in ("startDate", "endDate"):
|
|
379
|
+
value = _fmt_date(value)
|
|
380
|
+
if value is None:
|
|
381
|
+
if current.get(path) is not None: # remove on an empty field is an error
|
|
382
|
+
ops.append({"op": "remove", "path": f"/{path}"})
|
|
383
|
+
else:
|
|
384
|
+
ops.append({"op": "add", "path": f"/{path}", "value": value})
|
|
385
|
+
if not ops:
|
|
386
|
+
return current
|
|
387
|
+
logger.info("Updating policy %s: %s", policy_id, ops)
|
|
388
|
+
return self.patch_policy(policy_id, ops)
|
|
389
|
+
|
|
390
|
+
def delete_policy(self, policy_id: int, missing_ok: bool = False) -> bool:
|
|
391
|
+
"""Delete one policy. With missing_ok, a 404 (already deleted) returns False instead of raising.
|
|
392
|
+
|
|
393
|
+
Parameters:
|
|
394
|
+
policy_id (int): The ID of the resource policy to delete.
|
|
395
|
+
missing_ok (bool, optional): If True, a 404 response will return False instead of raising an error.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
bool: True if the policy was deleted, False if it was already missing and missing_ok is True.
|
|
399
|
+
"""
|
|
400
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}")
|
|
401
|
+
try:
|
|
402
|
+
self._request("DELETE", url, f"Delete resource policy {policy_id}", expected=(200, 204))
|
|
403
|
+
except NotFoundError:
|
|
404
|
+
if missing_ok:
|
|
405
|
+
logger.info("Policy %s was already gone", policy_id)
|
|
406
|
+
return False
|
|
407
|
+
raise
|
|
408
|
+
logger.debug("Deleted policy %s", policy_id)
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
def set_policy_group(self, policy_id: int, group: str) -> bool:
|
|
412
|
+
"""Re-assign a group policy to another group (name or UUID).
|
|
413
|
+
|
|
414
|
+
Parameters:
|
|
415
|
+
policy_id (int): The ID of the resource policy to update.
|
|
416
|
+
group (str): The UUID or name of the group to assign the policy to.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
bool: True if the operation was successful.
|
|
420
|
+
"""
|
|
421
|
+
group_uuid = self.resolve_group(group)["uuid"]
|
|
422
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}/group")
|
|
423
|
+
self._request("PUT", url, f"Set group of policy {policy_id}", expected=(200, 204),
|
|
424
|
+
headers=self._headers("text/uri-list"), data=self._url(GROUPS, f"/{group_uuid}"))
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
def set_policy_eperson(self, policy_id: int, eperson_uuid: str) -> bool:
|
|
428
|
+
"""Re-assign an eperson policy to another eperson by UUID.
|
|
429
|
+
|
|
430
|
+
Parameters:
|
|
431
|
+
policy_id (int): The ID of the resource policy to update.
|
|
432
|
+
eperson_uuid (str): The UUID of the eperson to assign the policy to.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
bool: True if the operation was successful.
|
|
436
|
+
"""
|
|
437
|
+
url = self._url(RESOURCE_POLICIES, f"/{policy_id}/eperson")
|
|
438
|
+
self._request("PUT", url, f"Set eperson of policy {policy_id}", expected=(200, 204),
|
|
439
|
+
headers=self._headers("text/uri-list"), data=self._url(EPERSONS, f"/{eperson_uuid}"))
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# Groups
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def get_group(self, group_uuid: str) -> Dict[str, Any]:
|
|
447
|
+
"""Retrieve a group by its UUID.
|
|
448
|
+
|
|
449
|
+
Parameters:
|
|
450
|
+
group_uuid (str): The UUID of the group to retrieve.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Dict[str, Any]: The group data.
|
|
454
|
+
"""
|
|
455
|
+
url = self._url(GROUPS, f"/{group_uuid}")
|
|
456
|
+
return self._request("GET", url, f"Get group {group_uuid}").json()
|
|
457
|
+
|
|
458
|
+
def find_group_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
|
459
|
+
"""Find a group by its exact (case-insensitive) name via byMetadata search.
|
|
460
|
+
|
|
461
|
+
Parameters:
|
|
462
|
+
name (str): The name of the group to search for.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Optional[Dict[str, Any]]: The group data if found, else None.
|
|
466
|
+
"""
|
|
467
|
+
url = self._url(GROUPS, "/search/byMetadata")
|
|
468
|
+
for group in self._iter_embedded(url, "groups", f"Search group '{name}'", [("query", name)]):
|
|
469
|
+
if (group.get("name") or "").lower() == name.lower():
|
|
470
|
+
return group
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
def resolve_group(self, group: str) -> Dict[str, Any]:
|
|
474
|
+
"""Accept a group UUID or name, return the group (cached).
|
|
475
|
+
|
|
476
|
+
Parameters:
|
|
477
|
+
group (str): The UUID or name of the group to resolve.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Dict[str, Any]: The resolved group data.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
NotFoundError: If the group cannot be found.
|
|
484
|
+
"""
|
|
485
|
+
key = group.lower()
|
|
486
|
+
if key not in self._group_cache:
|
|
487
|
+
found = self.get_group(group) if validate_uuid(group) else self.find_group_by_name(group)
|
|
488
|
+
if not found:
|
|
489
|
+
raise NotFoundError(f"Group {group!r} not found")
|
|
490
|
+
self._group_cache[key] = found
|
|
491
|
+
return self._group_cache[key]
|
|
492
|
+
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
# Filtering
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def policy_group(self, policy: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
498
|
+
"""The group of a policy: from the embed if present, else one extra request.
|
|
499
|
+
|
|
500
|
+
Parameters:
|
|
501
|
+
policy (Dict[str, Any]): The policy data.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Optional[Dict[str, Any]]: The group data if found, else None.
|
|
505
|
+
"""
|
|
506
|
+
embedded = policy.get("_embedded") or {}
|
|
507
|
+
if "group" in embedded:
|
|
508
|
+
return embedded["group"]
|
|
509
|
+
return self.get_policy_group(policy["id"])
|
|
510
|
+
|
|
511
|
+
def filter_policies(self, policies: Iterable[Dict[str, Any]], group: Optional[str] = None,
|
|
512
|
+
actions: Optional[Sequence[str]] = None,
|
|
513
|
+
policy_types: Optional[Sequence[str]] = None) -> List[Dict[str, Any]]:
|
|
514
|
+
"""
|
|
515
|
+
Keep the policies matching all given criteria.
|
|
516
|
+
|
|
517
|
+
Parameters:
|
|
518
|
+
policies (Iterable[Dict[str, Any]]): The list of policies to filter.
|
|
519
|
+
group (Optional[str], optional): The group name or UUID to filter by. Policies granted to epersons never match. Defaults to None.
|
|
520
|
+
actions (Optional[Sequence[str]], optional): The list of actions to filter by. None means any. Defaults to None.
|
|
521
|
+
policy_types (Optional[Sequence[str]], optional): The list of policy types to filter by. None means any. Defaults to None.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
List[Dict[str, Any]]: The list of policies matching all given criteria.
|
|
525
|
+
"""
|
|
526
|
+
actions = _as_list(actions)
|
|
527
|
+
policy_types = _as_list(policy_types)
|
|
528
|
+
is_uuid = bool(group) and validate_uuid(group)
|
|
529
|
+
result = []
|
|
530
|
+
for p in policies:
|
|
531
|
+
if actions and p.get("action") not in actions:
|
|
532
|
+
continue
|
|
533
|
+
if policy_types and p.get("policyType") not in policy_types:
|
|
534
|
+
continue
|
|
535
|
+
if group:
|
|
536
|
+
g = self.policy_group(p) or {}
|
|
537
|
+
if is_uuid:
|
|
538
|
+
if g.get("uuid") != group:
|
|
539
|
+
continue
|
|
540
|
+
elif (g.get("name") or "").lower() != group.lower():
|
|
541
|
+
continue
|
|
542
|
+
result.append(p)
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
def find_group_policies_on_resource(self, resource_uuid: str, group: str = ANONYMOUS,
|
|
546
|
+
actions: Optional[Sequence[str]] = (Actions.READ,)) -> List[Dict[str, Any]]:
|
|
547
|
+
"""Find policies for a group on a specific resource.
|
|
548
|
+
|
|
549
|
+
Parameters:
|
|
550
|
+
resource_uuid (str): The UUID of the resource.
|
|
551
|
+
group (str, optional): The group name or UUID. Defaults to ANONYMOUS.
|
|
552
|
+
actions (Optional[Sequence[str]], optional): The list of actions to filter by. Defaults to (Actions.READ,).
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List[Dict[str, Any]]: The list of policies matching the criteria.
|
|
556
|
+
"""
|
|
557
|
+
return self.filter_policies(self.get_resource_policies(resource_uuid), group=group, actions=actions)
|
|
558
|
+
|
|
559
|
+
def has_group_access(self, resource_uuid: str, group: str = ANONYMOUS, action: str = Actions.READ,
|
|
560
|
+
on_date: DateLike = None) -> bool:
|
|
561
|
+
"""Check if a group has access to a resource for a specific action on a given date.
|
|
562
|
+
|
|
563
|
+
Parameters:
|
|
564
|
+
resource_uuid (str): The UUID of the resource.
|
|
565
|
+
group (str, optional): The group name or UUID. Defaults to ANONYMOUS.
|
|
566
|
+
action (str, optional): The action to check. Defaults to Actions.READ.
|
|
567
|
+
on_date (DateLike, optional): The date to check access for. Defaults to today.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
bool: True if a policy for the group+action exists and is valid on on_date, False otherwise.
|
|
571
|
+
"""
|
|
572
|
+
day = _fmt_date(on_date) or date.today().isoformat()
|
|
573
|
+
for p in self.find_group_policies_on_resource(resource_uuid, group, [action]):
|
|
574
|
+
start, end = _fmt_date(p.get("startDate")), _fmt_date(p.get("endDate"))
|
|
575
|
+
if (start is None or start <= day) and (end is None or end >= day):
|
|
576
|
+
return True
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
# Targets: an item and its bundles / bitstreams, or all items in a scope
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
def iter_item_resources(self, item_uuid: str, item_name: str = "", include_bundles: bool = True,
|
|
584
|
+
include_bitstreams: bool = True) -> Iterator[Dict[str, str]]:
|
|
585
|
+
"""
|
|
586
|
+
Yield {"type", "uuid", "name"} for the item and (optionally) its bundles
|
|
587
|
+
and bitstreams. Bundles and bitstreams come from one embedded request
|
|
588
|
+
(BundleManager.get_item_with_files) instead of 1 + one per bundle.
|
|
589
|
+
|
|
590
|
+
Parameters:
|
|
591
|
+
item_uuid (str): The UUID of the item.
|
|
592
|
+
item_name (str, optional): The name of the item. Defaults to "".
|
|
593
|
+
include_bundles (bool, optional): Whether to include bundles. Defaults to True.
|
|
594
|
+
include_bitstreams (bool, optional): Whether to include bitstreams. Defaults to True.
|
|
595
|
+
|
|
596
|
+
Yields:
|
|
597
|
+
Dict[str, str]: A dictionary with keys "type", "uuid", and "name" for each resource.
|
|
598
|
+
"""
|
|
599
|
+
if not (include_bundles or include_bitstreams):
|
|
600
|
+
yield {"type": "item", "uuid": item_uuid, "name": item_name}
|
|
601
|
+
return
|
|
602
|
+
item, bundles = self.bundles.get_item_with_files(item_uuid)
|
|
603
|
+
yield {"type": "item", "uuid": item_uuid, "name": item_name or item.get("name") or ""}
|
|
604
|
+
for bundle in bundles:
|
|
605
|
+
if include_bundles:
|
|
606
|
+
yield {"type": "bundle", "uuid": bundle["uuid"], "name": bundle.get("name") or ""}
|
|
607
|
+
if include_bitstreams:
|
|
608
|
+
for bs in bundle["bitstreams"]:
|
|
609
|
+
yield {"type": "bitstream", "uuid": bs["uuid"], "name": bs.get("name") or ""}
|
|
610
|
+
|
|
611
|
+
def iter_items_in_scope(self, scope_uuid: str, page_size: int = 100) -> Iterator[Dict[str, Any]]:
|
|
612
|
+
"""
|
|
613
|
+
Yield every item in a collection/community via discovery.
|
|
614
|
+
|
|
615
|
+
Discovery reflects the search index: run it as an administrator so
|
|
616
|
+
private/withdrawn items are included, and collect the list before
|
|
617
|
+
changing policies (changes are re-indexed and may shift pages).
|
|
618
|
+
|
|
619
|
+
Parameters:
|
|
620
|
+
scope_uuid (str): The UUID of the collection or community scope.
|
|
621
|
+
page_size (int, optional): The number of items to fetch per page. Defaults to 100.
|
|
622
|
+
|
|
623
|
+
Yields:
|
|
624
|
+
Dict[str, Any]: The item data for each item in the scope.
|
|
625
|
+
"""
|
|
626
|
+
url = self._url(DISCOVER_SEARCH)
|
|
627
|
+
page = 0
|
|
628
|
+
while True:
|
|
629
|
+
params = [("scope", scope_uuid), ("dsoType", "ITEM"), ("page", page), ("size", page_size)]
|
|
630
|
+
data = self._request("GET", url, f"List items in scope {scope_uuid}", params=params).json()
|
|
631
|
+
result = data.get("_embedded", {}).get("searchResult", {})
|
|
632
|
+
objects = result.get("_embedded", {}).get("objects", [])
|
|
633
|
+
for obj in objects:
|
|
634
|
+
item = obj.get("_embedded", {}).get("indexableObject")
|
|
635
|
+
if item:
|
|
636
|
+
yield item
|
|
637
|
+
total_pages = (result.get("page") or {}).get("totalPages", 0)
|
|
638
|
+
page += 1
|
|
639
|
+
if page >= total_pages or not objects:
|
|
640
|
+
break
|
|
641
|
+
|
|
642
|
+
# ------------------------------------------------------------------
|
|
643
|
+
# High level: remove / grant / restore group access
|
|
644
|
+
# ------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
def _policy_row(self, resource: Dict[str, str], policy: Dict[str, Any], item_uuid: str = "") -> Dict[str, Any]:
|
|
647
|
+
"""
|
|
648
|
+
Construct a dictionary representing a policy row.
|
|
649
|
+
|
|
650
|
+
Parameters:
|
|
651
|
+
resource (Dict[str, str]): The resource dictionary with keys "type", "uuid", and "name".
|
|
652
|
+
policy (Dict[str, Any]): The policy dictionary.
|
|
653
|
+
item_uuid (str, optional): The UUID of the item. Defaults to "".
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Dict[str, Any]: A dictionary representing the policy row.
|
|
657
|
+
"""
|
|
658
|
+
g = self.policy_group(policy) or {}
|
|
659
|
+
return {
|
|
660
|
+
"item_uuid": item_uuid,
|
|
661
|
+
"resource_type": resource.get("type", ""),
|
|
662
|
+
"resource_uuid": resource["uuid"],
|
|
663
|
+
"resource_name": resource.get("name", ""),
|
|
664
|
+
"policy_id": policy.get("id"),
|
|
665
|
+
"action": policy.get("action"),
|
|
666
|
+
"policy_type": policy.get("policyType"),
|
|
667
|
+
"group_name": g.get("name"),
|
|
668
|
+
"group_uuid": g.get("uuid"),
|
|
669
|
+
"name": policy.get("name"),
|
|
670
|
+
"description": policy.get("description"),
|
|
671
|
+
"start_date": policy.get("startDate"),
|
|
672
|
+
"end_date": policy.get("endDate"),
|
|
673
|
+
"status": "",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
def remove_group_policies(self, resources: Iterable[Union[str, Dict[str, str]]],
|
|
677
|
+
group: str = ANONYMOUS,
|
|
678
|
+
actions: Optional[Sequence[str]] = (Actions.READ,),
|
|
679
|
+
policy_types: Optional[Sequence[str]] = None,
|
|
680
|
+
dry_run: bool = True, item_uuid: str = "") -> List[Dict[str, Any]]:
|
|
681
|
+
"""
|
|
682
|
+
Remove the group's policies from the given resources.
|
|
683
|
+
|
|
684
|
+
resources: UUIDs or dicts {"type", "uuid", "name"} (see iter_item_resources)
|
|
685
|
+
actions: None = every action of the group
|
|
686
|
+
Returns one row per matching policy (fields: POLICY_ROW_FIELDS) with
|
|
687
|
+
status would-delete | deleted | already-gone | failed:<reason>.
|
|
688
|
+
Each row holds everything restore_policies() needs to undo it.
|
|
689
|
+
|
|
690
|
+
Parameters:
|
|
691
|
+
resources (Iterable[Union[str, Dict[str, str]]]): The list of resource UUIDs or dictionaries with keys "type", "uuid", and "name".
|
|
692
|
+
group (str, optional): The group name or UUID. Defaults to ANONYMOUS.
|
|
693
|
+
actions (Optional[Sequence[str]], optional): The list of actions to filter by. Defaults to (Actions.READ,).
|
|
694
|
+
policy_types (Optional[Sequence[str]], optional): The list of policy types to filter by. Defaults to None.
|
|
695
|
+
dry_run (bool, optional): If True, only simulate the removal. Defaults to True.
|
|
696
|
+
item_uuid (str, optional): The UUID of the item. Defaults to "".
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
List[Dict[str, Any]]: A list of dictionaries representing the policy rows with their status.
|
|
700
|
+
"""
|
|
701
|
+
rows = []
|
|
702
|
+
for res in resources:
|
|
703
|
+
resource = res if isinstance(res, dict) else {"type": "", "uuid": res, "name": ""}
|
|
704
|
+
matches = self.filter_policies(self.get_resource_policies(resource["uuid"]),
|
|
705
|
+
group=group, actions=actions, policy_types=policy_types)
|
|
706
|
+
for policy in matches:
|
|
707
|
+
row = self._policy_row(resource, policy, item_uuid)
|
|
708
|
+
label = f"{row['resource_type'] or 'resource'} {resource['uuid']} '{row['resource_name']}'"
|
|
709
|
+
if dry_run:
|
|
710
|
+
row["status"] = "would-delete"
|
|
711
|
+
logger.info("[dry-run] Would delete policy %s (%s, %s, group %s) on %s",
|
|
712
|
+
row["policy_id"], row["action"], row["policy_type"], row["group_name"], label)
|
|
713
|
+
else:
|
|
714
|
+
try:
|
|
715
|
+
deleted = self.delete_policy(row["policy_id"], missing_ok=True)
|
|
716
|
+
row["status"] = "deleted" if deleted else "already-gone"
|
|
717
|
+
logger.info("Deleted policy %s (%s, group %s) on %s",
|
|
718
|
+
row["policy_id"], row["action"], row["group_name"], label)
|
|
719
|
+
except DSpaceError as exc:
|
|
720
|
+
row["status"] = f"failed:{getattr(exc, 'status_code', '') or exc}"
|
|
721
|
+
logger.error("Could not delete policy %s on %s: %s", row["policy_id"], label, exc)
|
|
722
|
+
rows.append(row)
|
|
723
|
+
return rows
|
|
724
|
+
|
|
725
|
+
def remove_group_access_from_item(self, item_uuid: str, group: str = ANONYMOUS,
|
|
726
|
+
actions: Optional[Sequence[str]] = (Actions.READ,),
|
|
727
|
+
include_bundles: bool = False, include_bitstreams: bool = False,
|
|
728
|
+
item_name: str = "", dry_run: bool = True) -> List[Dict[str, Any]]:
|
|
729
|
+
"""
|
|
730
|
+
Remove the group's policies from an item and, optionally, its bundles
|
|
731
|
+
and bitstreams. Default: Anonymous READ on the item only, dry run.
|
|
732
|
+
include_bitstreams=True is usually what you want to also block downloads.
|
|
733
|
+
|
|
734
|
+
Parameters:
|
|
735
|
+
item_uuid (str): The UUID of the item.
|
|
736
|
+
group (str, optional): The group name or UUID. Defaults to ANONYMOUS.
|
|
737
|
+
actions (Optional[Sequence[str]], optional): The list of actions to remove. Defaults to (Actions.READ,).
|
|
738
|
+
include_bundles (bool, optional): If True, include the item's bundles. Defaults to False.
|
|
739
|
+
include_bitstreams (bool, optional): If True, include the item's bitstreams. Defaults to False.
|
|
740
|
+
item_name (str, optional): The name of the item. Defaults to "".
|
|
741
|
+
dry_run (bool, optional): If True, only simulate the removal. Defaults to True.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
List[Dict[str, Any]]: A list of dictionaries representing the policy rows with their status.
|
|
745
|
+
"""
|
|
746
|
+
resources = list(self.iter_item_resources(item_uuid, item_name, include_bundles, include_bitstreams))
|
|
747
|
+
return self.remove_group_policies(resources, group=group, actions=actions,
|
|
748
|
+
dry_run=dry_run, item_uuid=item_uuid)
|
|
749
|
+
|
|
750
|
+
def grant_group_access(self, resource_uuid: str, group: str = ANONYMOUS, action: str = Actions.READ,
|
|
751
|
+
start_date: DateLike = None, end_date: DateLike = None,
|
|
752
|
+
policy_type: Optional[str] = PolicyTypes.CUSTOM,
|
|
753
|
+
name: Optional[str] = None, description: Optional[str] = None,
|
|
754
|
+
skip_if_exists: bool = True) -> Dict[str, Any]:
|
|
755
|
+
"""
|
|
756
|
+
Give a group an action on a resource (e.g. make an item public again,
|
|
757
|
+
or set an embargo via start_date). With skip_if_exists, an existing
|
|
758
|
+
policy with the same group/action/dates is returned instead of adding a duplicate.
|
|
759
|
+
|
|
760
|
+
Parameters:
|
|
761
|
+
resource_uuid (str): The UUID of the resource.
|
|
762
|
+
group (str, optional): The group name or UUID. Defaults to ANONYMOUS.
|
|
763
|
+
action (str, optional): The action to grant. Defaults to Actions.READ.
|
|
764
|
+
start_date (DateLike, optional): The start date of the policy. Defaults to None.
|
|
765
|
+
end_date (DateLike, optional): The end date of the policy. Defaults to None.
|
|
766
|
+
policy_type (Optional[str], optional): The type of the policy. Defaults to PolicyTypes.CUSTOM.
|
|
767
|
+
name (Optional[str], optional): The name of the policy. Defaults to None.
|
|
768
|
+
description (Optional[str], optional): The description of the policy. Defaults to None.
|
|
769
|
+
skip_if_exists (bool, optional): If True, skip creating the policy if it already exists. Defaults to True.
|
|
770
|
+
|
|
771
|
+
Returns:
|
|
772
|
+
Dict[str, Any]: The created or existing policy.
|
|
773
|
+
"""
|
|
774
|
+
if skip_if_exists:
|
|
775
|
+
want = (_fmt_date(start_date), _fmt_date(end_date))
|
|
776
|
+
for p in self.find_group_policies_on_resource(resource_uuid, group, [action]):
|
|
777
|
+
if (_fmt_date(p.get("startDate")), _fmt_date(p.get("endDate"))) == want:
|
|
778
|
+
logger.info("Policy %s already grants %s to %s on %s", p.get("id"), action, group, resource_uuid)
|
|
779
|
+
return p
|
|
780
|
+
return self.create_policy(resource_uuid, action=action, group=group, policy_type=policy_type,
|
|
781
|
+
name=name, description=description, start_date=start_date, end_date=end_date)
|
|
782
|
+
|
|
783
|
+
def restore_policies(self, rows: Iterable[Dict[str, Any]], dry_run: bool = True,
|
|
784
|
+
statuses: Sequence[str] = ("deleted",)) -> List[Dict[str, Any]]:
|
|
785
|
+
"""
|
|
786
|
+
Re-create policies from rows produced by remove_group_policies()
|
|
787
|
+
(or read back from its CSV report). Only rows whose status is in
|
|
788
|
+
`statuses` are restored; the new policy gets a new id.
|
|
789
|
+
|
|
790
|
+
Parameters:
|
|
791
|
+
rows (Iterable[Dict[str, Any]]): The list of policy rows to restore.
|
|
792
|
+
dry_run (bool, optional): If True, only simulate the restoration. Defaults to True.
|
|
793
|
+
statuses (Sequence[str], optional): The list of statuses to restore. Defaults to ("deleted",).
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
List[Dict[str, Any]]: A list of dictionaries representing the restored policy rows with their status.
|
|
797
|
+
"""
|
|
798
|
+
results = []
|
|
799
|
+
for row in rows:
|
|
800
|
+
if row.get("status") not in statuses:
|
|
801
|
+
continue
|
|
802
|
+
out = dict(row)
|
|
803
|
+
group = row.get("group_uuid") or row.get("group_name")
|
|
804
|
+
if not group:
|
|
805
|
+
out["status"] = "restore-skipped:no-group"
|
|
806
|
+
results.append(out)
|
|
807
|
+
continue
|
|
808
|
+
if dry_run:
|
|
809
|
+
out["status"] = "would-restore"
|
|
810
|
+
logger.info("[dry-run] Would restore %s for %s on %s %s",
|
|
811
|
+
row.get("action"), row.get("group_name") or group,
|
|
812
|
+
row.get("resource_type"), row.get("resource_uuid"))
|
|
813
|
+
else:
|
|
814
|
+
try:
|
|
815
|
+
policy = self.grant_group_access(
|
|
816
|
+
row["resource_uuid"], group=group, action=row.get("action") or Actions.READ,
|
|
817
|
+
start_date=row.get("start_date") or None, end_date=row.get("end_date") or None,
|
|
818
|
+
policy_type=row.get("policy_type") or None,
|
|
819
|
+
name=row.get("name") or None, description=row.get("description") or None,
|
|
820
|
+
)
|
|
821
|
+
out["status"] = "restored"
|
|
822
|
+
out["policy_id"] = policy.get("id")
|
|
823
|
+
except DSpaceError as exc:
|
|
824
|
+
out["status"] = f"restore-failed:{getattr(exc, 'status_code', '') or exc}"
|
|
825
|
+
logger.error("Could not restore policy on %s: %s", row.get("resource_uuid"), exc)
|
|
826
|
+
results.append(out)
|
|
827
|
+
return results
|
|
828
|
+
|
|
829
|
+
# ------------------------------------------------------------------
|
|
830
|
+
# CSV reports
|
|
831
|
+
# ------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
@staticmethod
|
|
834
|
+
def write_report(rows: Iterable[Dict[str, Any]], path) -> None:
|
|
835
|
+
"""
|
|
836
|
+
Write a CSV report of policy rows.
|
|
837
|
+
|
|
838
|
+
Parameters:
|
|
839
|
+
rows (Iterable[Dict[str, Any]]): The list of policy rows to write to the report.
|
|
840
|
+
path (str): The file path to write the CSV report to.
|
|
841
|
+
"""
|
|
842
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
843
|
+
writer = csv.DictWriter(fh, fieldnames=POLICY_ROW_FIELDS, extrasaction="ignore")
|
|
844
|
+
writer.writeheader()
|
|
845
|
+
writer.writerows(rows)
|
|
846
|
+
|
|
847
|
+
@staticmethod
|
|
848
|
+
def read_report(path) -> List[Dict[str, Any]]:
|
|
849
|
+
"""
|
|
850
|
+
Read a CSV report of policy rows.
|
|
851
|
+
|
|
852
|
+
Parameters:
|
|
853
|
+
path (str): The file path to read the CSV report from.
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
List[Dict[str, Any]]: The list of policy rows read from the report.
|
|
857
|
+
"""
|
|
858
|
+
with open(path, newline="", encoding="utf-8") as fh:
|
|
859
|
+
return list(csv.DictReader(fh))
|
|
860
|
+
|
|
861
|
+
# ------------------------------------------------------------------
|
|
862
|
+
# Bulk access control (DSpace 7.6+)
|
|
863
|
+
# ------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
def list_bulk_access_condition_options(self) -> List[Dict[str, Any]]:
|
|
866
|
+
"""
|
|
867
|
+
List the available bulk access condition options.
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
List[Dict[str, Any]]: The list of bulk access condition options.
|
|
871
|
+
"""
|
|
872
|
+
url = self._url(BULK_ACCESS_CONDITION_OPTIONS)
|
|
873
|
+
return list(self._iter_embedded(url, "bulkaccessconditionoptions", "List bulk access condition options"))
|
|
874
|
+
|
|
875
|
+
def get_bulk_access_condition_options(self, config_name: str = "default") -> Dict[str, Any]:
|
|
876
|
+
"""
|
|
877
|
+
The access conditions an admin may apply, e.g.
|
|
878
|
+
{"itemAccessConditionOptions": [{"name": "openaccess"}, {"name": "embargo", "hasStartDate": true, ...}],
|
|
879
|
+
"bitstreamAccessConditionOptions": [...]}
|
|
880
|
+
"""
|
|
881
|
+
url = self._url(BULK_ACCESS_CONDITION_OPTIONS, f"/{config_name}")
|
|
882
|
+
return self._request("GET", url, f"Get bulk access condition options '{config_name}'").json()
|
|
883
|
+
|
|
884
|
+
@staticmethod
|
|
885
|
+
def _validate_conditions(conditions: List[Dict[str, Any]], options: List[Dict[str, Any]], level: str):
|
|
886
|
+
"""
|
|
887
|
+
Validate the given access conditions against the available options.
|
|
888
|
+
|
|
889
|
+
Parameters:
|
|
890
|
+
conditions (List[Dict[str, Any]]): The list of access conditions to validate.
|
|
891
|
+
options (List[Dict[str, Any]]): The list of available access condition options.
|
|
892
|
+
level (str): The level of access being validated (e.g., "item" or "bitstream").
|
|
893
|
+
|
|
894
|
+
Raises:
|
|
895
|
+
ValueError: If any of the conditions are invalid or missing required fields.
|
|
896
|
+
"""
|
|
897
|
+
by_name = {o["name"]: o for o in options}
|
|
898
|
+
for cond in conditions:
|
|
899
|
+
name = cond.get("name")
|
|
900
|
+
opt = by_name.get(name)
|
|
901
|
+
if not opt:
|
|
902
|
+
raise ValueError(f"{level} access condition {name!r} not allowed; options: {sorted(by_name)}")
|
|
903
|
+
start, end = _fmt_date(cond.get("startDate")), _fmt_date(cond.get("endDate"))
|
|
904
|
+
if opt.get("hasStartDate") and not start:
|
|
905
|
+
raise ValueError(f"{level} access condition {name!r} requires startDate")
|
|
906
|
+
if opt.get("hasEndDate") and not end:
|
|
907
|
+
raise ValueError(f"{level} access condition {name!r} requires endDate")
|
|
908
|
+
if start and opt.get("maxStartDate") and start > _fmt_date(opt["maxStartDate"]):
|
|
909
|
+
raise ValueError(f"{level} {name!r}: startDate {start} is after maxStartDate {opt['maxStartDate']}")
|
|
910
|
+
if end and opt.get("maxEndDate") and end > _fmt_date(opt["maxEndDate"]):
|
|
911
|
+
raise ValueError(f"{level} {name!r}: endDate {end} is after maxEndDate {opt['maxEndDate']}")
|
|
912
|
+
if start and end and end <= start:
|
|
913
|
+
raise ValueError(f"{level} {name!r}: endDate must be after startDate")
|
|
914
|
+
|
|
915
|
+
def build_bulk_access_payload(self, item_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
916
|
+
item_mode: str = "replace",
|
|
917
|
+
bitstream_conditions: Optional[List[Dict[str, Any]]] = None,
|
|
918
|
+
bitstream_mode: str = "replace",
|
|
919
|
+
bitstream_uuids: Optional[List[str]] = None,
|
|
920
|
+
validate: bool = True, config_name: str = "default") -> Dict[str, Any]:
|
|
921
|
+
"""
|
|
922
|
+
Build the JSON file for the bulk-access-control script.
|
|
923
|
+
|
|
924
|
+
conditions: e.g. [{"name": "administrator"}] or
|
|
925
|
+
[{"name": "embargo", "startDate": "2027-01-01"}]
|
|
926
|
+
mode: "replace" drops existing custom policies first, "add" keeps them.
|
|
927
|
+
bitstream_uuids: restrict the bitstream part to these bitstreams (default: all).
|
|
928
|
+
|
|
929
|
+
Parameters:
|
|
930
|
+
item_conditions (Optional[List[Dict[str, Any]]]): The list of access conditions for items.
|
|
931
|
+
item_mode (str): The mode for item access conditions ("replace" or "add").
|
|
932
|
+
bitstream_conditions (Optional[List[Dict[str, Any]]]): The list of access conditions for bitstreams.
|
|
933
|
+
bitstream_mode (str): The mode for bitstream access conditions ("replace" or "add").
|
|
934
|
+
bitstream_uuids (Optional[List[str]]): The list of bitstream UUIDs to restrict the bitstream part to.
|
|
935
|
+
validate (bool): Whether to validate the access conditions against the available options.
|
|
936
|
+
config_name (str): The name of the configuration to use for validation.
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
Dict[str, Any]: The payload for the bulk-access-control script.
|
|
940
|
+
"""
|
|
941
|
+
if item_conditions is None and bitstream_conditions is None:
|
|
942
|
+
raise ValueError("Give item_conditions and/or bitstream_conditions")
|
|
943
|
+
for mode in (item_mode, bitstream_mode):
|
|
944
|
+
if mode not in ("replace", "add"):
|
|
945
|
+
raise ValueError("mode must be 'replace' or 'add'")
|
|
946
|
+
|
|
947
|
+
if validate:
|
|
948
|
+
opts = self.get_bulk_access_condition_options(config_name)
|
|
949
|
+
if item_conditions is not None:
|
|
950
|
+
self._validate_conditions(item_conditions, opts.get("itemAccessConditionOptions", []), "item")
|
|
951
|
+
if bitstream_conditions is not None:
|
|
952
|
+
self._validate_conditions(bitstream_conditions,
|
|
953
|
+
opts.get("bitstreamAccessConditionOptions", []), "bitstream")
|
|
954
|
+
|
|
955
|
+
def norm(conds):
|
|
956
|
+
"""
|
|
957
|
+
Normalize the access conditions by formatting the dates.
|
|
958
|
+
|
|
959
|
+
Parameters:
|
|
960
|
+
conds (List[Dict[str, Any]]): The list of access conditions to normalize.
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
List[Dict[str, Any]]: The normalized list of access conditions.
|
|
964
|
+
"""
|
|
965
|
+
out = []
|
|
966
|
+
for c in conds:
|
|
967
|
+
d = {"name": c["name"]}
|
|
968
|
+
if c.get("startDate"):
|
|
969
|
+
d["startDate"] = _fmt_date(c["startDate"])
|
|
970
|
+
if c.get("endDate"):
|
|
971
|
+
d["endDate"] = _fmt_date(c["endDate"])
|
|
972
|
+
out.append(d)
|
|
973
|
+
return out
|
|
974
|
+
|
|
975
|
+
payload: Dict[str, Any] = {}
|
|
976
|
+
if item_conditions is not None:
|
|
977
|
+
payload["item"] = {"mode": item_mode, "accessConditions": norm(item_conditions)}
|
|
978
|
+
if bitstream_conditions is not None:
|
|
979
|
+
bs: Dict[str, Any] = {"mode": bitstream_mode, "accessConditions": norm(bitstream_conditions)}
|
|
980
|
+
if bitstream_uuids:
|
|
981
|
+
bs["constraints"] = {"uuid": list(bitstream_uuids)}
|
|
982
|
+
payload["bitstream"] = bs
|
|
983
|
+
return payload
|
|
984
|
+
|
|
985
|
+
def run_bulk_access_control(self, target_uuids: Sequence[str], payload: Dict[str, Any],
|
|
986
|
+
filename: str = "bulk-access.json") -> Dict[str, Any]:
|
|
987
|
+
"""
|
|
988
|
+
Start the bulk-access-control script for items / collections /
|
|
989
|
+
communities (a collection or community applies to all items inside).
|
|
990
|
+
Returns the created process; use wait_for_process() to follow it.
|
|
991
|
+
|
|
992
|
+
Parameters:
|
|
993
|
+
target_uuids (Sequence[str]): The list of target UUIDs for the bulk-access-control script.
|
|
994
|
+
payload (Dict[str, Any]): The payload containing access conditions and modes.
|
|
995
|
+
filename (str): The name of the JSON file to use for the bulk-access-control script.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
Dict[str, Any]: The created process for the bulk-access-control script.
|
|
999
|
+
"""
|
|
1000
|
+
if not target_uuids:
|
|
1001
|
+
raise ValueError("target_uuids must not be empty")
|
|
1002
|
+
parameters = [{"name": "-u", "value": u} for u in target_uuids]
|
|
1003
|
+
parameters.append({"name": "-f", "value": filename})
|
|
1004
|
+
files = {
|
|
1005
|
+
"properties": (None, json.dumps(parameters), "application/json"),
|
|
1006
|
+
"file": (filename, json.dumps(payload).encode("utf-8"), "application/json"),
|
|
1007
|
+
}
|
|
1008
|
+
url = self._url(SCRIPTS, "/bulk-access-control/processes")
|
|
1009
|
+
logger.info("Starting bulk-access-control on %d target(s): %s", len(target_uuids), payload)
|
|
1010
|
+
process = self._request("POST", url, "Start bulk-access-control", expected=(200, 201, 202),
|
|
1011
|
+
headers=self._headers(content_type=None), files=files).json()
|
|
1012
|
+
logger.info("bulk-access-control process %s: %s", process.get("processId"), process.get("processStatus"))
|
|
1013
|
+
return process
|
|
1014
|
+
|
|
1015
|
+
def get_process(self, process_id: int) -> Dict[str, Any]:
|
|
1016
|
+
"""
|
|
1017
|
+
Get the details of a specific process by its ID.
|
|
1018
|
+
|
|
1019
|
+
Parameters:
|
|
1020
|
+
process_id (int): The ID of the process to retrieve.
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
Dict[str, Any]: The details of the requested process.
|
|
1024
|
+
"""
|
|
1025
|
+
url = self._url(PROCESSES, f"/{process_id}")
|
|
1026
|
+
return self._request("GET", url, f"Get process {process_id}").json()
|
|
1027
|
+
|
|
1028
|
+
def wait_for_process(self, process_id: int, timeout: float = 600, poll_interval: float = 3) -> Dict[str, Any]:
|
|
1029
|
+
"""Poll until the process is COMPLETED or FAILED (raises DSpaceError on FAILED/timeout).
|
|
1030
|
+
|
|
1031
|
+
Parameters:
|
|
1032
|
+
process_id (int): The ID of the process to wait for.
|
|
1033
|
+
timeout (float): The maximum time to wait for the process to complete, in seconds.
|
|
1034
|
+
poll_interval (float): The interval between polling attempts, in seconds.
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Dict[str, Any]: The details of the completed process.
|
|
1038
|
+
|
|
1039
|
+
Raises:
|
|
1040
|
+
DSpaceError: If the process fails or the timeout is reached.
|
|
1041
|
+
"""
|
|
1042
|
+
deadline = time.monotonic() + timeout
|
|
1043
|
+
while True:
|
|
1044
|
+
proc = self.get_process(process_id)
|
|
1045
|
+
status = proc.get("processStatus")
|
|
1046
|
+
if status == "COMPLETED":
|
|
1047
|
+
return proc
|
|
1048
|
+
if status == "FAILED":
|
|
1049
|
+
raise DSpaceError(f"Process {process_id} failed; see /system/processes/{process_id}/output")
|
|
1050
|
+
if time.monotonic() > deadline:
|
|
1051
|
+
raise DSpaceError(f"Process {process_id} still {status} after {timeout}s")
|
|
1052
|
+
time.sleep(poll_interval)
|