dspace-api-toolkit 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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