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,694 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Relationships between items (configurable entities, DSpace 7+)
|
|
4
|
+
|
|
5
|
+
Plain DSpace links entity items - Publication, Person, OrgUnit, Project,
|
|
6
|
+
Journal, JournalVolume, JournalIssue - through relationships of a
|
|
7
|
+
relationship type. DSpace-CRIS links entities through authority metadata
|
|
8
|
+
instead and normally has no relationship types; these endpoints are there for
|
|
9
|
+
completeness and plain-DSpace installations.
|
|
10
|
+
|
|
11
|
+
GET /core/relationshiptypes[/{id}]
|
|
12
|
+
GET /core/relationshiptypes/search/byEntityType?type=
|
|
13
|
+
GET /core/entitytypes/{id}/relationshiptypes (EntityTypeManager)
|
|
14
|
+
GET /core/relationships[/{id}[/leftItem|/rightItem]]
|
|
15
|
+
GET /core/relationships/search/byLabel?label=&dso=&relatedEntityType=
|
|
16
|
+
GET /core/relationships/search/byItemsAndType?typeId=&relationshipLabel=&focusItem=&relatedItem=..
|
|
17
|
+
GET /core/items/{uuid}/relationships
|
|
18
|
+
POST /core/relationships?relationshipType=&leftwardValue=&rightwardValue= text/uri-list: left, right
|
|
19
|
+
PUT /core/relationships/{id} places and name variants (full replace)
|
|
20
|
+
PUT /core/relationships/{id}/leftItem|rightItem text/uri-list
|
|
21
|
+
DELETE /core/relationships/{id}?copyVirtualMetadata=all|left|right|configured
|
|
22
|
+
|
|
23
|
+
Direction
|
|
24
|
+
A relationship type is defined left -> right, e.g. Publication (left) to
|
|
25
|
+
Person (right) with leftwardType "isAuthorOfPublication" (the relation as
|
|
26
|
+
seen from the left item) and rightwardType "isPublicationOfAuthor". The
|
|
27
|
+
same label may exist for several entity pairs. relate() and friends take
|
|
28
|
+
the label from the point of view of the *focus* item and work out type
|
|
29
|
+
and order:
|
|
30
|
+
|
|
31
|
+
rel = client.relationships
|
|
32
|
+
rel.relate(publication, "isAuthorOfPublication", person, name_variant="Smith, J.")
|
|
33
|
+
rel.related_items(publication, "isAuthorOfPublication") # authors in order
|
|
34
|
+
rel.reorder(publication, "isAuthorOfPublication", [p2, p1])
|
|
35
|
+
rel.unrelate(publication, "isAuthorOfPublication", person, copy_virtual_metadata="left")
|
|
36
|
+
|
|
37
|
+
Places and name variants
|
|
38
|
+
leftPlace is the position of the relationship in the left item's list
|
|
39
|
+
(e.g. author order on the publication), rightPlace in the right item's.
|
|
40
|
+
The name variant (leftwardValue / rightwardValue) is how the related
|
|
41
|
+
item is shown on that side ("Smith, J." instead of "Smith, Jane").
|
|
42
|
+
|
|
43
|
+
Virtual metadata
|
|
44
|
+
Related items show up in the item's metadata as virtual values (e.g.
|
|
45
|
+
dc.contributor.author with authority "virtual::<relationship id>").
|
|
46
|
+
They cannot be changed with a metadata PATCH - change the relationship.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import logging
|
|
50
|
+
import re
|
|
51
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
|
52
|
+
|
|
53
|
+
from ._base import BaseManager
|
|
54
|
+
from .exceptions import DSpaceError
|
|
55
|
+
from .projection import EmbedArg
|
|
56
|
+
|
|
57
|
+
# Logger for the module
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
# API endpoints and constants for relationships management
|
|
61
|
+
RELATIONSHIPS = "/core/relationships"
|
|
62
|
+
RELATIONSHIP_TYPES = "/core/relationshiptypes"
|
|
63
|
+
ITEMS = "/core/items"
|
|
64
|
+
|
|
65
|
+
# Copy virtual metadata options and sides for relationships
|
|
66
|
+
COPY_VIRTUAL_METADATA = ("all", "left", "right", "configured")
|
|
67
|
+
SIDES = ("left", "right")
|
|
68
|
+
|
|
69
|
+
# Type alias for item references (either a UUID string or an item dict)
|
|
70
|
+
ItemRef = Union[str, Dict[str, Any]]
|
|
71
|
+
_UNSET = object()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def entity_type_of(item: Dict[str, Any]) -> Optional[str]:
|
|
75
|
+
"""Entity type label of an item dict (entityType attribute or dspace.entity.type metadata).
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
item (dict): The item dictionary to extract the entity type from
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Optional[str]: The entity type label if available, otherwise None
|
|
82
|
+
"""
|
|
83
|
+
if item.get("entityType"):
|
|
84
|
+
return item["entityType"]
|
|
85
|
+
values = (item.get("metadata") or {}).get("dspace.entity.type") or []
|
|
86
|
+
return values[0].get("value") if values else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# UUID regex for item hrefs
|
|
91
|
+
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _uuid_from_href(href: Optional[str]) -> Optional[str]:
|
|
95
|
+
"""'UUID' extraction from item hrefs.
|
|
96
|
+
|
|
97
|
+
'.../core/items/<uuid>' -> '<uuid>' (None if the link has no item uuid at the end).
|
|
98
|
+
|
|
99
|
+
Parameters:
|
|
100
|
+
href (Optional[str]): The item href to extract the UUID from
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Optional[str]: The extracted UUID if present, otherwise None
|
|
104
|
+
"""
|
|
105
|
+
if not href:
|
|
106
|
+
return None
|
|
107
|
+
last = href.split("?", 1)[0].rstrip("/").rsplit("/", 1)[-1]
|
|
108
|
+
return last if _UUID_RE.fullmatch(last) else None
|
|
109
|
+
|
|
110
|
+
class RelationshipManager(BaseManager):
|
|
111
|
+
"""Relationship types and relationships between entity items"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, auth):
|
|
114
|
+
"""Initialize the RelationshipManager with the given authentication object.
|
|
115
|
+
|
|
116
|
+
Parameters:
|
|
117
|
+
auth: The authentication object to use for API requests
|
|
118
|
+
"""
|
|
119
|
+
super().__init__(auth)
|
|
120
|
+
self._types: Optional[List[Dict[str, Any]]] = None
|
|
121
|
+
self._type_labels: Dict[Any, Tuple[Optional[str], Optional[str]]] = {}
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Helpers
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _item_uuid(self, item: ItemRef) -> str:
|
|
128
|
+
"""Get the UUID of an item reference.
|
|
129
|
+
|
|
130
|
+
Parameters:
|
|
131
|
+
item (ItemRef): The item reference, either a UUID string or an item dictionary
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
str: The UUID of the item
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If the item reference does not contain a valid UUID
|
|
138
|
+
"""
|
|
139
|
+
uuid = self._uuid_of(item)
|
|
140
|
+
if not uuid:
|
|
141
|
+
raise ValueError(f"Not an item uuid or item dict: {item!r}")
|
|
142
|
+
return uuid
|
|
143
|
+
|
|
144
|
+
def _item_uri(self, item: ItemRef) -> str:
|
|
145
|
+
"""Get the URI of an item reference.
|
|
146
|
+
|
|
147
|
+
Parameters:
|
|
148
|
+
item (ItemRef): The item reference, either a UUID string or an item dictionary
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
str: The URI of the item
|
|
152
|
+
"""
|
|
153
|
+
return self._url(ITEMS, f"/{self._item_uuid(item)}")
|
|
154
|
+
|
|
155
|
+
def _entity_type(self, item: ItemRef) -> Optional[str]:
|
|
156
|
+
"""Get the entity type label of an item reference.
|
|
157
|
+
|
|
158
|
+
Parameters:
|
|
159
|
+
item (ItemRef): The item reference, either a UUID string or an item dictionary
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Optional[str]: The entity type label if available, otherwise None
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(item, dict) and entity_type_of(item):
|
|
165
|
+
return entity_type_of(item)
|
|
166
|
+
data = self._get_json(self._url(ITEMS, f"/{self._item_uuid(item)}"), "Get item entity type")
|
|
167
|
+
return entity_type_of(data)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# Relationship types
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def get_relationship_types(self, refresh: bool = False) -> List[Dict[str, Any]]:
|
|
174
|
+
"""All relationship types (cached per manager), with leftType / rightType embedded where the server does.
|
|
175
|
+
|
|
176
|
+
Parameters:
|
|
177
|
+
refresh (bool): If True, refresh the cached relationship types from the server
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List[Dict[str, Any]]: A list of relationship type dictionaries
|
|
181
|
+
"""
|
|
182
|
+
if self._types is None or refresh:
|
|
183
|
+
self._types = list(self._iter_embedded(self._url(RELATIONSHIP_TYPES), "relationshiptypes",
|
|
184
|
+
"List relationship types", embed=["leftType", "rightType"]))
|
|
185
|
+
return self._types
|
|
186
|
+
|
|
187
|
+
def get_relationship_type(self, type_id) -> Dict[str, Any]:
|
|
188
|
+
"""Get a specific relationship type by its ID.
|
|
189
|
+
|
|
190
|
+
Parameters:
|
|
191
|
+
type_id: The ID of the relationship type to retrieve
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Dict[str, Any]: The relationship type dictionary
|
|
195
|
+
"""
|
|
196
|
+
return self._get_json(self._url(RELATIONSHIP_TYPES, f"/{type_id}"), f"Get relationship type {type_id}",
|
|
197
|
+
embed=["leftType", "rightType"])
|
|
198
|
+
|
|
199
|
+
def get_relationship_types_for_entity(self, entity_type: str) -> List[Dict[str, Any]]:
|
|
200
|
+
"""Get relationship types with this entity type (label) on either side.
|
|
201
|
+
|
|
202
|
+
Parameters:
|
|
203
|
+
entity_type (str): The entity type label to filter relationship types by
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List[Dict[str, Any]]: A list of relationship type dictionaries matching the entity type
|
|
207
|
+
"""
|
|
208
|
+
url = self._url(RELATIONSHIP_TYPES, "/search/byEntityType")
|
|
209
|
+
return list(self._iter_embedded(url, "relationshiptypes", f"Relationship types of {entity_type}",
|
|
210
|
+
[("type", entity_type)], embed=["leftType", "rightType"]))
|
|
211
|
+
|
|
212
|
+
def type_entity_labels(self, rtype: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
|
213
|
+
"""Get the entity type labels for the left and right sides of a relationship type.
|
|
214
|
+
|
|
215
|
+
Parameters:
|
|
216
|
+
rtype (Dict[str, Any]): The relationship type dictionary
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Tuple[Optional[str], Optional[str]]: A tuple containing the left and right entity type labels, or None if not available
|
|
220
|
+
"""
|
|
221
|
+
key = rtype.get("id")
|
|
222
|
+
if key not in self._type_labels:
|
|
223
|
+
embedded = rtype.get("_embedded") or {}
|
|
224
|
+
labels = []
|
|
225
|
+
for side in ("leftType", "rightType"):
|
|
226
|
+
label = (embedded.get(side) or {}).get("label")
|
|
227
|
+
href = ((rtype.get("_links") or {}).get(side) or {}).get("href")
|
|
228
|
+
if label is None and href:
|
|
229
|
+
label = self.get_by_href(href).get("label")
|
|
230
|
+
labels.append(label)
|
|
231
|
+
self._type_labels[key] = (labels[0], labels[1])
|
|
232
|
+
return self._type_labels[key]
|
|
233
|
+
|
|
234
|
+
def resolve_type(self, label: str, focus_entity_type: Optional[str] = None,
|
|
235
|
+
related_entity_type: Optional[str] = None) -> Tuple[Dict[str, Any], str]:
|
|
236
|
+
"""
|
|
237
|
+
(relationship type, focus side) for a relation label seen from the
|
|
238
|
+
focus item. focus side is "left" when label is the type's
|
|
239
|
+
leftwardType, "right" when it is the rightwardType. Entity types
|
|
240
|
+
disambiguate labels used by several types; ValueError if it stays
|
|
241
|
+
ambiguous or nothing matches.
|
|
242
|
+
|
|
243
|
+
Parameters:
|
|
244
|
+
label (str): The relationship label to resolve
|
|
245
|
+
focus_entity_type (Optional[str]): The entity type label of the focus item
|
|
246
|
+
related_entity_type (Optional[str]): The entity type label of the related item
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Tuple[Dict[str, Any], str]: A tuple containing the resolved relationship type dictionary and the focus side ("left" or "right")
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
ValueError: If the label is ambiguous or no matching relationship type is found
|
|
253
|
+
"""
|
|
254
|
+
candidates = []
|
|
255
|
+
for rtype in self.get_relationship_types():
|
|
256
|
+
for side, name in (("left", rtype.get("leftwardType")), ("right", rtype.get("rightwardType"))):
|
|
257
|
+
if name != label:
|
|
258
|
+
continue
|
|
259
|
+
left, right = self.type_entity_labels(rtype)
|
|
260
|
+
focus, related = (left, right) if side == "left" else (right, left)
|
|
261
|
+
if focus_entity_type and focus and focus != focus_entity_type:
|
|
262
|
+
continue
|
|
263
|
+
if related_entity_type and related and related != related_entity_type:
|
|
264
|
+
continue
|
|
265
|
+
candidates.append((rtype, side))
|
|
266
|
+
if not candidates:
|
|
267
|
+
raise ValueError(f"No relationship type with label {label!r}"
|
|
268
|
+
+ (f" for {focus_entity_type} -> {related_entity_type}"
|
|
269
|
+
if focus_entity_type or related_entity_type else "")
|
|
270
|
+
+ " (is the entity model loaded? dspace initialize-entities)")
|
|
271
|
+
if len(candidates) > 1:
|
|
272
|
+
pairs = [self.type_entity_labels(t) for t, _ in candidates]
|
|
273
|
+
raise ValueError(f"Label {label!r} is ambiguous ({pairs}) - pass the entity types or type_id")
|
|
274
|
+
return candidates[0]
|
|
275
|
+
|
|
276
|
+
def _resolve(self, focus: ItemRef, label: str, related: Optional[ItemRef], type_id=None
|
|
277
|
+
) -> Tuple[Dict[str, Any], str]:
|
|
278
|
+
"""Resolve a relationship type based on the focus item, label, and related item.
|
|
279
|
+
|
|
280
|
+
Parameters:
|
|
281
|
+
focus (ItemRef): The focus item of the relationship
|
|
282
|
+
label (str): The relationship label to resolve
|
|
283
|
+
related (Optional[ItemRef]): The related item of the relationship
|
|
284
|
+
type_id (Optional[str], optional): The ID of the relationship type to resolve
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Tuple[Dict[str, Any], str]: A tuple containing the resolved relationship type dictionary and the focus side ("left" or "right")
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ValueError: If the label is ambiguous or no matching relationship type is found
|
|
291
|
+
"""
|
|
292
|
+
if type_id is not None:
|
|
293
|
+
rtype = self.get_relationship_type(type_id)
|
|
294
|
+
if label == rtype.get("leftwardType"):
|
|
295
|
+
return rtype, "left"
|
|
296
|
+
if label == rtype.get("rightwardType"):
|
|
297
|
+
return rtype, "right"
|
|
298
|
+
raise ValueError(f"Relationship type {type_id} has no label {label!r}")
|
|
299
|
+
return self.resolve_type(label, self._entity_type(focus),
|
|
300
|
+
self._entity_type(related) if related is not None else None)
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Reading relationships
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def iter_relationships(self, embed: EmbedArg = "relationshipType", page_size: Optional[int] = None,
|
|
307
|
+
max_results: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
308
|
+
"""Every relationship in the repository (a generator) - e.g. for audits or exports.
|
|
309
|
+
|
|
310
|
+
Parameters:
|
|
311
|
+
embed (EmbedArg, optional): The embed parameter for the request
|
|
312
|
+
page_size (Optional[int], optional): The number of items per page
|
|
313
|
+
max_results (Optional[int], optional): The maximum number of results to retrieve
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Iterator[Dict[str, Any]]: An iterator over relationship dictionaries
|
|
317
|
+
"""
|
|
318
|
+
return self._iter_embedded(self._url(RELATIONSHIPS), "relationships", "List relationships",
|
|
319
|
+
page_size=page_size, max_results=max_results, embed=embed)
|
|
320
|
+
|
|
321
|
+
def get_relationship(self, relationship_id, embed: EmbedArg = None) -> Dict[str, Any]:
|
|
322
|
+
"""Get a specific relationship by its ID.
|
|
323
|
+
|
|
324
|
+
Parameters:
|
|
325
|
+
relationship_id: The ID of the relationship to retrieve
|
|
326
|
+
embed (EmbedArg, optional): The embed parameter for the request
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Dict[str, Any]: The relationship dictionary
|
|
330
|
+
"""
|
|
331
|
+
return self._get_json(self._url(RELATIONSHIPS, f"/{relationship_id}"),
|
|
332
|
+
f"Get relationship {relationship_id}", embed=embed)
|
|
333
|
+
|
|
334
|
+
def get_relationship_item(self, relationship, side: str) -> Dict[str, Any]:
|
|
335
|
+
"""Get the left or right item of a relationship.
|
|
336
|
+
|
|
337
|
+
Parameters:
|
|
338
|
+
relationship (Union[int, Dict[str, Any]]): The relationship ID or dictionary
|
|
339
|
+
side (str): "left" or "right" to specify which item to retrieve
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Dict[str, Any]: The item dictionary
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ValueError: If the side is not "left" or "right", or if the relationship has no link for the specified side
|
|
346
|
+
|
|
347
|
+
Notes:
|
|
348
|
+
DSpace links the item directly (_links.leftItem -> /core/items/{uuid});
|
|
349
|
+
there is no /relationships/{id}/leftItem resource to GET.
|
|
350
|
+
"""
|
|
351
|
+
if side not in SIDES:
|
|
352
|
+
raise ValueError("side must be 'left' or 'right'")
|
|
353
|
+
if not isinstance(relationship, dict):
|
|
354
|
+
relationship = self.get_relationship(relationship)
|
|
355
|
+
href = ((relationship.get("_links") or {}).get(f"{side}Item") or {}).get("href")
|
|
356
|
+
if not href:
|
|
357
|
+
raise ValueError(f"Relationship {relationship.get('id')} has no {side}Item link")
|
|
358
|
+
return self._get_json(href, f"Get {side} item of relationship {relationship.get('id')}")
|
|
359
|
+
|
|
360
|
+
def ends(self, relationship: Dict[str, Any]) -> Tuple[str, str]:
|
|
361
|
+
"""
|
|
362
|
+
Get the UUIDs of the left and right items of a relationship.
|
|
363
|
+
|
|
364
|
+
Parameters:
|
|
365
|
+
relationship (Dict[str, Any]): The relationship dictionary
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Tuple[str, str]: A tuple containing the UUIDs of the left and right items (left, right)
|
|
369
|
+
|
|
370
|
+
Notes:
|
|
371
|
+
The UUIDs are retrieved from leftId/rightId, embedded items, or the item links.
|
|
372
|
+
"""
|
|
373
|
+
embedded = relationship.get("_embedded") or {}
|
|
374
|
+
links = relationship.get("_links") or {}
|
|
375
|
+
result = []
|
|
376
|
+
for side in SIDES:
|
|
377
|
+
uuid = relationship.get(f"{side}Id") or (embedded.get(f"{side}Item") or {}).get("uuid")
|
|
378
|
+
if not uuid:
|
|
379
|
+
uuid = _uuid_from_href((links.get(f"{side}Item") or {}).get("href"))
|
|
380
|
+
if not uuid:
|
|
381
|
+
uuid = self.get_relationship_item(relationship, side).get("uuid")
|
|
382
|
+
result.append(uuid)
|
|
383
|
+
return result[0], result[1]
|
|
384
|
+
|
|
385
|
+
def iter_item_relationships(self, item: ItemRef, embed: EmbedArg = "relationshipType"
|
|
386
|
+
) -> Iterator[Dict[str, Any]]:
|
|
387
|
+
"""Every relationship with the item on either side (a generator).
|
|
388
|
+
|
|
389
|
+
Parameters:
|
|
390
|
+
item (ItemRef): The item to find relationships for
|
|
391
|
+
embed (EmbedArg, optional): The embed parameter for the request
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Iterator[Dict[str, Any]]: An iterator over relationship dictionaries
|
|
395
|
+
"""
|
|
396
|
+
uuid = self._item_uuid(item)
|
|
397
|
+
return self._iter_embedded(self._url(ITEMS, f"/{uuid}/relationships"), "relationships",
|
|
398
|
+
f"Get relationships of item {uuid}", embed=embed)
|
|
399
|
+
|
|
400
|
+
def search_by_label(self, label: str, dso: Optional[ItemRef] = None,
|
|
401
|
+
related_entity_type: Optional[str] = None,
|
|
402
|
+
max_results: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
403
|
+
"""Relationships of a type label (either side), optionally of one item and related entity type.
|
|
404
|
+
|
|
405
|
+
Parameters:
|
|
406
|
+
label (str): The relationship label to search for
|
|
407
|
+
dso (Optional[ItemRef], optional): The item to filter relationships by
|
|
408
|
+
related_entity_type (Optional[str], optional): The related entity type to filter by
|
|
409
|
+
max_results (Optional[int], optional): The maximum number of results to retrieve
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List[Dict[str, Any]]: A list of relationship dictionaries
|
|
413
|
+
"""
|
|
414
|
+
params = [("label", label)]
|
|
415
|
+
if dso is not None:
|
|
416
|
+
params.append(("dso", self._item_uuid(dso)))
|
|
417
|
+
if related_entity_type:
|
|
418
|
+
params.append(("relatedEntityType", related_entity_type))
|
|
419
|
+
return list(self._iter_embedded(self._url(RELATIONSHIPS, "/search/byLabel"), "relationships",
|
|
420
|
+
f"Search relationships {label}", params, max_results=max_results))
|
|
421
|
+
|
|
422
|
+
def find_relationships(self, focus: ItemRef, label: str, related: Union[ItemRef, Iterable[ItemRef]],
|
|
423
|
+
type_id=None) -> List[Dict[str, Any]]:
|
|
424
|
+
"""Existing relationships of the label between focus and the given related item(s).
|
|
425
|
+
|
|
426
|
+
Parameters:
|
|
427
|
+
focus (ItemRef): The focus item of the relationship
|
|
428
|
+
label (str): The relationship label to search for
|
|
429
|
+
related (Union[ItemRef, Iterable[ItemRef]]): The related item(s) to search for
|
|
430
|
+
type_id (Optional[str], optional): The ID of the relationship type to filter by
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
List[Dict[str, Any]]: A list of relationship dictionaries
|
|
434
|
+
Notes:
|
|
435
|
+
The function will return an empty list if no related items are provided.
|
|
436
|
+
"""
|
|
437
|
+
related_list = [related] if isinstance(related, (str, dict)) else list(related)
|
|
438
|
+
if not related_list:
|
|
439
|
+
return []
|
|
440
|
+
rtype, _side = self._resolve(focus, label, related_list[0], type_id)
|
|
441
|
+
params = [("typeId", rtype["id"]), ("relationshipLabel", label), ("focusItem", self._item_uuid(focus))]
|
|
442
|
+
params += [("relatedItem", self._item_uuid(r)) for r in related_list]
|
|
443
|
+
return list(self._iter_embedded(self._url(RELATIONSHIPS, "/search/byItemsAndType"), "relationships",
|
|
444
|
+
"Find relationships by items and type", params))
|
|
445
|
+
|
|
446
|
+
def related_items(self, item: ItemRef, label: str) -> List[Dict[str, Any]]:
|
|
447
|
+
"""
|
|
448
|
+
Retrieve items related to the given item under the specified relationship label.
|
|
449
|
+
|
|
450
|
+
Parameters:
|
|
451
|
+
item (ItemRef): The item to find related items for
|
|
452
|
+
label (str): The relationship label to search for
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List[Dict[str, Any]]: A list of dictionaries containing related item information, including relationship ID, item UUID, place, name variant, and type ID.
|
|
456
|
+
|
|
457
|
+
Items related to `item` under `label` (seen from `item`), in order:
|
|
458
|
+
[{"relationship_id", "item_uuid", "place", "name_variant", "type_id"}].
|
|
459
|
+
"""
|
|
460
|
+
uuid = self._item_uuid(item)
|
|
461
|
+
result = []
|
|
462
|
+
for relationship in self.search_by_label(label, dso=uuid):
|
|
463
|
+
rtype = (relationship.get("_embedded") or {}).get("relationshipType") or {}
|
|
464
|
+
left, right = self.ends(relationship)
|
|
465
|
+
if left == uuid and rtype.get("leftwardType", label) == label:
|
|
466
|
+
other, place, variant = right, relationship.get("leftPlace"), relationship.get("leftwardValue")
|
|
467
|
+
elif right == uuid and rtype.get("rightwardType", label) == label:
|
|
468
|
+
other, place, variant = left, relationship.get("rightPlace"), relationship.get("rightwardValue")
|
|
469
|
+
else:
|
|
470
|
+
continue
|
|
471
|
+
result.append({"relationship_id": relationship["id"], "item_uuid": other, "place": place,
|
|
472
|
+
"name_variant": variant, "type_id": rtype.get("id")})
|
|
473
|
+
return sorted(result, key=lambda r: (r["place"] is None, r["place"]))
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
# Writing relationships
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def create_relationship(self, type_id, left_item: ItemRef, right_item: ItemRef,
|
|
480
|
+
leftward_value: Optional[str] = None,
|
|
481
|
+
rightward_value: Optional[str] = None) -> Dict[str, Any]:
|
|
482
|
+
"""Create a relationship; left/right must match the type's leftType/rightType.
|
|
483
|
+
|
|
484
|
+
Parameters:
|
|
485
|
+
type_id (str): The ID of the relationship type
|
|
486
|
+
left_item (ItemRef): The left item of the relationship
|
|
487
|
+
right_item (ItemRef): The right item of the relationship
|
|
488
|
+
leftward_value (Optional[str], optional): The leftward value for the relationship
|
|
489
|
+
rightward_value (Optional[str], optional): The rightward value for the relationship
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Dict[str, Any]: The created relationship dictionary
|
|
493
|
+
"""
|
|
494
|
+
params = [("relationshipType", type_id)]
|
|
495
|
+
if leftward_value is not None:
|
|
496
|
+
params.append(("leftwardValue", leftward_value))
|
|
497
|
+
if rightward_value is not None:
|
|
498
|
+
params.append(("rightwardValue", rightward_value))
|
|
499
|
+
body = f"{self._item_uri(left_item)}\n{self._item_uri(right_item)}"
|
|
500
|
+
relationship = self._request("POST", self._url(RELATIONSHIPS), "Create relationship", expected=(200, 201),
|
|
501
|
+
headers=self._headers("text/uri-list"), params=params, data=body).json()
|
|
502
|
+
logger.info("Created relationship %s (type %s)", relationship.get("id"), type_id)
|
|
503
|
+
return relationship
|
|
504
|
+
|
|
505
|
+
def relate(self, focus: ItemRef, label: str, related: ItemRef, name_variant: Optional[str] = None,
|
|
506
|
+
type_id=None) -> Dict[str, Any]:
|
|
507
|
+
"""
|
|
508
|
+
Relate focus to related under `label` as seen from focus, e.g.
|
|
509
|
+
relate(publication, "isAuthorOfPublication", person). name_variant is
|
|
510
|
+
how `related` appears on the focus item.
|
|
511
|
+
|
|
512
|
+
Parameters:
|
|
513
|
+
focus (ItemRef): The focus item of the relationship
|
|
514
|
+
label (str): The relationship label to use
|
|
515
|
+
related (ItemRef): The related item
|
|
516
|
+
name_variant (Optional[str], optional): The name variant for the related item as seen from the focus item
|
|
517
|
+
type_id (Optional[str], optional): The ID of the relationship type to use
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Dict[str, Any]: The created relationship dictionary
|
|
521
|
+
"""
|
|
522
|
+
rtype, side = self._resolve(focus, label, related, type_id)
|
|
523
|
+
if side == "left":
|
|
524
|
+
return self.create_relationship(rtype["id"], focus, related, leftward_value=name_variant)
|
|
525
|
+
return self.create_relationship(rtype["id"], related, focus, rightward_value=name_variant)
|
|
526
|
+
|
|
527
|
+
def ensure_related(self, focus: ItemRef, label: str, related: ItemRef, name_variant: Optional[str] = None,
|
|
528
|
+
type_id=None) -> Tuple[Dict[str, Any], bool]:
|
|
529
|
+
"""Like relate(), but returns (existing relationship, False) when it is already there.
|
|
530
|
+
|
|
531
|
+
Parameters:
|
|
532
|
+
focus (ItemRef): The focus item of the relationship
|
|
533
|
+
label (str): The relationship label to use
|
|
534
|
+
related (ItemRef): The related item
|
|
535
|
+
name_variant (Optional[str], optional): The name variant for the related item as seen from the focus item
|
|
536
|
+
type_id (Optional[str], optional): The ID of the relationship type to use
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Tuple[Dict[str, Any], bool]: A tuple containing the relationship dictionary and a boolean indicating if it was newly created
|
|
540
|
+
"""
|
|
541
|
+
existing = self.find_relationships(focus, label, related, type_id)
|
|
542
|
+
if existing:
|
|
543
|
+
return existing[0], False
|
|
544
|
+
return self.relate(focus, label, related, name_variant, type_id), True
|
|
545
|
+
|
|
546
|
+
def update_relationship(self, relationship_id, left_place=_UNSET, right_place=_UNSET,
|
|
547
|
+
leftward_value=_UNSET, rightward_value=_UNSET) -> Dict[str, Any]:
|
|
548
|
+
"""
|
|
549
|
+
Change places and/or name variants. The PUT replaces all four
|
|
550
|
+
properties, so the current values are read first and only the given
|
|
551
|
+
ones change (pass None to clear a name variant).
|
|
552
|
+
|
|
553
|
+
Parameters:
|
|
554
|
+
relationship_id (str): The ID of the relationship to update
|
|
555
|
+
left_place (Optional[int], optional): The new left place value
|
|
556
|
+
right_place (Optional[int], optional): The new right place value
|
|
557
|
+
leftward_value (Optional[str], optional): The new leftward value
|
|
558
|
+
rightward_value (Optional[str], optional): The new rightward value
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Dict[str, Any]: The updated relationship dictionary
|
|
562
|
+
"""
|
|
563
|
+
current = self.get_relationship(relationship_id)
|
|
564
|
+
body = {key: current.get(key) for key in ("leftPlace", "rightPlace", "leftwardValue", "rightwardValue")}
|
|
565
|
+
for key, value in (("leftPlace", left_place), ("rightPlace", right_place),
|
|
566
|
+
("leftwardValue", leftward_value), ("rightwardValue", rightward_value)):
|
|
567
|
+
if value is not _UNSET:
|
|
568
|
+
body[key] = value
|
|
569
|
+
return self._request("PUT", self._url(RELATIONSHIPS, f"/{relationship_id}"),
|
|
570
|
+
f"Update relationship {relationship_id}", headers=self._headers(), json=body).json()
|
|
571
|
+
|
|
572
|
+
def set_name_variant(self, relationship_id, side: str, value: Optional[str]) -> Dict[str, Any]:
|
|
573
|
+
"""Name variant on the left or right item (None removes it).
|
|
574
|
+
|
|
575
|
+
Parameters:
|
|
576
|
+
relationship_id (str): The ID of the relationship to update
|
|
577
|
+
side (str): Either "left" or "right" indicating which side's name variant to set
|
|
578
|
+
value (Optional[str]): The new name variant value (None removes it)
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Dict[str, Any]: The updated relationship dictionary
|
|
582
|
+
"""
|
|
583
|
+
if side not in SIDES:
|
|
584
|
+
raise ValueError("side must be 'left' or 'right'")
|
|
585
|
+
return self.update_relationship(relationship_id, **{f"{side}ward_value": value})
|
|
586
|
+
|
|
587
|
+
def replace_item(self, relationship_id, side: str, item: ItemRef) -> Dict[str, Any]:
|
|
588
|
+
"""Point the left or right end of a relationship to another item (the type stays)."""
|
|
589
|
+
if side not in SIDES:
|
|
590
|
+
raise ValueError("side must be 'left' or 'right'")
|
|
591
|
+
response = self._request("PUT", self._url(RELATIONSHIPS, f"/{relationship_id}/{side}Item"),
|
|
592
|
+
f"Replace {side} item of relationship {relationship_id}", expected=(200, 204),
|
|
593
|
+
headers=self._headers("text/uri-list"), data=self._item_uri(item))
|
|
594
|
+
return self._json_or_none(response) or self.get_relationship(relationship_id)
|
|
595
|
+
|
|
596
|
+
def delete_relationship(self, relationship_id, copy_virtual_metadata: Optional[str] = None) -> bool:
|
|
597
|
+
"""
|
|
598
|
+
Delete a relationship.
|
|
599
|
+
|
|
600
|
+
Parameters:
|
|
601
|
+
relationship_id (str): The ID of the relationship to delete
|
|
602
|
+
copy_virtual_metadata (Optional[str], optional): Keeps the related values as real metadata: "all", "left", "right" or "configured"
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
bool: True if the relationship was successfully deleted
|
|
606
|
+
"""
|
|
607
|
+
if copy_virtual_metadata is not None and copy_virtual_metadata not in COPY_VIRTUAL_METADATA:
|
|
608
|
+
raise ValueError(f"copy_virtual_metadata must be one of {COPY_VIRTUAL_METADATA}")
|
|
609
|
+
params = {"copyVirtualMetadata": copy_virtual_metadata} if copy_virtual_metadata else None
|
|
610
|
+
self._request("DELETE", self._url(RELATIONSHIPS, f"/{relationship_id}"),
|
|
611
|
+
f"Delete relationship {relationship_id}", expected=(204,), params=params)
|
|
612
|
+
logger.info("Deleted relationship %s", relationship_id)
|
|
613
|
+
return True
|
|
614
|
+
|
|
615
|
+
def unrelate(self, focus: ItemRef, label: str, related: ItemRef, copy_virtual_metadata: Optional[str] = None,
|
|
616
|
+
type_id=None) -> int:
|
|
617
|
+
"""Delete the relationships of the label between the two items; returns how many.
|
|
618
|
+
|
|
619
|
+
Parameters:
|
|
620
|
+
focus (ItemRef): The focus item of the relationship
|
|
621
|
+
label (str): The relationship label to use
|
|
622
|
+
related (ItemRef): The related item
|
|
623
|
+
copy_virtual_metadata (Optional[str], optional): Keeps the related values as real metadata: "all", "left", "right" or "configured"
|
|
624
|
+
type_id (Optional[str], optional): The ID of the relationship type to use
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
int: The number of relationships deleted
|
|
628
|
+
"""
|
|
629
|
+
found = self.find_relationships(focus, label, related, type_id)
|
|
630
|
+
for relationship in found:
|
|
631
|
+
self.delete_relationship(relationship["id"], copy_virtual_metadata)
|
|
632
|
+
return len(found)
|
|
633
|
+
|
|
634
|
+
def reorder(self, item: ItemRef, label: str, ordered: Iterable[ItemRef]) -> List[Dict[str, Any]]:
|
|
635
|
+
"""
|
|
636
|
+
Put the related items of `item` under `label` into this order (e.g.
|
|
637
|
+
author order). Items not listed keep their relative order after the
|
|
638
|
+
listed ones. DSpace shifts the other places when one place changes,
|
|
639
|
+
so places are set one by one from the front.
|
|
640
|
+
|
|
641
|
+
Parameters:
|
|
642
|
+
item (ItemRef): The focus item whose related items are to be reordered
|
|
643
|
+
label (str): The relationship label to use
|
|
644
|
+
ordered (Iterable[ItemRef]): The desired order of related items
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
List[Dict[str, Any]]: The updated list of related items under the given label
|
|
648
|
+
"""
|
|
649
|
+
current = self.related_items(item, label)
|
|
650
|
+
by_item = {r["item_uuid"]: r for r in current}
|
|
651
|
+
wanted = [self._item_uuid(o) for o in ordered]
|
|
652
|
+
unknown = [u for u in wanted if u not in by_item]
|
|
653
|
+
if unknown:
|
|
654
|
+
raise ValueError(f"Not related to {self._item_uuid(item)} under {label!r}: {unknown}")
|
|
655
|
+
uuid = self._item_uuid(item)
|
|
656
|
+
# keep the server's numbering (0- or 1-based): start at the lowest current place
|
|
657
|
+
base = min((r["place"] for r in current if r["place"] is not None), default=0)
|
|
658
|
+
for place, related_uuid in enumerate(wanted, start=base):
|
|
659
|
+
entry = by_item[related_uuid]
|
|
660
|
+
if entry["place"] == place:
|
|
661
|
+
continue
|
|
662
|
+
relationship = self.get_relationship(entry["relationship_id"])
|
|
663
|
+
left, _right = self.ends(relationship)
|
|
664
|
+
key = "left_place" if left == uuid else "right_place"
|
|
665
|
+
self.update_relationship(entry["relationship_id"], **{key: place})
|
|
666
|
+
current = self.related_items(item, label)
|
|
667
|
+
by_item = {r["item_uuid"]: r for r in current}
|
|
668
|
+
return current
|
|
669
|
+
|
|
670
|
+
# ------------------------------------------------------------------
|
|
671
|
+
# Housekeeping
|
|
672
|
+
# ------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
def delete_item_relationships(self, item: ItemRef, copy_virtual_metadata: Optional[str] = None,
|
|
675
|
+
label: Optional[str] = None) -> int:
|
|
676
|
+
"""Delete every relationship of an item (optionally of one label); returns how many.
|
|
677
|
+
|
|
678
|
+
Parameters:
|
|
679
|
+
item (ItemRef): The item whose relationships are to be deleted
|
|
680
|
+
copy_virtual_metadata (Optional[str], optional): Keeps the related values as real metadata: "all", "left", "right" or "configured"
|
|
681
|
+
label (Optional[str], optional): The relationship label to filter by
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
int: The number of relationships deleted
|
|
685
|
+
"""
|
|
686
|
+
relationships = self.search_by_label(label, dso=item) if label else list(self.iter_item_relationships(item))
|
|
687
|
+
deleted = 0
|
|
688
|
+
for relationship in relationships:
|
|
689
|
+
try:
|
|
690
|
+
self.delete_relationship(relationship["id"], copy_virtual_metadata)
|
|
691
|
+
deleted += 1
|
|
692
|
+
except DSpaceError as exc:
|
|
693
|
+
logger.warning("Could not delete relationship %s: %s", relationship.get("id"), exc)
|
|
694
|
+
return deleted
|