|
1 | 1 | # This software is licenced under the BSD 3-Clause licence |
2 | 2 | # available at https://opensource.org/licenses/BSD-3-Clause |
3 | | -# and described in the LICENSE file in the root of this project |
| 3 | +# and described in the LICENSE.txt file in the root of this project |
4 | 4 |
|
5 | 5 | """ |
6 | 6 | DSpace REST API client library. Intended to make interacting with DSpace in Python 3 easier, particularly |
|
14 | 14 |
|
15 | 15 | @author Kim Shepherd <kim@shepherd.nz> |
16 | 16 | """ |
17 | | - |
| 17 | +import code |
18 | 18 | import json |
| 19 | +import logging |
| 20 | + |
19 | 21 | import requests |
20 | 22 | from requests import Request |
21 | 23 | import os |
@@ -329,11 +331,88 @@ def parse_json(response): |
329 | 331 | return response_json |
330 | 332 |
|
331 | 333 |
|
| 334 | +class Group(DSpaceObject): |
| 335 | + """ |
| 336 | + Extends DSpaceObject to implement specific attributes and methods for groups (aka. EPersonGroups) |
| 337 | + """ |
| 338 | + type = 'group' |
| 339 | + name = None |
| 340 | + permanent = False |
| 341 | + |
| 342 | + def __init__(self, api_resource=None): |
| 343 | + """ |
| 344 | + Default constructor. Call DSpaceObject init then set group-specific attributes |
| 345 | + @param api_resource: API result object to use as initial data |
| 346 | + """ |
| 347 | + super(Group, self).__init__(api_resource) |
| 348 | + self.type = 'group' |
| 349 | + if 'name' in api_resource: |
| 350 | + self.name = api_resource['name'] |
| 351 | + if 'permanent' in api_resource: |
| 352 | + self.permanent = api_resource['permanent'] |
| 353 | + |
| 354 | + def as_dict(self): |
| 355 | + """ |
| 356 | + Return a dict representation of this Group, based on super with group-specific attributes added |
| 357 | + @return: dict of Group for API use |
| 358 | + """ |
| 359 | + dso_dict = super(Group, self).as_dict() |
| 360 | + group_dict = {'name': self.name, 'permanent': self.permanent} |
| 361 | + return {**dso_dict, **group_dict} |
| 362 | + |
| 363 | + |
| 364 | +class User(SimpleDSpaceObject): |
| 365 | + """ |
| 366 | + Extends DSpaceObject to implement specific attributes and methods for users (aka. EPersons) |
| 367 | + """ |
| 368 | + type = 'user' |
| 369 | + name = None, |
| 370 | + netid = None, |
| 371 | + lastActive = None, |
| 372 | + canLogIn = False, |
| 373 | + email = None, |
| 374 | + requireCertificate = False, |
| 375 | + selfRegistered = False |
| 376 | + |
| 377 | + def __init__(self, api_resource=None): |
| 378 | + """ |
| 379 | + Default constructor. Call DSpaceObject init then set user-specific attributes |
| 380 | + @param api_resource: API result object to use as initial data |
| 381 | + """ |
| 382 | + super(User, self).__init__(api_resource) |
| 383 | + self.type = 'user' |
| 384 | + if 'name' in api_resource: |
| 385 | + self.name = api_resource['name'] |
| 386 | + if 'netid' in api_resource: |
| 387 | + self.netid = api_resource['netid'] |
| 388 | + if 'lastActive' in api_resource: |
| 389 | + self.lastActive = api_resource['lastActive'] |
| 390 | + if 'canLogIn' in api_resource: |
| 391 | + self.canLogIn = api_resource['canLogIn'] |
| 392 | + if 'email' in api_resource: |
| 393 | + self.email = api_resource['email'] |
| 394 | + if 'requireCertificate' in api_resource: |
| 395 | + self.requireCertificate = api_resource['requireCertificate'] |
| 396 | + if 'selfRegistered' in api_resource: |
| 397 | + self.selfRegistered = api_resource['selfRegistered'] |
| 398 | + |
| 399 | + def as_dict(self): |
| 400 | + """ |
| 401 | + Return a dict representation of this User, based on super with user-specific attributes added |
| 402 | + @return: dict of User for API use |
| 403 | + """ |
| 404 | + dso_dict = super(User, self).as_dict() |
| 405 | + user_dict = {'name': self.name, 'netid': self.netid, 'lastActive': self.lastActive, 'canLogIn': self.canLogIn, |
| 406 | + 'email': self.email, 'requireCertificate': self.requireCertificate, |
| 407 | + 'selfRegistered': self.selfRegistered} |
| 408 | + return {**dso_dict, **user_dict} |
| 409 | + |
| 410 | + |
332 | 411 | class DSpaceClient: |
333 | 412 | """ |
334 | 413 | Main class of the API client itself. This client uses request sessions to connect and authenticate to |
335 | 414 | the REST API, maintain XSRF tokens, and all GET, POST, PUT, PATCH operations. |
336 | | - Low-level api_get, api_post, api_put, api_patch functions are defined to handle the requests and do |
| 415 | + Low-level api_get, api_post, api_put, api_delete, api_patch functions are defined to handle the requests and do |
337 | 416 | retries / XSRF refreshes where necessary. |
338 | 417 | Higher level get, create, update, partial_update (patch) functions are implemented for each DSO type |
339 | 418 | """ |
@@ -468,7 +547,7 @@ def api_post(self, url, params, json, retry=False): |
468 | 547 | def api_put(self, url, params, json, retry=False): |
469 | 548 | """ |
470 | 549 | Perform a PUT request. Refresh XSRF token if necessary. |
471 | | - PUTs are tupically used to update objects. |
| 550 | + PUTs are typically used to update objects. |
472 | 551 | @param url: DSpace REST API URL |
473 | 552 | @param params: Any parameters to include (eg ?parent=abbc-....) |
474 | 553 | @param json: Data in json-ready form (dict) to send as PUT body (eg. item.as_dict()) |
@@ -499,6 +578,39 @@ def api_put(self, url, params, json, retry=False): |
499 | 578 |
|
500 | 579 | return r |
501 | 580 |
|
| 581 | + def api_delete(self, url, params, retry=False): |
| 582 | + """ |
| 583 | + Perform a DELETE request. Refresh XSRF token if necessary. |
| 584 | + DELETES are typically used to update objects. |
| 585 | + @param url: DSpace REST API URL |
| 586 | + @param params: Any parameters to include (eg ?parent=abbc-....) |
| 587 | + @param retry: Has this method already been retried? Used if we need to refresh XSRF. |
| 588 | + @return: Response from API |
| 589 | + """ |
| 590 | + h = {'Content-type': 'application/json'} |
| 591 | + r = self.session.delete(url, params=params, headers=h) |
| 592 | + if 'DSPACE-XSRF-TOKEN' in r.headers: |
| 593 | + t = r.headers['DSPACE-XSRF-TOKEN'] |
| 594 | + print('Updating token to ' + t) |
| 595 | + self.session.headers.update({'X-XSRF-Token': t}) |
| 596 | + self.session.cookies.update({'X-XSRF-Token': t}) |
| 597 | + |
| 598 | + if r.status_code == 403: |
| 599 | + # 403 Forbidden |
| 600 | + # If we had a CSRF failure, retry the request with the updated token |
| 601 | + # After speaking in #dev it seems that these do need occasional refreshes but I suspect |
| 602 | + # it's happening too often for me, so check for accidentally triggering it |
| 603 | + print(r.text) |
| 604 | + r_json = r.json() |
| 605 | + if 'message' in r_json and 'CSRF token' in r_json['message']: |
| 606 | + if retry: |
| 607 | + print('Already retried... something must be wrong') |
| 608 | + else: |
| 609 | + print("Retrying request with updated CSRF token") |
| 610 | + return self.api_delete(url, params=params, retry=True) |
| 611 | + |
| 612 | + return r |
| 613 | + |
502 | 614 | def api_patch(self, url, operation, path, value, retry=False): |
503 | 615 | """ |
504 | 616 | @param url: DSpace REST API URL |
@@ -693,6 +805,42 @@ def update_dso(self, dso, params=None): |
693 | 805 | print(f'{e}') |
694 | 806 | return None |
695 | 807 |
|
| 808 | + def delete_dso(self, dso=None, url=None, params=None): |
| 809 | + """ |
| 810 | + Delete DSpaceObject. Takes a DSpaceObject and any optional parameters. Will send a PUT update to the remote |
| 811 | + object and return the updated object, typed correctly. |
| 812 | + :param dso: DSpaceObject from which to parse self link |
| 813 | + :param params: Optional parameters |
| 814 | + :param url: URI if not deleting from DSO |
| 815 | + :return: |
| 816 | +
|
| 817 | + """ |
| 818 | + if dso is None: |
| 819 | + if url is None: |
| 820 | + print(f'Need a DSO or a URL to delete') |
| 821 | + return None |
| 822 | + else: |
| 823 | + if not isinstance(dso, SimpleDSpaceObject): |
| 824 | + print(f'Only SimpleDSpaceObject types (eg Item, Collection, Community, EPerson) ' |
| 825 | + f'are supported by generic update_dso PUT.') |
| 826 | + return dso |
| 827 | + # Get self URI from HAL links |
| 828 | + url = dso.links['self']['href'] |
| 829 | + |
| 830 | + try: |
| 831 | + r = self.api_delete(url, params=params) |
| 832 | + if r.status_code == 204: |
| 833 | + # 204 No Content - success! |
| 834 | + print(f'{url} was deleted sucessfully!') |
| 835 | + return r |
| 836 | + else: |
| 837 | + print(f'update operation failed: {r.status_code}: {r.text} ({url})') |
| 838 | + return None |
| 839 | + |
| 840 | + except ValueError as e: |
| 841 | + print(f'{e}') |
| 842 | + return None |
| 843 | + |
696 | 844 | def get_bundles(self, parent=None, uuid=None): |
697 | 845 | """ |
698 | 846 | Get bundles for an item |
@@ -950,7 +1098,7 @@ def create_item(self, parent, item): |
950 | 1098 | Create an item beneath the given parent collection |
951 | 1099 | @param parent: UUID of parent collection to pass as a parameter to create_dso |
952 | 1100 | @param item: python Item object containing all the data and links expected by the REST API |
953 | | - @return: Item object construcuted from the API response |
| 1101 | + @return: Item object constructed from the API response |
954 | 1102 | """ |
955 | 1103 | url = f'{self.API_ENDPOINT}/core/items' |
956 | 1104 | if parent is None: |
@@ -1008,3 +1156,52 @@ def add_metadata(self, dso, field, value, language=None, authority=None, confide |
1008 | 1156 | url=url, operation=self.PatchOperation.ADD, path=path, value=patch_value) |
1009 | 1157 |
|
1010 | 1158 | return dso_type(api_resource=parse_json(r)) |
| 1159 | + |
| 1160 | + def create_user(self, user, token=None): |
| 1161 | + """ |
| 1162 | + Create a user |
| 1163 | + @param user: python User object or Python dict containing all the data and links expected by the REST API |
| 1164 | + :param token: Token if creating new user (optional) from the link in a registration email |
| 1165 | + @return: User object constructed from the API response |
| 1166 | + """ |
| 1167 | + url = f'{self.API_ENDPOINT}/eperson/epersons' |
| 1168 | + data = user |
| 1169 | + if isinstance(user, User): |
| 1170 | + data = user.as_dict() |
| 1171 | + # TODO: Validation. Note, at least here I will just allow a dict instead of the pointless cast<->cast |
| 1172 | + # that you see for other DSO types - still figuring out the best way |
| 1173 | + params = None |
| 1174 | + if token is not None: |
| 1175 | + params = {'token': token} |
| 1176 | + return User(api_resource=parse_json(self.create_dso(url, params=params, data=data))) |
| 1177 | + |
| 1178 | + def delete_user(self, user): |
| 1179 | + if not isinstance(user, User): |
| 1180 | + print(f'Must be a valid user') |
| 1181 | + return None |
| 1182 | + return self.delete_dso(user) |
| 1183 | + |
| 1184 | + def get_users(self): |
| 1185 | + url = f'{self.API_ENDPOINT}/eperson/epersons' |
| 1186 | + users = list() |
| 1187 | + r = self.api_get(url) |
| 1188 | + r_json = parse_json(response=r) |
| 1189 | + if '_embedded' in r_json: |
| 1190 | + if 'epersons' in r_json['_embedded']: |
| 1191 | + for user_resource in r_json['_embedded']['epersons']: |
| 1192 | + users.append(User(user_resource)) |
| 1193 | + return users |
| 1194 | + |
| 1195 | + def create_group(self, group): |
| 1196 | + """ |
| 1197 | + Create a group |
| 1198 | + @param group: python Group object or Python dict containing all the data and links expected by the REST API |
| 1199 | + @return: User object constructed from the API response |
| 1200 | + """ |
| 1201 | + url = f'{self.API_ENDPOINT}/eperson/groups' |
| 1202 | + data = group |
| 1203 | + if isinstance(group, Group): |
| 1204 | + data = group.as_dict() |
| 1205 | + # TODO: Validation. Note, at least here I will just allow a dict instead of the pointless cast<->cast |
| 1206 | + # that you see for other DSO types - still figuring out the best way |
| 1207 | + return Group(api_resource=parse_json(self.create_dso(url, params=None, data=data))) |
0 commit comments