Skip to content

Commit c9b4872

Browse files
jr-rkclaude
andcommitted
merge: bring CLARIN branch (main) into dtq
origin/main and origin/dtq had diverged since 5724696 (2024-10-24) with no ancestry either way. main added +300/-0 lines and dtq +263/-71, both confined to dspace_rest_client/client.py and dspace_rest_client/models.py. main contributes the CLARIN/UFAL surface: License and Label models, clarinlruallowances get/create, submit-group creation, eperson-to-group membership, get_item_by_handle, get_items_from_collection, get_bundle_by_name, get_user_by_email, api_put_uri and the dict-based resource-policy helpers. dtq keeps its hardening: reauth on 401, request timeouts, proxy support, verify_response, _last_err, the ResourcePolicy model and 404-safe bundles. Conflict resolutions (3 hunks, 2 in client.py and 1 in models.py): - get_items: kept dtq's paginated get_items(self, page=0, size=20) and dropped main's no-arg variants. main still carried two duplicate `def get_items` definitions; the shadowing second one tested for 'collections' in _embedded while iterating _embedded['items']. The paginated signature is required by DSpace-ISstag-integration src/ingest/_dspace.py, which calls get_items(page=page, size=page_size). main's additive get_item_by_handle is kept alongside it. - remove_metadata: unified the two signatures into remove_metadata(self, dso, field, place=None). place=None removes every value of the field (dtq behaviour, PATCH /metadata/{field}); an explicit place removes a single value (main behaviour, PATCH /metadata/{field}/{place}). This keeps dtq's 2-arg call sites and dspace-import-clarin's remove_metadata(item, key, 0) working. main's module-level logging.error was replaced by _logger.error to match the rest of the file. - models.py: kept all three new classes - License (with to_dict), Label and ResourcePolicy (with as_dict and __repr__). The conflict was purely positional, both sides having appended at EOF. api_put_uri was added on main against the pre-hardening base, so it was harmonised with its sibling api_put: reset self._last_err on entry, pass proxies=self.proxies, and use _logger instead of module-level logging. Its public signature is unchanged - dspace-rest-test depends on it. dtq-dev is deliberately NOT merged. Its only unique commit, 145635a (2023-06-12, "copied code from dtq main"), is a snapshot of a different project: it relocates dspace_rest_client/{client,models}.py into support/dspace_interface/, deletes setup.py, example.py, example_gets.py, solr_example.py, publish.sh, CHANGELOG.md and MAINTAINING.md, and adds ~8.5k lines of import tooling, test fixtures, license icons and a localization CSV. Merging it would destroy the installable package. Both resource-policy APIs are retained side by side, since main's dict-based set (get_resource_policy, create_resource_policy, update_resource_policy_group) and dtq's model-based set (get_resourcepolicy, create_resourcepolicy) each have live consumers. Unification is left to a separate deprecation change. Verified: compileall clean, package imports, no duplicate method definitions, and the merged API surface is exactly the union of both branches (nothing from either side lost, main's duplicate get_items aside). The DSpace-ISstag-integration suite gives an identical result with and without this merge - 579 passed, 40 failed - where the 40 failures are pre-existing and caused by flask being absent from that environment, not by the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents f9ca942 + dbec151 commit c9b4872

2 files changed

Lines changed: 295 additions & 6 deletions

File tree

dspace_rest_client/client.py

Lines changed: 238 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,38 @@ def api_put(self, url, params, json, retry=False):
325325

326326
return r
327327

328+
def api_put_uri(self, url, params, uri_list, retry=False):
329+
"""
330+
Perform a PUT request. Refresh XSRF token if necessary.
331+
PUTs are typically used to update objects.
332+
@param url: DSpace REST API URL
333+
@param params: Any parameters to include (eg ?parent=abbc-....)
334+
@param uri_list: One or more URIs referencing objects
335+
@param retry: Has this method already been retried? Used if we need to refresh XSRF.
336+
@return: Response from API
337+
"""
338+
self._last_err = None
339+
r = self.session.put(url, params=params, data=uri_list, headers=self.list_request_headers,
340+
proxies=self.proxies)
341+
self.update_token(r)
342+
343+
if r.status_code == 403:
344+
# 403 Forbidden
345+
# If we had a CSRF failure, retry the request with the updated token
346+
# After speaking in #dev it seems that these do need occasional refreshes but I suspect
347+
# it's happening too often for me, so check for accidentally triggering it
348+
_logger.debug(r.text)
349+
# Parse response
350+
r_json = parse_json(r)
351+
if 'message' in r_json and 'CSRF token' in r_json['message']:
352+
if retry:
353+
_logger.warning(f'Too many retries updating token: {r.status_code}: {r.text}')
354+
else:
355+
_logger.debug("Retrying request with updated CSRF token")
356+
return self.api_put_uri(url, params=params, uri_list=uri_list, retry=True)
357+
358+
return r
359+
328360
def api_delete(self, url, params, retry=False):
329361
"""
330362
Perform a DELETE request. Refresh XSRF token if necessary.
@@ -978,6 +1010,29 @@ def get_item(self, uuid):
9781010
_logger.error(f'Invalid item UUID: {uuid}')
9791011
return None
9801012

1013+
def get_item_by_handle(self, handle):
1014+
"""
1015+
Get item based on handle.
1016+
"""
1017+
if handle is None:
1018+
return None
1019+
params = {
1020+
"handle": handle
1021+
}
1022+
url = f'{self.API_ENDPOINT}/core/items/search/byHandle'
1023+
try:
1024+
r = self.api_get(url, params, None)
1025+
r_json = parse_json(r)
1026+
if '_embedded' in r_json:
1027+
if 'items' in r_json['_embedded']:
1028+
items = r_json['_embedded']['items']
1029+
if len(items) > 0:
1030+
return Item(items[0])
1031+
return None
1032+
except (TypeError, ValueError):
1033+
_logger.error(f'Invalid item handle: {handle}')
1034+
return None
1035+
9811036
def get_items(self, page=0, size=20):
9821037
"""
9831038
Get all archived items for a logged-in administrator. Admin only! Usually you will want to
@@ -1079,24 +1134,27 @@ def add_metadata(self, dso, field, value, language=None, authority=None, confide
10791134

10801135
return dso_type(api_resource=parse_json(r))
10811136

1082-
def remove_metadata(self, dso, field):
1137+
def remove_metadata(self, dso, field, place=None):
10831138
"""
1084-
Remove metadata
1139+
Remove metadata from dso based on metadata field.
1140+
@param dso: DSpace object to patch
1141+
@param field: metadata field, e.g. dc.title
1142+
@param place: if None, every value of the field is removed. Otherwise only
1143+
the value at this place - a 0+ integer, or a hyphen meaning "last".
1144+
@return: DSpace object constructed from the API response
10851145
"""
10861146
if dso is None or field is None or not isinstance(dso, DSpaceObject):
10871147
_logger.error('Invalid or missing DSpace object, field or value string')
10881148
return self
10891149

10901150
dso_type = type(dso)
10911151

1092-
# Place can be 0+ integer, or a hyphen - meaning "last"
1093-
path = f'/metadata/{field}'
1152+
path = f'/metadata/{field}' if place is None else f'/metadata/{field}/{place}'
10941153
url = dso.links['self']['href']
10951154

10961155
r = self.api_patch(url=url, operation=self.PatchOperation.REMOVE, path=path, value=None)
10971156
return dso_type(api_resource=parse_json(r))
10981157

1099-
11001158
def create_user(self, user, token=None):
11011159
"""
11021160
Create a user
@@ -1154,6 +1212,45 @@ def create_group(self, group):
11541212
# that you see for other DSO types - still figuring out the best way
11551213
return Group(api_resource=parse_json(self.create_dso(url, params=None, data=data)))
11561214

1215+
def create_submit_group(self, collection):
1216+
"""
1217+
Creates a submitter group for the given collection.
1218+
"""
1219+
url = f'{self.API_ENDPOINT}/core/collections/{collection.uuid}/submittersGroup'
1220+
r = self.api_post(url, json={}, params=None)
1221+
if r.status_code == 201:
1222+
return Group(parse_json(r))
1223+
return None
1224+
1225+
def add_member(self, group, eperson):
1226+
"""
1227+
Adds a user (EPerson) as a member of the specified group.
1228+
1229+
Args:
1230+
group (Group): The group to which the user will be added.
1231+
eperson (User): The EPerson to be added as a member of the group.
1232+
1233+
Returns:
1234+
bool: True if the user was successfully added (HTTP 204), False otherwise.
1235+
"""
1236+
if not isinstance(group, Group):
1237+
_logger.error("Provided 'group' is not an instance of Group.")
1238+
return False
1239+
1240+
if not isinstance(eperson, User):
1241+
_logger.error("Provided 'eperson' is not an instance of User.")
1242+
return False
1243+
1244+
url = f'{self.API_ENDPOINT}/eperson/groups/{group.uuid}/epersons'
1245+
eperson_uri = f'{self.API_ENDPOINT}/epersons/{eperson.uuid}'
1246+
r = self.api_post_uri(url, params=None, uri_list=eperson_uri)
1247+
if r.status_code == 204:
1248+
return True
1249+
_logger.error(f"Failed to add user {eperson.uuid} to group {group.uuid}. "
1250+
f"Status code: {r.status_code}")
1251+
return False
1252+
1253+
11571254
def start_workflow(self, workspace_item):
11581255
url = f'{self.API_ENDPOINT}/workflow/workflowitems'
11591256
res = parse_json(self.api_post_uri(url, params=None, uri_list=workspace_item))
@@ -1204,3 +1301,139 @@ def solr_query(self, query, filters=None, fields=None, start=0, rows=999999999):
12041301
return self.solr.search(query, fq=filters, start=start, rows=rows, **{
12051302
'fl': ','.join(fields)
12061303
})
1304+
1305+
def get_items_from_collection(self, collection_id, page=0, size=1000):
1306+
"""
1307+
Get all items
1308+
@return: list of Item objects
1309+
"""
1310+
url = f'{self.API_ENDPOINT}/discover/search/objects?sort=dc.date.accessioned,DESC&page={page}&size={size}&scope={collection_id}&dsoType=ITEM&embed=thumbnail'
1311+
1312+
items = list()
1313+
r = self.api_get(url)
1314+
r_json = parse_json(r)
1315+
if '_embedded' in r_json:
1316+
if 'searchResult' in r_json['_embedded']:
1317+
if '_embedded' in r_json['_embedded']['searchResult']:
1318+
for item_resource in r_json['_embedded']['searchResult']['_embedded']['objects']:
1319+
items.append(Item(item_resource['_embedded']['indexableObject']))
1320+
1321+
return items
1322+
1323+
def get_bundle_by_name(self, name, item_uuid):
1324+
"""
1325+
Get a bundle by name for a specific item
1326+
@param name: Name of the bundle
1327+
@param item_uuid: UUID of the item
1328+
@return: Bundle object
1329+
"""
1330+
url = f'{self.API_ENDPOINT}/core/items/{item_uuid}/bundles'
1331+
r_json = self.fetch_resource(url, params=None)
1332+
if '_embedded' in r_json:
1333+
if 'bundles' in r_json['_embedded']:
1334+
for bundle in r_json['_embedded']['bundles']:
1335+
if bundle['name'] == name:
1336+
return Bundle(bundle)
1337+
return None
1338+
1339+
def get_resource_policy(self, bundle_uuid):
1340+
"""
1341+
Get a resource policy for a specific bundle
1342+
"""
1343+
url = f'{self.API_ENDPOINT}/authz/resourcepolicies/search/resource?uuid={bundle_uuid}&embed=eperson&embed=group'
1344+
r = self.api_get(url)
1345+
r_json = parse_json(r)
1346+
if '_embedded' in r_json:
1347+
if 'resourcepolicies' in r_json['_embedded']:
1348+
return r_json['_embedded']['resourcepolicies'][0]
1349+
1350+
def create_resource_policy(self, resource_uuid, data, group_uuid=None, eperson_uuid=None):
1351+
"""
1352+
Creates a resource policy by sending a POST request to the API endpoint.
1353+
"""
1354+
url = f'{self.API_ENDPOINT}/authz/resourcepolicies'
1355+
params = {"resource": resource_uuid}
1356+
if group_uuid:
1357+
params["group"] = group_uuid
1358+
if eperson_uuid:
1359+
params["eperson"] = eperson_uuid
1360+
1361+
r = self.api_post(url, params=params, json=data)
1362+
if r.status_code == 201:
1363+
return True
1364+
return False
1365+
1366+
1367+
def update_resource_policy_group(self, policy_id, group_uuid):
1368+
"""
1369+
Update a resource policy with a new group
1370+
"""
1371+
url = f'{self.API_ENDPOINT}/authz/resourcepolicies/{policy_id}/group'
1372+
body = f'{self.API_ENDPOINT}/eperson/groups/{group_uuid}'
1373+
r = self.api_put_uri(url, None, body, False)
1374+
return r
1375+
1376+
def get_clarinlruallowances(self):
1377+
"""
1378+
Fetch all clarinlruallowances.
1379+
"""
1380+
url = f'{self.API_ENDPOINT}/core/clarinlruallowances'
1381+
try:
1382+
response = self.api_get(url)
1383+
data = parse_json(response)
1384+
allowances = data.get('_embedded', {}).get('clarinlruallowances')
1385+
if allowances:
1386+
return allowances
1387+
except Exception as e:
1388+
_logger.error(f"Error fetching CLARIN LRU allowances [{url}]: {e}")
1389+
return None
1390+
1391+
def get_clarinlruallowances_by_bitstream_and_user(self, bitstream_uuid, user_uuid):
1392+
"""
1393+
Fetch user allowances for a specific bitstream and user.
1394+
"""
1395+
url = f'{self.API_ENDPOINT}/core/clarinlruallowances/search/byBitstreamAndUser'
1396+
params = {'bitstreamUUID': bitstream_uuid, 'userUUID': user_uuid}
1397+
try:
1398+
response = self.api_get(url, params=params)
1399+
data = parse_json(response)
1400+
allowances = data.get('_embedded', {}).get('clarinlruallowances')
1401+
if allowances:
1402+
return allowances
1403+
except Exception as e:
1404+
_logger.error(f"Error fetching user allowances: {e}")
1405+
return None
1406+
1407+
1408+
def create_clarinlruallowances(self, bitstream_uuid):
1409+
"""
1410+
Create clarinlruallowances for a bitstream for logged user
1411+
by managing user metadata of bitstream.
1412+
"""
1413+
url = f'{self.API_ENDPOINT}/core/clarinusermetadata/manage'
1414+
params = {'bitstreamUUID': bitstream_uuid}
1415+
metadata_payload = [
1416+
{"metadataKey": "NAME", "metadataValue": "Test"}
1417+
]
1418+
try:
1419+
response = self.api_post(url, json=metadata_payload, params=params)
1420+
if response.status_code == 200:
1421+
return True
1422+
except Exception as e:
1423+
_logger.error(f"Error managing user metadata: {e}")
1424+
return False
1425+
1426+
1427+
def get_user_by_email(self, email):
1428+
"""
1429+
Retrieve user details using their email address.
1430+
"""
1431+
url = f'{self.API_ENDPOINT}/eperson/epersons/search/byEmail'
1432+
params = {'email': email}
1433+
try:
1434+
response = self.api_get(url, params=params)
1435+
user_data = parse_json(response)
1436+
return User(user_data)
1437+
except Exception as e:
1438+
_logger.error(f"Error retrieving user by email {email}: {e}")
1439+
return None

dspace_rest_client/models.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,61 @@ class RelationshipType(AddressableHALResource):
521521
def __init__(self, api_resource):
522522
super(RelationshipType, self).__init__(api_resource)
523523

524+
class License(AddressableHALResource):
525+
"""
526+
Specific attributes and functions for licenses
527+
"""
528+
def __init__(self, api_resource=None):
529+
super(License, self).__init__(api_resource)
530+
api_resource = api_resource or {}
531+
self.type = 'clarinlicense'
532+
self.name = api_resource.get('name')
533+
self.definition = api_resource.get('definition')
534+
self.confirmation = api_resource.get('confirmation', 0)
535+
self.requiredInfo = api_resource.get('requiredInfo')
536+
license_label_value = api_resource.get('clarinLicenseLabel')
537+
self.licenseLabel = Label(license_label_value) if license_label_value else None
538+
self.extendedLicenseLabel = [Label(label) for label in
539+
api_resource.get('extendedClarinLicenseLabels', [])]
540+
self.bitstream = api_resource.get('bitstreams')
541+
542+
def to_dict(self):
543+
return {
544+
'name': self.name,
545+
'license_id': self.id,
546+
'definition': self.definition,
547+
'confirmation': self.confirmation,
548+
'required_info': self.requiredInfo,
549+
'label_id': self.licenseLabel.id if self.licenseLabel else None,
550+
}
551+
552+
553+
class Label(AddressableHALResource):
554+
"""
555+
Specific attributes and functions for licenses
556+
"""
557+
def __init__(self, api_resource=None):
558+
"""
559+
Default constructor. Call DSpaceObject init then set label-specific attributes
560+
@param api_resource: API result object to use as initial data
561+
"""
562+
super(Label, self).__init__(api_resource)
563+
api_resource = api_resource or {}
564+
self.type = 'clarinlicenselabel'
565+
self.label = api_resource.get('label')
566+
self.title = api_resource.get('title')
567+
self.icon = api_resource.get('icon')
568+
self.extended = api_resource.get('extended', False)
569+
570+
def to_dict(self):
571+
return {
572+
'label_id': self.id,
573+
'label': self.label,
574+
'title': self.title,
575+
'icon': self.icon,
576+
'is_extended': self.extended
577+
}
578+
524579

525580
class ResourcePolicy(AddressableHALResource):
526581
"""
@@ -544,6 +599,7 @@ def __init__(self, api_resource: dict):
544599
if 'group' in api_resource['_embedded']:
545600
self.groupName = api_resource['_embedded']['group'].get('name')
546601
self.groupUUID = api_resource['_embedded']['group'].get('uuid')
602+
547603
def as_dict(self):
548604
return {
549605
'id': self.id,
@@ -559,4 +615,4 @@ def as_dict(self):
559615
}
560616

561617
def __repr__(self):
562-
return f"ResourcePolicy: {self.name} [{self.groupName}] [action: {self.action}] [type: {self.type}]"
618+
return f"ResourcePolicy: {self.name} [{self.groupName}] [action: {self.action}] [type: {self.type}]"

0 commit comments

Comments
 (0)