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,217 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Features shared by communities and collections ("containers"): logo,
4
+ parent community, metadata PATCH and the *Authorized search methods.
5
+
6
+ GET /core/{communities|collections}/{uuid}/logo 204 if none
7
+ POST /core/{communities|collections}/{uuid}/logo multipart, only when there is no logo
8
+ DELETE /core/bitstreams/{logo uuid} removes the logo (both types)
9
+ GET /core/{communities|collections}/{uuid}/parentCommunity 204 for top-level communities
10
+ PATCH /core/{communities|collections}/{uuid} metadata (MetadataPatch)
11
+ GET /core/{communities|collections}/search/findAdminAuthorized?query=
12
+ GET /core/{communities|collections}/search/findEditAuthorized?query=
13
+ """
14
+
15
+ import hashlib
16
+ import logging
17
+ import mimetypes
18
+ from typing import Any, Dict, Iterable, List, Optional, Union
19
+
20
+ from .metadata import MetadataPatch, as_operations
21
+ from .projection import EmbedArg
22
+
23
+ # Logging setup for the module
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # API paths for DSpace containers and bitstreams
27
+ BITSTREAMS = "/core/bitstreams"
28
+ COMMUNITIES = "/core/communities"
29
+
30
+ # Timeout for logo upload requests (in seconds)
31
+ LOGO_TIMEOUT = 120
32
+
33
+
34
+ class ContainerMixin:
35
+ """Mixin providing shared functionality for community and collection managers.
36
+
37
+ Includes methods for handling logos, parent communities, and metadata PATCH operations.
38
+ """
39
+
40
+ # Container-specific constants
41
+ _CONTAINER_PATH = "" # "/core/communities" or "/core/collections"
42
+ _CONTAINER_KEY = "" # HAL key of search results: "communities" / "collections"
43
+
44
+ def _container_url(self, uuid: str, suffix: str = "") -> str:
45
+ """Construct the URL for a container resource, optionally with a suffix.
46
+
47
+ Parameters:
48
+ uuid: The UUID of the container.
49
+ suffix: Optional suffix to append to the URL.
50
+
51
+ Returns:
52
+ The full URL for the container resource as a string.
53
+ """
54
+ return self._url(self._CONTAINER_PATH, f"/{uuid}{suffix}")
55
+
56
+ # ------------------------------------------------------------------
57
+ # Logo
58
+ # ------------------------------------------------------------------
59
+
60
+ def get_logo(self, uuid: str) -> Optional[Dict[str, Any]]:
61
+ """The logo bitstream, or None.
62
+
63
+ Parameters:
64
+ uuid: The UUID of the container.
65
+
66
+ Returns:
67
+ The logo bitstream as a dictionary, or None if there is no logo.
68
+ """
69
+ return self._get_optional(self._container_url(uuid, "/logo"), f"Get logo of {uuid}")
70
+
71
+ def set_logo(self, uuid: str, source: Any, name: Optional[str] = None,
72
+ mime_type: Optional[str] = None) -> Dict[str, Any]:
73
+ """Upload a logo for the container, replacing any existing one.
74
+
75
+ Parameters:
76
+ uuid: The UUID of the container.
77
+ source: The source of the logo (file path, bytes, or file-like object).
78
+ name: Optional name of the file.
79
+ mime_type: Optional MIME type of the file.
80
+
81
+ Returns:
82
+ The new logo bitstream as a dictionary.
83
+ """
84
+ from .bundles import BundleManager
85
+ name, content = BundleManager._read_source(source, name)
86
+ mime_type = mime_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
87
+ if self.delete_logo(uuid):
88
+ logger.info("Replaced the existing logo of %s", uuid)
89
+ logo = self._request("POST", self._container_url(uuid, "/logo"), f"Upload logo of {uuid}",
90
+ expected=(200, 201), headers=self._headers(content_type=None),
91
+ files={"file": (name, content, mime_type)}, timeout=LOGO_TIMEOUT).json()
92
+ remote_md5 = ((logo.get("checkSum") or {}).get("value") or "").lower()
93
+ if remote_md5 and remote_md5 != hashlib.md5(content).hexdigest():
94
+ logger.warning("Logo of %s: server MD5 %s differs from the local file", uuid, remote_md5)
95
+ return logo
96
+
97
+ def delete_logo(self, uuid: str) -> bool:
98
+ """Remove the logo of the container.
99
+
100
+ Parameters:
101
+ uuid: The UUID of the container.
102
+
103
+ Returns:
104
+ True if a logo was deleted, False if there was none.
105
+ """
106
+ logo = self.get_logo(uuid)
107
+ if not logo:
108
+ return False
109
+ self._request("DELETE", self._url(BITSTREAMS, f"/{logo['uuid']}"), f"Delete logo of {uuid}",
110
+ expected=(204,))
111
+ return True
112
+
113
+ # ------------------------------------------------------------------
114
+ # Hierarchy
115
+ # ------------------------------------------------------------------
116
+
117
+ def get_parent_community(self, uuid: str, embed: EmbedArg = None) -> Optional[Dict[str, Any]]:
118
+ """Retrieve the (first) parent community of the container.
119
+
120
+ Parameters:
121
+ uuid: The UUID of the container.
122
+ embed: Optional HAL embed parameter.
123
+
124
+ Returns:
125
+ The parent community as a dictionary, or None for top-level communities.
126
+ """
127
+ return self._get_optional(self._container_url(uuid, "/parentCommunity"), f"Get parent community of {uuid}",
128
+ embed=embed)
129
+
130
+ def get_ancestors(self, uuid: str, max_depth: int = 50) -> List[Dict[str, Any]]:
131
+ """Retrieve the ancestor communities of the container, from the top-level one down to the direct parent.
132
+
133
+ Parameters:
134
+ uuid: The UUID of the container.
135
+ max_depth: Maximum number of ancestor levels to retrieve.
136
+
137
+ Returns:
138
+ A list of ancestor communities as dictionaries, ordered from top-level to direct parent.
139
+ """
140
+ trail: List[Dict[str, Any]] = []
141
+ parent = self._get_optional(self._container_url(uuid, "/parentCommunity"), f"Get parent of {uuid}")
142
+ while parent and len(trail) < max_depth:
143
+ trail.insert(0, parent)
144
+ parent = self._get_optional(self._url(COMMUNITIES, f"/{parent['uuid']}/parentCommunity"),
145
+ f"Get parent of {parent['uuid']}")
146
+ return trail
147
+
148
+ # ------------------------------------------------------------------
149
+ # Metadata
150
+ # ------------------------------------------------------------------
151
+
152
+ def patch_metadata(self, uuid: str, patch: Union[MetadataPatch, Iterable[Dict[str, Any]]]) -> Dict[str, Any]:
153
+ """Apply a MetadataPatch (or raw operations) to the container's metadata.
154
+
155
+ Parameters:
156
+ uuid: The UUID of the container.
157
+ patch: A MetadataPatch object or an iterable of raw JSON Patch operations.
158
+
159
+ Returns:
160
+ The updated container as a dictionary.
161
+ """
162
+ ops = as_operations(patch)
163
+ if not ops:
164
+ raise ValueError("Empty metadata patch")
165
+ return self._request("PATCH", self._container_url(uuid), f"Patch metadata of {uuid}",
166
+ headers=self._headers(), json=ops).json()
167
+
168
+ # ------------------------------------------------------------------
169
+ # Authorized searches
170
+ # ------------------------------------------------------------------
171
+
172
+ def _authorized_search(self, method: str, query: Optional[str], extra=(), max_results: Optional[int] = None,
173
+ embed: EmbedArg = None) -> List[Dict[str, Any]]:
174
+ """Perform an authorized search for containers based on the given method and query.
175
+
176
+ Parameters:
177
+ method: The search method to use (e.g., 'findAdminAuthorized').
178
+ query: Optional search query string.
179
+ extra: Additional query parameters as a list of key-value pairs.
180
+ max_results: Maximum number of results to return.
181
+ embed: Optional HAL embed parameter.
182
+
183
+ Returns:
184
+ A list of containers matching the search criteria.
185
+ """
186
+ params = list(extra) + ([("query", query)] if query else [])
187
+ url = self._url(self._CONTAINER_PATH, f"/search/{method}")
188
+ return list(self._iter_embedded(url, self._CONTAINER_KEY, method, params, max_results=max_results,
189
+ embed=embed))
190
+
191
+ def find_admin_authorized(self, query: Optional[str] = None, max_results: Optional[int] = None,
192
+ embed: EmbedArg = None) -> List[Dict[str, Any]]:
193
+ """Retrieve containers that the current user administers.
194
+
195
+ Parameters:
196
+ query: Optional search query string.
197
+ max_results: Maximum number of results to return.
198
+ embed: Optional HAL embed parameter.
199
+
200
+ Returns:
201
+ A list of containers administered by the current user.
202
+ """
203
+ return self._authorized_search("findAdminAuthorized", query, max_results=max_results, embed=embed)
204
+
205
+ def find_edit_authorized(self, query: Optional[str] = None, max_results: Optional[int] = None,
206
+ embed: EmbedArg = None) -> List[Dict[str, Any]]:
207
+ """Retrieve containers that the current user may edit (WRITE).
208
+
209
+ Parameters:
210
+ query: Optional search query string.
211
+ max_results: Maximum number of results to return.
212
+ embed: Optional HAL embed parameter.
213
+
214
+ Returns:
215
+ A list of containers the current user may edit.
216
+ """
217
+ return self._authorized_search("findEditAuthorized", query, max_results=max_results, embed=embed)
dspace_api/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Single source of the package version (read by pyproject.toml and the release workflow)."""
3
+
4
+ __version__ = "0.1.1"
dspace_api/auth.py ADDED
@@ -0,0 +1,467 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Authentication and session management for DSpace API
4
+ """
5
+
6
+ import base64
7
+ import datetime
8
+ import json
9
+ import logging
10
+ import threading
11
+ import time
12
+ from contextlib import nullcontext
13
+
14
+ import requests
15
+
16
+ from .config import DSpaceConfig, Endpoints
17
+ from .exceptions import AuthenticationError, SessionError, handle_response_error
18
+ from .session import DSpaceSession
19
+
20
+ # Logging setup for the module
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def jwt_expiry(authorization):
25
+ """
26
+ Expiry (unix time) of a 'Bearer <jwt>' header value, or None if it is
27
+ not a JWT or carries no 'exp'. The signature is not verified - this is
28
+ only used to decide when to refresh our own token.
29
+
30
+ Parameters:
31
+ authorization: The 'Authorization' header value (e.g., 'Bearer <jwt>').
32
+
33
+ Returns:
34
+ The expiry time as a float (unix time) or None if not available.
35
+ """
36
+ if not authorization:
37
+ return None
38
+ token = authorization.split(" ", 1)[1] if " " in authorization else authorization
39
+ parts = token.split(".")
40
+ if len(parts) != 3:
41
+ return None
42
+ try:
43
+ payload = parts[1] + "=" * (-len(parts[1]) % 4)
44
+ exp = json.loads(base64.urlsafe_b64decode(payload)).get("exp")
45
+ return float(exp) if exp is not None else None
46
+ except (ValueError, TypeError, AttributeError):
47
+ return None
48
+
49
+
50
+ class DSpaceAuth:
51
+ """Handles authentication and session management for DSpace API"""
52
+
53
+ def __init__(self, config: DSpaceConfig):
54
+ """Initialize the DSpaceAuth instance with the given configuration.
55
+
56
+ Parameters:
57
+ config: The DSpaceConfig instance containing API configuration.
58
+ """
59
+ self.config = config
60
+ self.session = self._new_session()
61
+ self._authenticated = False
62
+ # Kept in memory only, to log in again when the token expires on long runs
63
+ self._username = None
64
+ self._password = None
65
+ self._token_expires_at = None
66
+ self._reauth_lock = threading.RLock()
67
+
68
+ def _new_session(self):
69
+ """Create a new DSpace session.
70
+
71
+ Returns:
72
+ DSpaceSession: A new session instance.
73
+ """
74
+ return DSpaceSession(self)
75
+
76
+ def _internal(self):
77
+ """Scope for the auth's own requests (no refresh / re-login inside them).
78
+
79
+ Returns:
80
+ Context manager for internal requests.
81
+ """
82
+ if isinstance(self.session, DSpaceSession):
83
+ return self.session.internal()
84
+ return nullcontext()
85
+
86
+ @property
87
+ def token_expires_at(self):
88
+ """Unix time at which the current login token expires (None if unknown).
89
+
90
+ Returns:
91
+ float: The expiry time as a float (unix time) or None if not available.
92
+ """
93
+ return self._token_expires_at
94
+
95
+ def _set_authorization(self, value):
96
+ """Set the authorization header for the session and update the token expiry.
97
+
98
+ Parameters:
99
+ value: The 'Authorization' header value (e.g., 'Bearer <jwt>').
100
+ """
101
+ self.session.headers.update({"Authorization": value})
102
+ self._token_expires_at = jwt_expiry(value)
103
+ if self._token_expires_at:
104
+ logger.debug("Login token valid until %s",
105
+ datetime.datetime.fromtimestamp(self._token_expires_at).isoformat())
106
+
107
+ @property
108
+ def is_authenticated(self):
109
+ """Check if the session is currently authenticated.
110
+
111
+ Returns:
112
+ bool: True if authenticated, False otherwise.
113
+ """
114
+ return self._authenticated
115
+
116
+ def create_session(self, username=None, password=None):
117
+ """
118
+ Create and authenticate a session
119
+
120
+ Args:
121
+ username: DSpace username
122
+ password: DSpace password
123
+
124
+ Returns:
125
+ requests.Session: Authenticated session
126
+ """
127
+ if username and password:
128
+ self.login(username, password)
129
+ else:
130
+ self._setup_csrf_token()
131
+
132
+ return self.session
133
+
134
+ def check_status(self):
135
+ """
136
+ Check the authentication status of the current session
137
+
138
+ Returns:
139
+ bool: True if authenticated, False otherwise
140
+ """
141
+ logger.info("Checking authentication status")
142
+
143
+ status_url = self.config.dspace_url + Endpoints.AUTHN_STATUS
144
+
145
+ try:
146
+ with self._internal():
147
+ response = self.session.get(status_url, timeout=self.config.timeout)
148
+ self._update_token(response)
149
+
150
+ if response.status_code == 200:
151
+ data = response.json()
152
+ authenticated = data.get("authenticated", False)
153
+
154
+ if authenticated:
155
+ logger.info("User is authenticated")
156
+ self._authenticated = True
157
+ return True
158
+ else:
159
+ logger.info("User is not authenticated")
160
+ self._setup_csrf_token()
161
+ return False
162
+ else:
163
+ handle_response_error(response, "Status check")
164
+
165
+ except requests.exceptions.RequestException as e:
166
+ raise SessionError(f"Failed to check session status: {e}")
167
+
168
+ def _setup_csrf_token(self):
169
+ """
170
+ Setup CSRF token for the session
171
+
172
+ Parameters:
173
+ None
174
+
175
+ Returns:
176
+ None
177
+ """
178
+ csrf_url = self.config.dspace_url + Endpoints.CSRF
179
+
180
+ try:
181
+ with self._internal():
182
+ response = self.session.get(csrf_url, timeout=self.config.timeout)
183
+ logger.debug("CSRF status code: %s", response.status_code)
184
+
185
+ if "DSPACE-XSRF-TOKEN" in response.headers:
186
+ token = response.headers["DSPACE-XSRF-TOKEN"]
187
+ logger.debug("Setting XSRF token: %s...", token[:6])
188
+ self.session.headers.update({"X-XSRF-Token": token})
189
+ self.session.cookies.update({"X-XSRF-Token": token})
190
+ else:
191
+ logger.warning("No XSRF token found in response headers")
192
+
193
+ except requests.exceptions.RequestException as e:
194
+ raise SessionError(f"Failed to setup CSRF token: {e}")
195
+
196
+ def login(self, username, password):
197
+ """
198
+ Authenticate with DSpace
199
+
200
+ Args:
201
+ username: DSpace username
202
+ password: DSpace password
203
+
204
+ Raises:
205
+ AuthenticationError: If login fails
206
+ """
207
+ if not username or not password:
208
+ raise AuthenticationError("Username and password are required")
209
+
210
+ # First check status to get CSRF token if needed
211
+ self.check_status()
212
+
213
+ login_url = self.config.dspace_url + Endpoints.AUTHN_LOGIN
214
+ login_data = {"user": username, "password": password}
215
+
216
+ try:
217
+ with self._internal():
218
+ response = self.session.post(
219
+ login_url,
220
+ data=login_data,
221
+ timeout=self.config.timeout
222
+ )
223
+
224
+ self._update_token(response)
225
+
226
+ logger.info("Login attempt status: %s", response.status_code)
227
+ logger.info("Login time: %s", datetime.datetime.now())
228
+
229
+ if response.status_code == 200:
230
+ logger.info("Login successful")
231
+ self._authenticated = True
232
+ if getattr(self.config, "reauthenticate", True):
233
+ self._username, self._password = username, password
234
+
235
+ # Update session with authorization header if present
236
+ if "Authorization" in response.headers:
237
+ self._set_authorization(response.headers.get("Authorization"))
238
+ else:
239
+ self._handle_login_error(response)
240
+
241
+ except requests.exceptions.RequestException as e:
242
+ raise AuthenticationError(f"Login request failed: {e}")
243
+
244
+ def _handle_login_error(self, response):
245
+ """Handle login error responses
246
+
247
+ Parameters:
248
+ response: The HTTP response object from the login attempt.
249
+
250
+ Raises:
251
+ AuthenticationError: If the login failed due to invalid credentials or forbidden access.
252
+ """
253
+ if response.status_code == 401:
254
+ raise AuthenticationError("Invalid credentials")
255
+ elif response.status_code == 403:
256
+ try:
257
+ error_data = response.json()
258
+ error_msg = error_data.get('message', 'Access forbidden')
259
+ raise AuthenticationError(f"Forbidden: {error_msg}")
260
+ except json.JSONDecodeError:
261
+ raise AuthenticationError("Access forbidden")
262
+ else:
263
+ handle_response_error(response, "Login")
264
+
265
+ def _update_token(self, response):
266
+ """Update XSRF token if present in response headers
267
+
268
+ Parameters:
269
+ response: The HTTP response object from which to extract the XSRF token.
270
+
271
+ Returns:
272
+ None
273
+ """
274
+ if "DSPACE-XSRF-TOKEN" in response.headers:
275
+ token = response.headers["DSPACE-XSRF-TOKEN"]
276
+ logger.debug("Updating XSRF token: %s...", token[:6])
277
+ self.session.headers.update({"X-XSRF-Token": token})
278
+ self.session.cookies.update({"X-XSRF-Token": token})
279
+
280
+ def logout(self):
281
+ """
282
+ Logout and clean up the session
283
+
284
+ Parameters:
285
+ None
286
+
287
+ Returns:
288
+ None
289
+ """
290
+ if not self._authenticated:
291
+ logger.info("Session not authenticated, skipping logout")
292
+ return
293
+
294
+ logout_url = self.config.dspace_url + Endpoints.AUTHN_LOGOUT
295
+
296
+ try:
297
+ with self._internal():
298
+ response = self.session.post(logout_url, timeout=self.config.timeout)
299
+
300
+ logger.info("Logout status: %s", response.status_code)
301
+ logger.info("Logout time: %s", datetime.datetime.now())
302
+
303
+ if response.status_code == 204:
304
+ logger.info("Logout successful")
305
+ self._cleanup_session()
306
+ else:
307
+ logger.warning("Logout returned status %s", response.status_code)
308
+ self._cleanup_session() # Clean up anyway
309
+
310
+ except requests.exceptions.RequestException as e:
311
+ logger.error("Logout request failed: %s", e)
312
+ self._cleanup_session() # Clean up anyway
313
+
314
+ def _cleanup_session(self):
315
+ """Clean up session headers and close session
316
+
317
+ Parameters:
318
+ None
319
+
320
+ Returns:
321
+ None
322
+ """
323
+ # Remove auth-related headers
324
+ headers_to_remove = ["Authorization", "X-XSRF-Token"]
325
+ for header in headers_to_remove:
326
+ self.session.headers.pop(header, None)
327
+
328
+ # Remove auth-related cookies
329
+ cookies_to_remove = ["X-XSRF-Token"]
330
+ for cookie in cookies_to_remove:
331
+ self.session.cookies.pop(cookie, None)
332
+
333
+ self._authenticated = False
334
+ self._username = self._password = None
335
+ self._token_expires_at = None
336
+ self.session.close()
337
+
338
+ # Create new session for potential reuse
339
+ self.session = self._new_session()
340
+
341
+ # ------------------------------------------------------------------
342
+ # Keeping the login alive on long runs
343
+ # ------------------------------------------------------------------
344
+
345
+ def can_reauthenticate(self):
346
+ """Check if the session can reauthenticate.
347
+
348
+ Returns:
349
+ bool: True if reauthentication is possible, False otherwise.
350
+ """
351
+ return bool(self._username and self._password and getattr(self.config, "reauthenticate", True))
352
+
353
+ def ensure_fresh_token(self):
354
+ """Refresh the token if it expires within config.token_refresh_margin seconds.
355
+
356
+ Parameters:
357
+ None
358
+
359
+ Returns:
360
+ None
361
+ """
362
+ expires = self._token_expires_at
363
+ if not self._authenticated or expires is None:
364
+ return
365
+ margin = getattr(self.config, "token_refresh_margin", 60)
366
+ if time.time() < expires - margin:
367
+ return
368
+ with self._reauth_lock:
369
+ # another thread may have refreshed while we waited for the lock
370
+ if self._token_expires_at is not None and time.time() < self._token_expires_at - margin:
371
+ return
372
+ self.refresh_token()
373
+
374
+ def refresh_token(self):
375
+ """
376
+ Get a new token. DSpace issues one for a POST to /authn/login that
377
+ carries a still-valid token; if that fails, log in again.
378
+
379
+ Parameters:
380
+ None
381
+
382
+ Returns:
383
+ bool: True if the token was successfully refreshed or reauthenticated, False otherwise.
384
+ """
385
+ login_url = self.config.dspace_url + Endpoints.AUTHN_LOGIN
386
+ try:
387
+ with self._internal():
388
+ response = self.session.post(login_url, timeout=self.config.timeout)
389
+ self._update_token(response)
390
+ if response.status_code == 200 and "Authorization" in response.headers:
391
+ self._set_authorization(response.headers["Authorization"])
392
+ logger.info("Login token refreshed")
393
+ return True
394
+ logger.info("Token refresh returned %s - logging in again", response.status_code)
395
+ except requests.exceptions.RequestException as e:
396
+ logger.warning("Token refresh failed (%s) - logging in again", e)
397
+ return self.reauthenticate()
398
+
399
+ # ------------------------------------------------------------------
400
+ # DSpace-CRIS end-user agreement
401
+ # ------------------------------------------------------------------
402
+
403
+ # DSpace-CRIS end-user agreement field in the eperson metadata
404
+ AGREEMENT_FIELD = "dspace.agreements.end-user"
405
+
406
+ def accept_user_agreement(self):
407
+ """
408
+ Accept the DSpace-CRIS end-user agreement for the logged-in user and
409
+ get a new token (the server reads the flag from a token claim).
410
+ Returns True if the agreement is accepted afterwards.
411
+
412
+ Parameters:
413
+ None
414
+
415
+ Returns:
416
+ bool: True if the agreement is accepted successfully, False otherwise.
417
+ """
418
+ with self._reauth_lock:
419
+ try:
420
+ with self._internal():
421
+ status = self.session.get(self.config.dspace_url + Endpoints.AUTHN_STATUS,
422
+ params={"embed": "eperson"}, timeout=self.config.timeout)
423
+ self._update_token(status)
424
+ person = ((status.json() if status.status_code == 200 else {}).get("_embedded") or {}
425
+ ).get("eperson") or {}
426
+ if not person.get("uuid"):
427
+ logger.warning("Cannot accept the user agreement: no logged-in eperson")
428
+ return False
429
+ values = (person.get("metadata") or {}).get(self.AGREEMENT_FIELD) or []
430
+ if not any(str(v.get("value")).lower() == "true" for v in values):
431
+ operation = {"op": "replace" if values else "add",
432
+ "path": f"/metadata/{self.AGREEMENT_FIELD}",
433
+ "value": [{"value": "true"}]}
434
+ patch = self.session.patch(
435
+ f"{self.config.dspace_url}{Endpoints.EPERSONS}/{person['uuid']}",
436
+ json=[operation], headers=self.config.headers, timeout=self.config.timeout)
437
+ self._update_token(patch)
438
+ if patch.status_code != 200:
439
+ logger.warning("Accepting the user agreement failed: HTTP %s %s",
440
+ patch.status_code, patch.text[:200])
441
+ return False
442
+ logger.info("End-user agreement accepted for %s", person.get("email") or person["uuid"])
443
+ except (requests.exceptions.RequestException, ValueError) as e:
444
+ logger.warning("Accepting the user agreement failed: %s", e)
445
+ return False
446
+ # new token -> new userAgreementAccepted claim
447
+ return self.refresh_token()
448
+
449
+ def reauthenticate(self):
450
+ """Log in again with the stored credentials (used after expiry or a 401).
451
+
452
+ Parameters:
453
+ None
454
+
455
+ Returns:
456
+ bool: True if reauthentication is successful, False otherwise.
457
+ """
458
+ if not self.can_reauthenticate():
459
+ raise AuthenticationError("Session expired and no stored credentials to log in again")
460
+ with self._reauth_lock:
461
+ # Drop the dead token, otherwise /authn/status itself may answer 401
462
+ self.session.headers.pop("Authorization", None)
463
+ self._token_expires_at = None
464
+ self._authenticated = False
465
+ self.login(self._username, self._password)
466
+ logger.info("Logged in again")
467
+ return True