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/session.py ADDED
@@ -0,0 +1,538 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Resilient HTTP session for the DSpace API toolkit.
4
+
5
+ DSpaceAuth creates a DSpaceSession; every manager uses it through
6
+ ``auth.session`` - including the older managers that call ``session.get()``
7
+ directly - so all requests get the same behaviour:
8
+
9
+ - Transient failures are retried with exponential backoff:
10
+ - HTTP 429 (any method - the server did not process the request),
11
+ honouring Retry-After
12
+ - HTTP 502/503/504 (configurable) for idempotent methods only
13
+ - connection errors and read timeouts for idempotent methods only
14
+ - connect timeouts for any method (the request never reached the server)
15
+ POST and PATCH are NOT retried after a 5xx or a broken connection,
16
+ because DSpace may already have created the bitstream / applied the patch.
17
+
18
+ - The login token (JWT) is refreshed shortly before it expires, and a 401
19
+ triggers one re-login with the stored credentials and one retry.
20
+
21
+ - A 403 that carries a fresh CSRF token is retried once (non-GET only).
22
+
23
+ - Optional projections (allLanguages, preventMetadataSecurity,
24
+ preventPointsCalculation) exist only on some servers (DSpace-CRIS,
25
+ DSpace 10). Plain DSpace 8/9 answer an unknown projection with 400/404.
26
+ On such an answer the session checks once per projection whether the
27
+ server knows it (GET /core/sites?projection=X), remembers the result,
28
+ drops unsupported ones from this and every later request and resends.
29
+
30
+ - Metadata PATCH on archived objects: DSpace uses only the FIRST value of
31
+ an array in "add /metadata/<field>" and "replace /metadata/<field>". An
32
+ operation with several values is sent as "remove" (replace only) plus
33
+ one "add /metadata/<field>/-" per value, so no value is lost silently.
34
+
35
+ - DSpace-CRIS answers every request of a user who has not accepted the
36
+ end-user agreement with 403 "No user agreement accepted". With
37
+ config.accept_user_agreement the agreement is accepted for the logged-in
38
+ user, the token is refreshed (the flag is a token claim) and the request
39
+ is sent again; otherwise the 403 surfaces as UserAgreementRequired.
40
+
41
+ Requests made by DSpaceAuth itself (login, status, CSRF, logout) run in an
42
+ "internal" scope: they are retried on transient errors but never trigger a
43
+ token refresh or re-login, which would recurse.
44
+
45
+ Bodies are re-sent on retry: pass bytes / strings / dicts (as the toolkit
46
+ does), not one-shot generators or open file objects.
47
+ """
48
+
49
+ import email.utils
50
+ import json
51
+ import logging
52
+ import random
53
+ import threading
54
+ import time
55
+ import weakref
56
+ from contextlib import contextmanager
57
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
58
+
59
+ import requests
60
+
61
+ from .config import DEFAULT_RETRY_STATUSES
62
+
63
+ # Logger for the module
64
+ logger = logging.getLogger(__name__)
65
+
66
+ # Constants for session management and DSpace API behavior
67
+
68
+ # XSRF token header used by DSpace
69
+ XSRF_RESPONSE_HEADER = "DSPACE-XSRF-TOKEN"
70
+ # HTTP methods considered idempotent by the session manager
71
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
72
+ # The server did not process the request -> safe to retry any method
73
+ ALWAYS_RETRYABLE_STATUSES = frozenset({429})
74
+
75
+ # Projections not every server has (see module docstring)
76
+ OPTIONAL_PROJECTIONS = frozenset({"allLanguages", "preventMetadataSecurity", "preventPointsCalculation"})
77
+ UNKNOWN_PROJECTION_STATUSES = frozenset({400, 404})
78
+ # Message indicating that the user has not accepted the end-user agreement
79
+ USER_AGREEMENT_MESSAGE = "No user agreement accepted"
80
+
81
+
82
+ def _projection_values(value):
83
+ """Normalize projection values into a list of names."""
84
+ values = value if isinstance(value, (list, tuple)) else [value]
85
+ names = []
86
+ for v in values:
87
+ names.extend(n.strip() for n in str(v).split(",") if n.strip())
88
+ return names
89
+
90
+
91
+ def requested_projections(url, params):
92
+ """Names of the projections in the URL query string and in `params`."""
93
+ names = [n for key, value in parse_qsl(urlsplit(url).query) if key == "projection"
94
+ for n in _projection_values(value)]
95
+ if isinstance(params, dict):
96
+ names.extend(_projection_values(params["projection"]) if params.get("projection") is not None else [])
97
+ elif isinstance(params, (list, tuple)):
98
+ for pair in params:
99
+ if isinstance(pair, (list, tuple)) and len(pair) == 2 and pair[0] == "projection":
100
+ names.extend(_projection_values(pair[1]))
101
+ return names
102
+
103
+
104
+ def _keep(value, drop):
105
+ """Keep only the projection values not in `drop`."""
106
+ kept = [n for n in _projection_values(value) if n not in drop]
107
+ return ",".join(kept) if kept else None
108
+
109
+
110
+ def strip_projections(url, params, drop):
111
+ """(url, params) without the projections in `drop` (same params shape)."""
112
+ parts = urlsplit(url)
113
+ if parts.query:
114
+ query = []
115
+ for key, value in parse_qsl(parts.query, keep_blank_values=True):
116
+ if key == "projection":
117
+ value = _keep(value, drop)
118
+ if value is None:
119
+ continue
120
+ query.append((key, value))
121
+ url = urlunsplit(parts._replace(query=urlencode(query)))
122
+ if isinstance(params, dict):
123
+ params = dict(params)
124
+ if params.get("projection") is not None:
125
+ values = params["projection"] if isinstance(params["projection"], (list, tuple)) else [params["projection"]]
126
+ kept = [k for k in (_keep(v, drop) for v in values) if k is not None]
127
+ if kept:
128
+ params["projection"] = kept if len(kept) > 1 else kept[0]
129
+ else:
130
+ del params["projection"]
131
+ elif isinstance(params, (list, tuple)):
132
+ cleaned = []
133
+ for pair in params:
134
+ if isinstance(pair, (list, tuple)) and len(pair) == 2 and pair[0] == "projection":
135
+ value = _keep(pair[1], drop)
136
+ if value is None:
137
+ continue
138
+ pair = (pair[0], value)
139
+ cleaned.append(pair)
140
+ params = cleaned
141
+ return url, params
142
+
143
+
144
+ def _is_value_list(value):
145
+ """Check if the value is a list with more than one element."""
146
+ return isinstance(value, list) and len(value) > 1
147
+
148
+
149
+ def expand_metadata_operations(operations):
150
+ """
151
+ Rewrite multi-value 'add'/'replace' on /metadata/<field> into operations
152
+ DSpace applies completely (see module docstring). Other operations, and
153
+ single values, are returned unchanged. Returns None if nothing changed.
154
+
155
+ Parameters:
156
+ operations: List of metadata operations to expand
157
+
158
+ Returns:
159
+ list: Expanded list of operations, or None if no changes were made
160
+ """
161
+ if not isinstance(operations, list):
162
+ return None
163
+ result, changed = [], False
164
+ for op in operations:
165
+ path = op.get("path") if isinstance(op, dict) else None
166
+ if (isinstance(path, str) and path.startswith("/metadata/") and path.count("/") == 2
167
+ and op.get("op") in ("add", "replace") and _is_value_list(op.get("value"))):
168
+ if op["op"] == "replace":
169
+ result.append({"op": "remove", "path": path})
170
+ result.extend({"op": "add", "path": f"{path}/-", "value": value} for value in op["value"])
171
+ changed = True
172
+ else:
173
+ result.append(op)
174
+ return result if changed else None
175
+
176
+
177
+ def is_user_agreement_response(response):
178
+ """
179
+ Check if the response indicates that the user has not accepted the end-user agreement.
180
+
181
+ Parameters:
182
+ response: The HTTP response object to check
183
+
184
+ Returns:
185
+ bool: True if the response indicates a missing user agreement, False otherwise
186
+ """
187
+ if response.status_code != 403:
188
+ return False
189
+ try:
190
+ return USER_AGREEMENT_MESSAGE in (response.text or "")
191
+ except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive
192
+ return False
193
+
194
+
195
+ def parse_retry_after(value, now=None):
196
+ """Seconds to wait from a Retry-After header (delta-seconds or HTTP date), or None.
197
+
198
+ Parameters:
199
+ value: The value of the Retry-After header
200
+ now: Current time as a timestamp, used for calculating the wait time
201
+
202
+ Returns:
203
+ float: Number of seconds to wait, or None if the header is invalid
204
+ """
205
+ if not value:
206
+ return None
207
+ value = value.strip()
208
+ if value.isdigit():
209
+ return float(value)
210
+ try:
211
+ when = email.utils.parsedate_to_datetime(value)
212
+ except (TypeError, ValueError, IndexError):
213
+ return None
214
+ if when is None: # pragma: no cover - older Pythons return None instead of raising
215
+ return None
216
+ now = time.time() if now is None else now
217
+ return max(0.0, when.timestamp() - now)
218
+
219
+
220
+ class _Settings:
221
+ """Retry settings used when the session has no auth/config attached."""
222
+ max_retries = 3 # Maximum number of retry attempts
223
+ retry_backoff = 1.0 # Backoff factor for retry delays
224
+ retry_max_wait = 30.0 # Maximum wait time between retries in seconds
225
+ retry_statuses = frozenset(DEFAULT_RETRY_STATUSES)
226
+
227
+
228
+ class DSpaceSession(requests.Session):
229
+ """requests.Session with retries, token refresh, re-login and CSRF retry"""
230
+
231
+ def __init__(self, auth=None):
232
+ """Initialize the DSpaceSession.
233
+
234
+ Parameters:
235
+ auth: Optional authentication object to attach to the session
236
+ """
237
+ super().__init__()
238
+ # weakref: the auth object owns the session, avoid a reference cycle
239
+ self._auth_ref = weakref.ref(auth) if auth is not None else None
240
+ self._local = threading.local()
241
+ self.sleep = time.sleep # replaceable in tests
242
+ # projection name -> True (server knows it) / False (unknown, dropped)
243
+ self.projection_support = {}
244
+ self._projection_lock = threading.Lock()
245
+
246
+ # ------------------------------------------------------------------
247
+ # Scope for DSpaceAuth's own requests
248
+ # ------------------------------------------------------------------
249
+
250
+ @property
251
+ def dspace_auth(self):
252
+ """Get the current DSpaceAuth instance attached to the session.
253
+
254
+ Returns:
255
+ DSpaceAuth or None: The authentication object if attached, None otherwise
256
+ """
257
+ return self._auth_ref() if self._auth_ref is not None else None
258
+
259
+ @contextmanager
260
+ def internal(self):
261
+ """Context manager for marking requests as internal to the DSpaceAuth session.
262
+
263
+ Yields:
264
+ DSpaceSession: The current session instance
265
+ """
266
+ self._local.depth = getattr(self._local, "depth", 0) + 1
267
+ try:
268
+ yield self
269
+ finally:
270
+ self._local.depth -= 1
271
+
272
+ def _is_internal(self):
273
+ """Check if the current request is internal to the DSpaceAuth session.
274
+
275
+ Returns:
276
+ bool: True if the request is internal, False otherwise
277
+ """
278
+ return getattr(self._local, "depth", 0) > 0
279
+
280
+ def _settings(self):
281
+ """Get the settings for the current session, falling back to default settings if necessary.
282
+
283
+ Returns:
284
+ _Settings: The settings object to use for the session
285
+ """
286
+ auth = self.dspace_auth
287
+ config = getattr(auth, "config", None)
288
+ return config if config is not None and hasattr(config, "max_retries") else _Settings
289
+
290
+ # ------------------------------------------------------------------
291
+ # Request pipeline
292
+ # ------------------------------------------------------------------
293
+
294
+ def request(self, method, url, *args, **kwargs):
295
+ """Send an HTTP request through the DSpace session, handling authentication, retries, and projections.
296
+
297
+ Parameters:
298
+ method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
299
+ url (str): The URL to send the request to
300
+ *args: Additional positional arguments to pass to the request
301
+ **kwargs: Additional keyword arguments to pass to the request
302
+
303
+ Returns:
304
+ Response: The HTTP response object
305
+ """
306
+ auth = self.dspace_auth
307
+ if auth is None or self._is_internal():
308
+ return self._request_with_retries(method, url, args, kwargs)
309
+
310
+ auth.ensure_fresh_token()
311
+ is_get = method.upper() == "GET"
312
+ if is_get:
313
+ url, kwargs = self._without_unsupported_projections(url, kwargs)
314
+ elif method.upper() == "PATCH":
315
+ kwargs = self._expand_metadata_patch(kwargs)
316
+ response = self._send(auth, method, url, args, kwargs)
317
+
318
+ if is_get and response.status_code in UNKNOWN_PROJECTION_STATUSES:
319
+ retry = self._drop_unknown_projections(auth, url, kwargs)
320
+ if retry is not None:
321
+ response.close()
322
+ url, kwargs = retry
323
+ response = self._send(auth, method, url, args, kwargs)
324
+
325
+ # DSpace answers a stale CSRF token with 403 + a fresh token
326
+ if (response.status_code == 403 and method.upper() != "GET"
327
+ and XSRF_RESPONSE_HEADER in response.headers):
328
+ logger.debug("Retrying %s %s with refreshed CSRF token", method, url)
329
+ response = self._send(auth, method, url, args, kwargs)
330
+
331
+ # Expired / revoked login: log in again once and repeat the request
332
+ if response.status_code == 401 and auth.can_reauthenticate():
333
+ logger.warning("%s %s returned 401 - logging in again and retrying once", method, url)
334
+ auth.reauthenticate()
335
+ response = self._send(auth, method, url, args, kwargs)
336
+
337
+ # DSpace-CRIS: the logged-in user has not accepted the end-user agreement
338
+ if (is_user_agreement_response(response) and getattr(auth.config, "accept_user_agreement", False)
339
+ and getattr(auth, "is_authenticated", False)):
340
+ logger.warning("%s %s: no user agreement accepted - accepting it for the logged-in user "
341
+ "(accept_user_agreement is on) and retrying once", method, url)
342
+ if auth.accept_user_agreement():
343
+ response.close()
344
+ response = self._send(auth, method, url, args, kwargs)
345
+
346
+ return response
347
+
348
+ # ------------------------------------------------------------------
349
+ # Optional projections and metadata PATCH
350
+ # ------------------------------------------------------------------
351
+
352
+ def _without_unsupported_projections(self, url, kwargs):
353
+ """Remove projections from the request that are known to be unsupported by the server.
354
+
355
+ Parameters:
356
+ url (str): The request URL
357
+ kwargs (dict): The request keyword arguments, including "params"
358
+
359
+ Returns:
360
+ tuple: A tuple of (url, kwargs) with unsupported projections removed
361
+ """
362
+ unsupported = {name for name, ok in self.projection_support.items() if ok is False}
363
+ if not unsupported:
364
+ return url, kwargs
365
+ params = kwargs.get("params")
366
+ if not unsupported.intersection(requested_projections(url, params)):
367
+ return url, kwargs
368
+ url, params = strip_projections(url, params, unsupported)
369
+ return url, {**kwargs, "params": params}
370
+
371
+ def _drop_unknown_projections(self, auth, url, kwargs):
372
+ """Return (url, kwargs) without projections the server turned out not to know, or None if no unknown projections were found.
373
+
374
+ Parameters:
375
+ auth: The authentication object to use for checking projection support
376
+ url (str): The request URL
377
+ kwargs (dict): The request keyword arguments, including "params"
378
+
379
+ Returns:
380
+ tuple or None: A tuple of (url, kwargs) with unknown projections removed, or None if no unknown projections were found
381
+ """
382
+ candidates = [name for name in dict.fromkeys(requested_projections(url, kwargs.get("params")))
383
+ if name in OPTIONAL_PROJECTIONS and self.projection_support.get(name) is None]
384
+ unknown = {name for name in candidates if not self.supports_projection(name, auth)}
385
+ if not unknown:
386
+ return None
387
+ url, params = strip_projections(url, kwargs.get("params"), unknown)
388
+ return url, {**kwargs, "params": params}
389
+
390
+ def supports_projection(self, name, auth=None):
391
+ """
392
+ Check if the server supports the given projection.
393
+
394
+ Whether the server knows projection `name` (checked once with
395
+ GET /core/sites?projection=name, then remembered). Unclear answers
396
+ (e.g. 5xx, network errors) count as supported and are checked again later.
397
+
398
+ Parameters:
399
+ name (str): The name of the projection to check
400
+ auth: Optional authentication object to use for the check
401
+
402
+ Returns:
403
+ bool: True if the server supports the projection, False otherwise
404
+ """
405
+ known = self.projection_support.get(name)
406
+ if known is not None:
407
+ return known
408
+ auth = auth or self.dspace_auth
409
+ base = getattr(getattr(auth, "config", None), "dspace_url", None)
410
+ if not base:
411
+ return True
412
+ with self._projection_lock:
413
+ if name in self.projection_support:
414
+ return self.projection_support[name]
415
+ try:
416
+ with self.internal():
417
+ probe = self._request_with_retries("GET", f"{base}/core/sites", (),
418
+ {"params": {"projection": name, "size": 1},
419
+ "timeout": getattr(auth.config, "timeout", 30)})
420
+ except requests.exceptions.RequestException as exc:
421
+ logger.debug("Projection check for %s failed: %s", name, exc)
422
+ return True
423
+ status = probe.status_code
424
+ probe.close()
425
+ if status == 200:
426
+ self.projection_support[name] = True
427
+ elif status in UNKNOWN_PROJECTION_STATUSES:
428
+ self.projection_support[name] = False
429
+ logger.warning("This server does not know projection=%s (DSpace-CRIS / DSpace 10 only) - "
430
+ "requests are sent without it from now on", name)
431
+ else:
432
+ return True
433
+ return self.projection_support[name]
434
+
435
+ @staticmethod
436
+ def _expand_metadata_patch(kwargs):
437
+ """Expand metadata operations in the request payload.
438
+
439
+ Parameters:
440
+ kwargs (dict): The request keyword arguments, potentially containing "json" or "data"
441
+
442
+ Returns:
443
+ dict: The modified keyword arguments with expanded metadata operations
444
+ """
445
+ if "json" in kwargs:
446
+ expanded = expand_metadata_operations(kwargs["json"])
447
+ return {**kwargs, "json": expanded} if expanded is not None else kwargs
448
+ data = kwargs.get("data")
449
+ if isinstance(data, (str, bytes)) and data[:1] in ("[", b"["):
450
+ try:
451
+ operations = json.loads(data)
452
+ except ValueError:
453
+ return kwargs
454
+ expanded = expand_metadata_operations(operations)
455
+ if expanded is not None:
456
+ return {**kwargs, "data": json.dumps(expanded)}
457
+ return kwargs
458
+
459
+ def _send(self, auth, method, url, args, kwargs):
460
+ """Send an HTTP request with authentication and handle token updates.
461
+
462
+ Parameters:
463
+ auth: The authentication object to use for the request
464
+ method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
465
+ url (str): The URL to send the request to
466
+ args: Additional positional arguments to pass to the request
467
+ kwargs: Additional keyword arguments to pass to the request
468
+
469
+ Returns:
470
+ Response: The HTTP response object
471
+ """
472
+ response = self._request_with_retries(method, url, args, kwargs)
473
+ auth._update_token(response)
474
+ return response
475
+
476
+ def _backoff(self, settings, attempt):
477
+ """Calculate the backoff time for a retry attempt.
478
+
479
+ Parameters:
480
+ settings (_Settings): The session settings containing retry configuration
481
+ attempt (int): The current retry attempt number
482
+
483
+ Returns:
484
+ float: The time to wait before the next retry, in seconds
485
+ """
486
+ base = settings.retry_backoff * (2 ** attempt)
487
+ # up to 10% jitter so parallel workers don't retry in lockstep
488
+ return min(settings.retry_max_wait, base + random.uniform(0, base * 0.1))
489
+
490
+ def _request_with_retries(self, method, url, args, kwargs):
491
+ """Send an HTTP request with retries for transient errors.
492
+
493
+ Parameters:
494
+ method (str): The HTTP method (e.g., "GET", "POST", "PATCH")
495
+ url (str): The URL to send the request to
496
+ args: Additional positional arguments to pass to the request
497
+ kwargs: Additional keyword arguments to pass to the request
498
+
499
+ Returns:
500
+ Response: The HTTP response object
501
+
502
+ Raises:
503
+ requests.exceptions.RequestException: If the request fails and is not retryable
504
+ """
505
+ settings = self._settings()
506
+ idempotent = method.upper() in IDEMPOTENT_METHODS
507
+ attempt = 0
508
+ while True:
509
+ try:
510
+ response = super().request(method, url, *args, **kwargs)
511
+ except requests.exceptions.ConnectTimeout as exc:
512
+ error, retryable = exc, True # never reached the server
513
+ except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) as exc:
514
+ error, retryable = exc, idempotent # may have been processed
515
+ else:
516
+ status = response.status_code
517
+ retryable = attempt < settings.max_retries and (
518
+ status in ALWAYS_RETRYABLE_STATUSES
519
+ or (idempotent and status in settings.retry_statuses))
520
+ if not retryable:
521
+ return response
522
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
523
+ wait = (min(settings.retry_max_wait, retry_after) if retry_after is not None
524
+ else self._backoff(settings, attempt))
525
+ logger.warning("%s %s returned %s - retry %d/%d in %.1fs",
526
+ method, url, status, attempt + 1, settings.max_retries, wait)
527
+ response.close()
528
+ self.sleep(wait)
529
+ attempt += 1
530
+ continue
531
+
532
+ if not retryable or attempt >= settings.max_retries:
533
+ raise error
534
+ wait = self._backoff(settings, attempt)
535
+ logger.warning("%s %s failed (%s) - retry %d/%d in %.1fs",
536
+ method, url, type(error).__name__, attempt + 1, settings.max_retries, wait)
537
+ self.sleep(wait)
538
+ attempt += 1
dspace_api/site.py ADDED
@@ -0,0 +1,98 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Site object and Terms of Use for DSpace API
4
+
5
+ The repository-wide Terms of Use text is the Site object's dc.rights
6
+ metadata (one value per language, may contain Markdown). Whether a user
7
+ accepted it is stored on the eperson - see EPersonManager.*_end_user_agreement.
8
+
9
+ GET /core/sites?projection=allLanguages
10
+ PATCH /core/sites/{uuid} metadata (admin)
11
+ """
12
+
13
+ import logging
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from ._base import BaseManager
17
+ from .config import Endpoints
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SITES = getattr(Endpoints, "SITES", "/core/sites")
22
+ TERMS_FIELD = "dc.rights"
23
+
24
+
25
+ class SiteManager(BaseManager):
26
+ """Reads and updates the Site object, in particular the Terms of Use"""
27
+
28
+ def get_site(self, all_languages: Optional[bool] = True) -> Optional[Dict[str, Any]]:
29
+ """
30
+ The single Site object. Defaults to all_languages=True (unlike the
31
+ other managers), because the Terms of Use are stored per language;
32
+ pass None to follow the client setting, False to switch it off.
33
+
34
+ Parameters:
35
+ all_languages: Whether to retrieve all language versions of the site metadata
36
+
37
+ Returns:
38
+ dict: The site object, or None if not found
39
+ """
40
+ data = self._get_json(self._url(SITES), "Get site", all_languages=all_languages)
41
+ sites = data.get("_embedded", {}).get("sites", [])
42
+ return sites[0] if sites else None
43
+
44
+ def get_terms_of_use_values(self) -> List[Dict[str, Any]]:
45
+ """
46
+ Get the raw Terms of Use metadata values.
47
+
48
+ Returns:
49
+ list: List of metadata values for the Terms of Use
50
+ """
51
+ site = self.get_site() or {}
52
+ return (site.get("metadata") or {}).get(TERMS_FIELD) or []
53
+
54
+ def get_terms_of_use(self, language: str = "en", fallback_language: Optional[str] = "en") -> Optional[str]:
55
+ """
56
+ Terms text in `language`, else `fallback_language`, else the first
57
+ non-empty value. None if no terms are configured.
58
+
59
+ Parameters:
60
+ language: Preferred language for the terms
61
+ fallback_language: Language to fall back to if the preferred language is not available
62
+
63
+ Returns:
64
+ str: Terms of Use text in the requested language, or None if not available
65
+ """
66
+ values = [v for v in self.get_terms_of_use_values() if (v.get("value") or "").strip()]
67
+ for lang in (language, fallback_language):
68
+ match = next((v for v in values if v.get("language") == lang), None) if lang else None
69
+ if match:
70
+ return match["value"].strip()
71
+ return values[0]["value"].strip() if values else None
72
+
73
+ def set_terms_of_use(self, text: str, language: Optional[str] = "en") -> Dict[str, Any]:
74
+ """
75
+ Create or replace the terms for one language, leaving other languages alone (admin).
76
+
77
+ Parameters:
78
+ text: The terms text to set
79
+ language: Language for the terms
80
+
81
+ Returns:
82
+ dict: The updated site object
83
+ """
84
+ site = self.get_site()
85
+ if not site:
86
+ raise ValueError("No Site object found")
87
+ values = (site.get("metadata") or {}).get(TERMS_FIELD) or []
88
+ new_value = {"value": text, "language": language}
89
+ index = next((i for i, v in enumerate(values) if v.get("language") == language), None)
90
+ if index is not None:
91
+ op = {"op": "replace", "path": f"/metadata/{TERMS_FIELD}/{index}", "value": new_value}
92
+ elif values:
93
+ op = {"op": "add", "path": f"/metadata/{TERMS_FIELD}/-", "value": new_value}
94
+ else:
95
+ op = {"op": "add", "path": f"/metadata/{TERMS_FIELD}", "value": [new_value]}
96
+ logger.info("Setting terms of use (%s) via %s", language, op["op"])
97
+ return self._request("PATCH", self._url(SITES, f"/{site['uuid']}"), "Set terms of use",
98
+ headers=self._headers(), json=[op]).json()