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,363 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Community management for DSpace API (DSpace 7+ / DSpace-CRIS 7+)
|
|
4
|
+
|
|
5
|
+
Covered REST contract
|
|
6
|
+
GET /core/communities/{uuid}
|
|
7
|
+
GET /core/communities/search/top
|
|
8
|
+
GET /core/communities/{uuid}/subcommunities
|
|
9
|
+
GET /core/communities/{uuid}/collections
|
|
10
|
+
POST /core/communities top-level community
|
|
11
|
+
POST /core/communities?parent={uuid} subcommunity
|
|
12
|
+
PATCH /core/communities/{uuid} metadata
|
|
13
|
+
DELETE /core/communities/{uuid} (deletes everything below it!)
|
|
14
|
+
GET|POST|DELETE /core/communities/{uuid}/adminGroup
|
|
15
|
+
GET /discover/search/objects?configuration=editCommunity&dsoType=COMMUNITY
|
|
16
|
+
GET /core/communities/search/findAdminAuthorized|findEditAuthorized|findAddAuthorized
|
|
17
|
+
GET|POST /core/communities/{uuid}/logo, DELETE via the logo bitstream
|
|
18
|
+
GET /core/communities/{uuid}/parentCommunity
|
|
19
|
+
|
|
20
|
+
Shared with collections (see _containers.py): get/set/delete_logo,
|
|
21
|
+
get_parent_community, get_ancestors, patch_metadata, find_admin_authorized,
|
|
22
|
+
find_edit_authorized. iter_tree() walks a whole community hierarchy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
|
27
|
+
|
|
28
|
+
from ._base import BaseManager
|
|
29
|
+
from ._containers import ContainerMixin
|
|
30
|
+
from .projection import EmbedArg, ProjectionArg
|
|
31
|
+
from .config import Endpoints
|
|
32
|
+
|
|
33
|
+
# Logger for the module
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# API endpoint for communities
|
|
37
|
+
COMMUNITIES = getattr(Endpoints, "COMMUNITIES", "/core/communities")
|
|
38
|
+
|
|
39
|
+
# Type alias for a reference to a DSpace object (UUID or full object)
|
|
40
|
+
Ref = Union[str, Dict[str, Any]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_dso_metadata(title: str, description: Optional[str] = None, abstract: Optional[str] = None,
|
|
44
|
+
rights: Optional[str] = None, table_of_contents: Optional[str] = None,
|
|
45
|
+
language: Optional[str] = None, **extra: Any) -> Dict[str, List[Dict[str, Any]]]:
|
|
46
|
+
"""
|
|
47
|
+
Metadata for a community/collection form. Empty or whitespace-only
|
|
48
|
+
values are left out. Fields: dc.title, dc.description (HTML allowed),
|
|
49
|
+
dc.description.abstract, dc.rights (HTML allowed),
|
|
50
|
+
dc.description.tableofcontents; extra={"dc.rights.license": "..."}.
|
|
51
|
+
Booleans become "true"/"false".
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
title (str): Title of the community/collection
|
|
55
|
+
description (Optional[str]): Description (HTML allowed)
|
|
56
|
+
abstract (Optional[str]): Abstract
|
|
57
|
+
rights (Optional[str]): Rights (HTML allowed)
|
|
58
|
+
table_of_contents (Optional[str]): Table of contents
|
|
59
|
+
language (Optional[str]): Language code for the metadata values
|
|
60
|
+
**extra: Additional metadata fields
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dict[str, List[Dict[str, Any]]]: Metadata dictionary suitable for DSpace API
|
|
64
|
+
"""
|
|
65
|
+
if not (title or "").strip():
|
|
66
|
+
raise ValueError("title is required")
|
|
67
|
+
fields = {
|
|
68
|
+
"dc.title": title,
|
|
69
|
+
"dc.description": description,
|
|
70
|
+
"dc.description.abstract": abstract,
|
|
71
|
+
"dc.rights": rights,
|
|
72
|
+
"dc.description.tableofcontents": table_of_contents,
|
|
73
|
+
**extra,
|
|
74
|
+
}
|
|
75
|
+
md = {}
|
|
76
|
+
for field, value in fields.items():
|
|
77
|
+
if value is None:
|
|
78
|
+
continue
|
|
79
|
+
value = ("true" if value else "false") if isinstance(value, bool) else str(value).strip()
|
|
80
|
+
if value:
|
|
81
|
+
md[field] = [{"value": value, "language": language}]
|
|
82
|
+
return md
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CommunityManager(ContainerMixin, BaseManager):
|
|
86
|
+
"""Manages communities, their administrator group, logo and hierarchy"""
|
|
87
|
+
|
|
88
|
+
# Container configuration for communities
|
|
89
|
+
_CONTAINER_PATH = COMMUNITIES
|
|
90
|
+
_CONTAINER_KEY = "communities"
|
|
91
|
+
|
|
92
|
+
def __init__(self, auth):
|
|
93
|
+
"""Initialize the CommunityManager with authentication.
|
|
94
|
+
|
|
95
|
+
Parameters:
|
|
96
|
+
auth: Authentication object for API access
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
super().__init__(auth)
|
|
100
|
+
self._group_manager = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def groups(self):
|
|
104
|
+
"""Access the GroupManager for community groups.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
GroupManager: Manager for community groups
|
|
108
|
+
"""
|
|
109
|
+
if self._group_manager is None:
|
|
110
|
+
from .groups import GroupManager
|
|
111
|
+
self._group_manager = GroupManager(self.auth)
|
|
112
|
+
return self._group_manager
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Read
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def get_community(self, community_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
|
|
119
|
+
"""Get a community by UUID.
|
|
120
|
+
|
|
121
|
+
Parameters:
|
|
122
|
+
community_uuid (str): UUID of the community
|
|
123
|
+
all_languages (Optional[bool]): Whether to include all languages
|
|
124
|
+
projection (ProjectionArg): Projection for the response
|
|
125
|
+
embed (EmbedArg): Embedded resources to include
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dict[str, Any]: Community details
|
|
129
|
+
"""
|
|
130
|
+
return self._get_json(self._url(COMMUNITIES, f"/{community_uuid}"), f"Get community {community_uuid}",
|
|
131
|
+
all_languages=all_languages, projection=projection, embed=embed)
|
|
132
|
+
|
|
133
|
+
def get_top_communities(self, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
134
|
+
"""Get the top-level communities.
|
|
135
|
+
|
|
136
|
+
Parameters:
|
|
137
|
+
all_languages (Optional[bool]): Whether to include all languages
|
|
138
|
+
projection (ProjectionArg): Projection for the response
|
|
139
|
+
embed (EmbedArg): Embedded resources to include
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List[Dict[str, Any]]: List of top-level communities
|
|
143
|
+
"""
|
|
144
|
+
return list(self._iter_embedded(self._url(COMMUNITIES, "/search/top"), "communities", "Get top communities",
|
|
145
|
+
all_languages=all_languages, projection=projection, embed=embed))
|
|
146
|
+
|
|
147
|
+
def get_subcommunities(self, community_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
148
|
+
"""Get subcommunities of a community.
|
|
149
|
+
|
|
150
|
+
Parameters:
|
|
151
|
+
community_uuid (str): UUID of the community
|
|
152
|
+
all_languages (Optional[bool]): Whether to include all languages
|
|
153
|
+
projection (ProjectionArg): Projection for the response
|
|
154
|
+
embed (EmbedArg): Embedded resources to include
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List[Dict[str, Any]]: List of subcommunities
|
|
158
|
+
"""
|
|
159
|
+
url = self._url(COMMUNITIES, f"/{community_uuid}/subcommunities")
|
|
160
|
+
return list(self._iter_embedded(url, ("subcommunities", "communities"),
|
|
161
|
+
f"Get subcommunities of {community_uuid}", all_languages=all_languages, projection=projection, embed=embed))
|
|
162
|
+
|
|
163
|
+
def get_community_collections(self, community_uuid: str, all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
164
|
+
"""Get collections of a community.
|
|
165
|
+
|
|
166
|
+
Parameters:
|
|
167
|
+
community_uuid (str): UUID of the community
|
|
168
|
+
all_languages (Optional[bool]): Whether to include all languages
|
|
169
|
+
projection (ProjectionArg): Projection for the response
|
|
170
|
+
embed (EmbedArg): Embedded resources to include
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List[Dict[str, Any]]: List of collections
|
|
174
|
+
"""
|
|
175
|
+
url = self._url(COMMUNITIES, f"/{community_uuid}/collections")
|
|
176
|
+
return list(self._iter_embedded(url, ("collections", "mappedCollections"),
|
|
177
|
+
f"Get collections of {community_uuid}", all_languages=all_languages, projection=projection, embed=embed))
|
|
178
|
+
|
|
179
|
+
def search_editable_communities(self, query: str = "", configuration: str = "editCommunity",
|
|
180
|
+
max_results: Optional[int] = 20,
|
|
181
|
+
all_languages: Optional[bool] = None, projection: ProjectionArg = None, embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
182
|
+
"""Communities (top-level or sub) the current user can edit, e.g. to pick a parent.
|
|
183
|
+
|
|
184
|
+
Parameters:
|
|
185
|
+
query (str): Search query
|
|
186
|
+
configuration (str): Search configuration
|
|
187
|
+
max_results (Optional[int]): Maximum number of results
|
|
188
|
+
all_languages (Optional[bool]): Whether to include all languages
|
|
189
|
+
projection (ProjectionArg): Projection for the response
|
|
190
|
+
embed (EmbedArg): Embedded resources to include
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List[Dict[str, Any]]: List of editable communities
|
|
194
|
+
"""
|
|
195
|
+
return self._discover(query, dso_type="COMMUNITY", configuration=configuration, sort="dc.title,ASC",
|
|
196
|
+
max_results=max_results, all_languages=all_languages, projection=projection, embed=embed)
|
|
197
|
+
|
|
198
|
+
def find_add_authorized(self, query: Optional[str] = None, max_results: Optional[int] = None,
|
|
199
|
+
embed: EmbedArg = None) -> List[Dict[str, Any]]:
|
|
200
|
+
"""Communities in which the current user may create subcommunities or collections.
|
|
201
|
+
|
|
202
|
+
Parameters:
|
|
203
|
+
query (Optional[str]): Search query
|
|
204
|
+
max_results (Optional[int]): Maximum number of results
|
|
205
|
+
embed (EmbedArg): Embedded resources to include
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List[Dict[str, Any]]: List of communities where the user can add subcommunities or collections
|
|
209
|
+
"""
|
|
210
|
+
return self._authorized_search("findAddAuthorized", query, max_results=max_results, embed=embed)
|
|
211
|
+
|
|
212
|
+
def iter_tree(self, community_uuid: Optional[str] = None, max_depth: Optional[int] = None,
|
|
213
|
+
include_collections: bool = True) -> Iterator[Tuple[int, str, Dict[str, Any]]]:
|
|
214
|
+
"""Iterate over the community tree in a depth-first manner.
|
|
215
|
+
|
|
216
|
+
Parameters:
|
|
217
|
+
community_uuid (Optional[str]): UUID of the starting community. If None, starts at top-level communities.
|
|
218
|
+
max_depth (Optional[int]): Maximum depth to traverse
|
|
219
|
+
include_collections (bool): Whether to include collections in the iteration
|
|
220
|
+
|
|
221
|
+
Yields:
|
|
222
|
+
Tuple[int, str, Dict[str, Any]]: (depth, "community"|"collection", object)
|
|
223
|
+
"""
|
|
224
|
+
roots = [self.get_community(community_uuid)] if community_uuid else self.get_top_communities()
|
|
225
|
+
stack = [(0, root) for root in reversed(roots)]
|
|
226
|
+
while stack:
|
|
227
|
+
depth, community = stack.pop()
|
|
228
|
+
yield depth, "community", community
|
|
229
|
+
if max_depth is not None and depth >= max_depth:
|
|
230
|
+
continue
|
|
231
|
+
if include_collections:
|
|
232
|
+
for collection in self.get_community_collections(community["uuid"]):
|
|
233
|
+
yield depth + 1, "collection", collection
|
|
234
|
+
for sub in reversed(self.get_subcommunities(community["uuid"])):
|
|
235
|
+
stack.append((depth + 1, sub))
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Write
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def create_community(self, metadata: Dict[str, Any], parent_uuid: Optional[str] = None) -> Dict[str, Any]:
|
|
242
|
+
"""
|
|
243
|
+
Create a community; with parent_uuid it becomes a subcommunity.
|
|
244
|
+
metadata: e.g. build_dso_metadata("Faculty of Music", description="...")
|
|
245
|
+
|
|
246
|
+
Parameters:
|
|
247
|
+
metadata (Dict[str, Any]): Metadata for the community
|
|
248
|
+
parent_uuid (Optional[str]): UUID of the parent community, if creating a subcommunity
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Dict[str, Any]: The created community object
|
|
252
|
+
"""
|
|
253
|
+
md = self._format_metadata(metadata)
|
|
254
|
+
if "dc.title" not in md:
|
|
255
|
+
raise ValueError("metadata must contain dc.title")
|
|
256
|
+
body = {"name": md["dc.title"][0]["value"], "metadata": md, "type": "community"}
|
|
257
|
+
params = {"parent": parent_uuid} if parent_uuid else None
|
|
258
|
+
community = self._request("POST", self._url(COMMUNITIES), "Create community", expected=(200, 201),
|
|
259
|
+
headers=self._headers(), params=params, json=body).json()
|
|
260
|
+
logger.info("Created %s %s", "subcommunity" if parent_uuid else "community", community.get("uuid"))
|
|
261
|
+
return community
|
|
262
|
+
|
|
263
|
+
def update_community_metadata(self, community_uuid: str, operation: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
264
|
+
"""
|
|
265
|
+
Update metadata of a community.
|
|
266
|
+
|
|
267
|
+
Parameters:
|
|
268
|
+
community_uuid (str): UUID of the community to update
|
|
269
|
+
operation (str): Patch operation (e.g., "add", "replace", "remove")
|
|
270
|
+
metadata (Dict[str, Any]): Metadata to apply with the operation
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Dict[str, Any]: The updated community object
|
|
274
|
+
"""
|
|
275
|
+
url = self._url(COMMUNITIES, f"/{community_uuid}")
|
|
276
|
+
return self._request("PATCH", url, f"Update community {community_uuid}", headers=self._headers(),
|
|
277
|
+
json=self._metadata_patch_ops(operation, metadata)).json()
|
|
278
|
+
|
|
279
|
+
def delete_community(self, community_uuid: str) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Delete a community.
|
|
282
|
+
|
|
283
|
+
Parameters:
|
|
284
|
+
community_uuid (str): UUID of the community to delete
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
bool: True if the community was successfully deleted
|
|
288
|
+
|
|
289
|
+
WARNING: deletes all subcommunities, collections and items below it.
|
|
290
|
+
"""
|
|
291
|
+
self._request("DELETE", self._url(COMMUNITIES, f"/{community_uuid}"),
|
|
292
|
+
f"Delete community {community_uuid}", expected=(204,))
|
|
293
|
+
logger.info("Deleted community %s", community_uuid)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
# Administrators
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def get_admin_group(self, community_uuid: str) -> Optional[Dict[str, Any]]:
|
|
301
|
+
"""Get the admin group for a community.
|
|
302
|
+
|
|
303
|
+
Parameters:
|
|
304
|
+
community_uuid (str): UUID of the community
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Optional[Dict[str, Any]]: The admin group object if it exists, None otherwise.
|
|
308
|
+
"""
|
|
309
|
+
return self.groups.get_role_group("community", community_uuid, "adminGroup")
|
|
310
|
+
|
|
311
|
+
def get_or_create_admin_group(self, community_uuid: str, description: Optional[str] = None) -> Dict[str, Any]:
|
|
312
|
+
"""Get or create the admin group for a community.
|
|
313
|
+
|
|
314
|
+
Parameters:
|
|
315
|
+
community_uuid (str): UUID of the community
|
|
316
|
+
description (Optional[str]): Description for the admin group if it needs to be created
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Dict[str, Any]: The admin group object
|
|
320
|
+
"""
|
|
321
|
+
return self.groups.get_or_create_role_group("community", community_uuid, "adminGroup", description)
|
|
322
|
+
|
|
323
|
+
def delete_admin_group(self, community_uuid: str) -> bool:
|
|
324
|
+
"""Delete the admin group for a community.
|
|
325
|
+
|
|
326
|
+
Parameters:
|
|
327
|
+
community_uuid (str): UUID of the community
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
bool: True if the admin group was successfully deleted
|
|
331
|
+
"""
|
|
332
|
+
return self.groups.delete_role_group("community", community_uuid, "adminGroup")
|
|
333
|
+
|
|
334
|
+
def add_admins(self, community_uuid: str, epersons: Iterable[Ref] = (), subgroups: Iterable[Ref] = (),
|
|
335
|
+
description: Optional[str] = None) -> Dict[str, Any]:
|
|
336
|
+
"""Create the admin group if needed and add epersons and/or groups to it.
|
|
337
|
+
|
|
338
|
+
Parameters:
|
|
339
|
+
community_uuid (str): UUID of the community
|
|
340
|
+
epersons (Iterable[Ref]): Epersons to add to the admin group
|
|
341
|
+
subgroups (Iterable[Ref]): Subgroups to add to the admin group
|
|
342
|
+
description (Optional[str]): Description for the admin group if it needs to be created
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Dict[str, Any]: The admin group object
|
|
346
|
+
"""
|
|
347
|
+
return self.groups.ensure_role_members("community", community_uuid, "adminGroup",
|
|
348
|
+
epersons, subgroups, description)
|
|
349
|
+
|
|
350
|
+
def get_admins(self, community_uuid: str) -> Dict[str, List[Dict[str, Any]]]:
|
|
351
|
+
"""Get the members of the admin group for a community.
|
|
352
|
+
|
|
353
|
+
Parameters:
|
|
354
|
+
community_uuid (str): UUID of the community
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Dict[str, List[Dict[str, Any]]]: {"epersons": [...], "subgroups": [...]} of the admin group (empty if none).
|
|
358
|
+
"""
|
|
359
|
+
group = self.get_admin_group(community_uuid)
|
|
360
|
+
if not group:
|
|
361
|
+
return {"epersons": [], "subgroups": []}
|
|
362
|
+
return {"epersons": self.groups.get_group_epersons(group["uuid"]),
|
|
363
|
+
"subgroups": self.groups.get_subgroups(group["uuid"])}
|
dspace_api/config.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Configuration and constants for DSpace API client
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from dotenv import find_dotenv, load_dotenv
|
|
12
|
+
|
|
13
|
+
from .projection import ALL_LANGUAGES, apply_view
|
|
14
|
+
|
|
15
|
+
# Projection that makes DSpace return metadata values in every language,
|
|
16
|
+
# instead of only the values matching the request locale.
|
|
17
|
+
ALL_LANGUAGES_PROJECTION = ALL_LANGUAGES
|
|
18
|
+
|
|
19
|
+
# Scoped override set by DSpaceConfig.languages(); None = use the config default.
|
|
20
|
+
# A ContextVar keeps the override local to the current thread / asyncio task.
|
|
21
|
+
_all_languages_scope: ContextVar[Optional[bool]] = ContextVar("dspace_all_languages", default=None)
|
|
22
|
+
|
|
23
|
+
# Environment variable truthy values
|
|
24
|
+
_TRUE_VALUES = ("1", "true", "yes", "on")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env_bool(name, default=False):
|
|
28
|
+
"""Parse an environment variable as a boolean."""
|
|
29
|
+
value = os.getenv(name)
|
|
30
|
+
if value is None or not value.strip():
|
|
31
|
+
return default
|
|
32
|
+
return value.strip().lower() in _TRUE_VALUES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _env_int(name, default):
|
|
36
|
+
"""Parse an environment variable as an integer."""
|
|
37
|
+
value = os.getenv(name)
|
|
38
|
+
try:
|
|
39
|
+
return int(value) if value is not None and value.strip() else default
|
|
40
|
+
except ValueError:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_float(name, default):
|
|
45
|
+
"""Parse an environment variable as a float."""
|
|
46
|
+
value = os.getenv(name)
|
|
47
|
+
try:
|
|
48
|
+
return float(value) if value is not None and value.strip() else default
|
|
49
|
+
except ValueError:
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _env_int_set(name, default):
|
|
54
|
+
"""Parse an environment variable as a set of integers."""
|
|
55
|
+
value = os.getenv(name)
|
|
56
|
+
if value is None or not value.strip():
|
|
57
|
+
return frozenset(default)
|
|
58
|
+
try:
|
|
59
|
+
return frozenset(int(v) for v in value.replace(";", ",").split(",") if v.strip())
|
|
60
|
+
except ValueError:
|
|
61
|
+
return frozenset(default)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Statuses that usually mean "try again later" rather than "your request is wrong"
|
|
65
|
+
DEFAULT_RETRY_STATUSES = (429, 502, 503, 504)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def with_all_languages(params, enabled):
|
|
69
|
+
"""
|
|
70
|
+
Return `params` with the allLanguages projection applied if `enabled` is True.
|
|
71
|
+
|
|
72
|
+
Accepts None, a dict or a list of (key, value) tuples and returns the
|
|
73
|
+
same shape (None becomes a list). An existing projection (e.g.
|
|
74
|
+
preventMetadataSecurity) is kept: DSpace accepts the parameter
|
|
75
|
+
several times and combines the projections.
|
|
76
|
+
"""
|
|
77
|
+
return apply_view(params, ALL_LANGUAGES_PROJECTION) if enabled else params
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DSpaceConfig:
|
|
81
|
+
"""
|
|
82
|
+
Configuration class for DSpace API settings
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, dspace_url=None, version=8, all_languages=None, timeout=None,
|
|
86
|
+
max_retries=None, retry_backoff=None, retry_max_wait=None, retry_statuses=None,
|
|
87
|
+
token_refresh_margin=None, reauthenticate=None, env_file=True,
|
|
88
|
+
accept_user_agreement=None):
|
|
89
|
+
"""
|
|
90
|
+
Robustness settings (each falls back to an environment variable / .env):
|
|
91
|
+
|
|
92
|
+
timeout DSPACE_TIMEOUT seconds per request (30)
|
|
93
|
+
max_retries DSPACE_MAX_RETRIES retries for transient failures (3)
|
|
94
|
+
retry_backoff DSPACE_RETRY_BACKOFF base seconds, doubled per attempt (1.0)
|
|
95
|
+
retry_max_wait DSPACE_RETRY_MAX_WAIT cap for one wait, incl. Retry-After (30)
|
|
96
|
+
retry_statuses DSPACE_RETRY_STATUSES e.g. "429,502,503,504"
|
|
97
|
+
token_refresh_margin DSPACE_TOKEN_REFRESH_MARGIN refresh the login token this many
|
|
98
|
+
seconds before it expires (60)
|
|
99
|
+
reauthenticate DSPACE_REAUTHENTICATE log in again on 401 / expiry (true)
|
|
100
|
+
accept_user_agreement DSPACE_ACCEPT_USER_AGREEMENT DSpace-CRIS: accept the end-user
|
|
101
|
+
agreement for the logged-in user when
|
|
102
|
+
the server demands it (false)
|
|
103
|
+
|
|
104
|
+
env_file: True (default) reads ./.env, a path reads that file, False
|
|
105
|
+
reads none. Variables already set in the environment always win over
|
|
106
|
+
the file, so a stray .env cannot override CI or deployment settings.
|
|
107
|
+
"""
|
|
108
|
+
if env_file:
|
|
109
|
+
# find_dotenv(usecwd=True): search from the working directory, not from site-packages
|
|
110
|
+
path = find_dotenv(usecwd=True) if env_file is True else os.fspath(env_file)
|
|
111
|
+
load_dotenv(dotenv_path=path or None, override=False)
|
|
112
|
+
|
|
113
|
+
self.dspace_url = dspace_url or os.getenv("DSPACE_URL", "http://localhost:8080/server/api")
|
|
114
|
+
self.version = version
|
|
115
|
+
|
|
116
|
+
# Default for read requests: ask for metadata in all languages?
|
|
117
|
+
# None -> DSPACE_ALL_LANGUAGES from the environment / .env (default: off)
|
|
118
|
+
if all_languages is None:
|
|
119
|
+
all_languages = _env_bool("DSPACE_ALL_LANGUAGES", False)
|
|
120
|
+
self.all_languages = bool(all_languages)
|
|
121
|
+
|
|
122
|
+
def pick(value, env_value):
|
|
123
|
+
"""Pick the value to use: prefer the explicit value, fallback to the environment value."""
|
|
124
|
+
return env_value if value is None else value
|
|
125
|
+
|
|
126
|
+
self.timeout = pick(timeout, _env_float("DSPACE_TIMEOUT", 30))
|
|
127
|
+
self.max_retries = max(0, int(pick(max_retries, _env_int("DSPACE_MAX_RETRIES", 3))))
|
|
128
|
+
self.retry_backoff = max(0.0, float(pick(retry_backoff, _env_float("DSPACE_RETRY_BACKOFF", 1.0))))
|
|
129
|
+
self.retry_max_wait = max(0.0, float(pick(retry_max_wait, _env_float("DSPACE_RETRY_MAX_WAIT", 30))))
|
|
130
|
+
self.retry_statuses = frozenset(retry_statuses) if retry_statuses is not None else \
|
|
131
|
+
_env_int_set("DSPACE_RETRY_STATUSES", DEFAULT_RETRY_STATUSES)
|
|
132
|
+
self.token_refresh_margin = max(0.0, float(pick(token_refresh_margin,
|
|
133
|
+
_env_float("DSPACE_TOKEN_REFRESH_MARGIN", 60))))
|
|
134
|
+
self.reauthenticate = bool(pick(reauthenticate, _env_bool("DSPACE_REAUTHENTICATE", True)))
|
|
135
|
+
self.accept_user_agreement = bool(pick(accept_user_agreement,
|
|
136
|
+
_env_bool("DSPACE_ACCEPT_USER_AGREEMENT", False)))
|
|
137
|
+
|
|
138
|
+
# Remove trailing slash if present
|
|
139
|
+
if self.dspace_url.endswith('/'):
|
|
140
|
+
self.dspace_url = self.dspace_url[:-1]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def headers(self):
|
|
144
|
+
"""Default headers for API requests.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Dict[str, str]: The default headers for API requests.
|
|
148
|
+
"""
|
|
149
|
+
return {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
"Accept": "application/json",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def use_all_languages(self, call_value=None):
|
|
155
|
+
"""
|
|
156
|
+
Whether a read request should use projection=allLanguages.
|
|
157
|
+
Precedence: explicit per-call value > languages() scope > config default.
|
|
158
|
+
|
|
159
|
+
Parameters:
|
|
160
|
+
call_value (Optional[bool]): Explicit per-call value for allLanguages. If None, the scope or config default is used.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
bool: True if allLanguages should be used for the request, False otherwise.
|
|
164
|
+
"""
|
|
165
|
+
if call_value is not None:
|
|
166
|
+
return bool(call_value)
|
|
167
|
+
scoped = _all_languages_scope.get()
|
|
168
|
+
return self.all_languages if scoped is None else scoped
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def languages(self, all_languages=True):
|
|
172
|
+
"""
|
|
173
|
+
Temporarily switch allLanguages on/off for every call in the block:
|
|
174
|
+
|
|
175
|
+
with client.config.languages(all_languages=True):
|
|
176
|
+
item = client.items.get_item(uuid)
|
|
177
|
+
|
|
178
|
+
The scope applies to all clients in the current thread/task;
|
|
179
|
+
an explicit all_languages=... argument on a call still wins.
|
|
180
|
+
|
|
181
|
+
Parameters:
|
|
182
|
+
all_languages (bool): Whether to enable allLanguages for the scope of the block.
|
|
183
|
+
"""
|
|
184
|
+
token = _all_languages_scope.set(bool(all_languages))
|
|
185
|
+
try:
|
|
186
|
+
yield self
|
|
187
|
+
finally:
|
|
188
|
+
_all_languages_scope.reset(token)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# API Endpoints
|
|
192
|
+
class Endpoints:
|
|
193
|
+
"""API endpoint constants"""
|
|
194
|
+
|
|
195
|
+
# Authentication
|
|
196
|
+
AUTHN_STATUS = "/authn/status"
|
|
197
|
+
AUTHN_LOGIN = "/authn/login"
|
|
198
|
+
AUTHN_LOGOUT = "/authn/logout"
|
|
199
|
+
CSRF = "/security/csrf" # only DSpace 8+
|
|
200
|
+
|
|
201
|
+
# Core API
|
|
202
|
+
ITEMS = "/core/items"
|
|
203
|
+
COMMUNITIES = "/core/communities"
|
|
204
|
+
COLLECTIONS = "/core/collections"
|
|
205
|
+
BUNDLES = "/core/bundles"
|
|
206
|
+
BITSTREAMS = "/core/bitstreams"
|
|
207
|
+
BITSTREAM_FORMATS = "/core/bitstreamformats"
|
|
208
|
+
SITES = "/core/sites"
|
|
209
|
+
ENTITY_TYPES = "/core/entitytypes"
|
|
210
|
+
|
|
211
|
+
# Authorization / access control
|
|
212
|
+
RESOURCE_POLICIES = "/authz/resourcepolicies"
|
|
213
|
+
GROUPS = "/eperson/groups"
|
|
214
|
+
EPERSONS = "/eperson/epersons"
|
|
215
|
+
REGISTRATIONS = "/eperson/registrations"
|
|
216
|
+
BULK_ACCESS_CONDITION_OPTIONS = "/config/bulkaccessconditionoptions"
|
|
217
|
+
SCRIPTS = "/system/scripts"
|
|
218
|
+
PROCESSES = "/system/processes"
|
|
219
|
+
|
|
220
|
+
# Submission
|
|
221
|
+
WORKSPACE_ITEMS = "/submission/workspaceitems"
|
|
222
|
+
WORKFLOW_ITEMS = "/workflow/workflowitems"
|
|
223
|
+
|
|
224
|
+
# Discovery
|
|
225
|
+
DISCOVER_SEARCH = "/discover/search/objects"
|
|
226
|
+
|
|
227
|
+
# Vocabulary
|
|
228
|
+
VOCABULARIES = "/submission/vocabularies"
|
|
229
|
+
VOCABULARY_ENTRIES = "/submission/vocabularyEntryDetails"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Entity Types
|
|
233
|
+
class EntityTypes:
|
|
234
|
+
"""DSpace entity type constants"""
|
|
235
|
+
PUBLICATION = "Publication"
|
|
236
|
+
PERSON = "Person"
|
|
237
|
+
PROJECT = "Project"
|
|
238
|
+
ORGUNIT = "OrgUnit"
|
|
239
|
+
JOURNAL = "Journal"
|
|
240
|
+
JOURNALVOLUME = "JournalVolume"
|
|
241
|
+
JOURNALISSUE = "JournalIssue"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# COAR Types
|
|
245
|
+
class CoarTypes:
|
|
246
|
+
"""Common COAR publication type constants"""
|
|
247
|
+
STILL_IMAGE = "publication-coar-types:c_ecc8"
|
|
248
|
+
ARTICLE = "publication-coar-types:c_6501"
|
|
249
|
+
BOOK = "publication-coar-types:c_2f33"
|
|
250
|
+
THESIS = "publication-coar-types:c_46ec"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Common constants
|
|
254
|
+
DEFAULT_COLLECTION_UUID = "eead452e-0238-4e7e-912f-70cecd4a1f67"
|