diff --git a/dspace_rest_client/models.py b/dspace_rest_client/models.py index 4427512..ca8cb46 100644 --- a/dspace_rest_client/models.py +++ b/dspace_rest_client/models.py @@ -10,7 +10,12 @@ @author Kim Shepherd """ import json +import logging +_logger = logging.getLogger("dspace.models") +# library: records are dropped unless the application configures logging +if not any(isinstance(h, logging.NullHandler) for h in _logger.handlers): + _logger.addHandler(logging.NullHandler()) __all__ = ['DSpaceObject', 'HALResource', 'ExternalDataObject', 'SimpleDSpaceObject', 'Community', 'Collection', 'Item', 'Bundle', 'Bitstream', 'User', 'Group', 'ResourcePolicy'] @@ -597,11 +602,17 @@ def __init__(self, api_resource: dict): # Check for direct groupName/groupUUID (cached format from as_dict()) self.groupName = api_resource.get('groupName') self.groupUUID = api_resource.get('groupUUID') - # If not found, try extracting from _embedded structure (live API format) + # If not found, try extracting from _embedded structure (live API format). + # A policy is granted to a group OR to an eperson; the other one is null. if self.groupName is None and '_embedded' in api_resource: - if 'group' in api_resource['_embedded']: - self.groupName = api_resource['_embedded']['group'].get('name') - self.groupUUID = api_resource['_embedded']['group'].get('uuid') + embedded = api_resource['_embedded'] or {} + group = embedded.get('group') + if group: + self.groupName = group.get('name') + self.groupUUID = group.get('uuid') + elif not embedded.get('eperson'): + _logger.warning( + f'Resource policy [{self.id}] has neither a group nor an eperson') def as_dict(self): return { diff --git a/tests/_helpers.py b/tests/_helpers.py index d4183cc..d357df1 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -7,7 +7,9 @@ response parsing - the two things downstream code (this repo) depends on - fails a test instead of silently shipping. """ +import contextlib import json +import logging import os import re import sys @@ -33,6 +35,14 @@ BITSTREAM_UUID = "9f54ef33-c454-4d8e-a5fe-79d8291045ba" ANON_GROUP_UUID = "6ecfd145-3b7d-429e-ab31-ef6905a05763" +_FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + + +def load_fixture(name: str) -> dict: + """A recorded (anonymised) DSpace response from ``tests/fixtures``.""" + with open(os.path.join(_FIXTURES, name), encoding="utf-8") as f: + return json.load(f) + def make_client(api_endpoint: str = API) -> DSpaceClient: """A real client with no network touched. @@ -112,3 +122,19 @@ def policy_json(pid: int = 1, action: str = "READ", group_name: str = "Anonymous if start_date is not None: d["startDate"] = start_date return d + + +@contextlib.contextmanager +def no_warnings_logged(test, logger_name: str): + """``test.assertNoLogs(logger_name, "WARNING")`` - which needs Python 3.10, + while ``setup.py`` supports 3.8.""" + records = [] + handler = logging.Handler(level=logging.WARNING) + handler.emit = records.append + logger = logging.getLogger(logger_name) + logger.addHandler(handler) + try: + yield + finally: + logger.removeHandler(handler) + test.assertEqual([r.getMessage() for r in records], []) diff --git a/tests/fixtures/resourcepolicies_eperson_and_group.json b/tests/fixtures/resourcepolicies_eperson_and_group.json new file mode 100644 index 0000000..ab0ba9d --- /dev/null +++ b/tests/fixtures/resourcepolicies_eperson_and_group.json @@ -0,0 +1,275 @@ +{ + "_embedded": { + "resourcepolicies": [ + { + "id": 2481446, + "name": null, + "description": null, + "action": "READ", + "startDate": null, + "endDate": null, + "type": "resourcepolicy", + "_links": { + "eperson": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/2481446/eperson" + }, + "group": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/2481446/group" + }, + "resource": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/2481446/resource" + }, + "self": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/2481446" + } + }, + "_embedded": { + "eperson": { + "id": "33333333-3333-3333-3333-333333333333", + "uuid": "33333333-3333-3333-3333-333333333333", + "name": "person@example.org", + "handle": null, + "metadata": { + "dspace.agreements.cookies": [ + { + "value": "{\"authentication\":true}", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "eperson.firstname": [ + { + "value": "Test", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "eperson.language": [ + { + "value": "cs", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "eperson.lastname": [ + { + "value": "Person", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "eperson.phone": [ + { + "value": "000000000", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ] + }, + "netid": "person-0000[https://idp.example.org/idp/shibboleth]", + "lastActive": "2026-01-01T00:00:00.000+00:00", + "canLogIn": true, + "email": "person@example.org", + "requireCertificate": false, + "selfRegistered": true, + "welcomeInfo": null, + "canEditSubmissionMetadata": null, + "type": "eperson", + "_links": { + "groups": { + "href": "http://dspace.test/server/api/eperson/epersons/33333333-3333-3333-3333-333333333333/groups" + }, + "self": { + "href": "http://dspace.test/server/api/eperson/epersons/33333333-3333-3333-3333-333333333333" + } + } + }, + "resource": { + "id": "9f54ef33-c454-4d8e-a5fe-79d8291045ba", + "uuid": "9f54ef33-c454-4d8e-a5fe-79d8291045ba", + "name": "license.txt", + "handle": null, + "metadata": { + "dc.source": [ + { + "value": "Written by org.dspace.content.LicenseUtils", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "dc.title": [ + { + "value": "license.txt", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ] + }, + "bundleName": "LICENSE", + "sizeBytes": 1748, + "checkSum": { + "checkSumAlgorithm": "MD5", + "value": "8a4605be74aa9ea9d79846c1fba20a33" + }, + "sequenceId": 2, + "type": "bitstream", + "_links": { + "content": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/content" + }, + "bundle": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/bundle" + }, + "format": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/format" + }, + "thumbnail": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/thumbnail" + }, + "self": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba" + } + } + }, + "group": null + } + }, + { + "id": 3390404, + "name": null, + "policyType": "TYPE_INHERITED", + "description": null, + "action": "READ", + "startDate": null, + "endDate": null, + "type": "resourcepolicy", + "_links": { + "eperson": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/3390404/eperson" + }, + "group": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/3390404/group" + }, + "resource": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/3390404/resource" + }, + "self": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/3390404" + } + }, + "_embedded": { + "eperson": null, + "resource": { + "id": "9f54ef33-c454-4d8e-a5fe-79d8291045ba", + "uuid": "9f54ef33-c454-4d8e-a5fe-79d8291045ba", + "name": "license.txt", + "handle": null, + "metadata": { + "dc.source": [ + { + "value": "Written by org.dspace.content.LicenseUtils", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ], + "dc.title": [ + { + "value": "license.txt", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ] + }, + "bundleName": "LICENSE", + "sizeBytes": 1748, + "checkSum": { + "checkSumAlgorithm": "MD5", + "value": "8a4605be74aa9ea9d79846c1fba20a33" + }, + "sequenceId": 2, + "type": "bitstream", + "_links": { + "content": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/content" + }, + "bundle": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/bundle" + }, + "format": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/format" + }, + "thumbnail": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba/thumbnail" + }, + "self": { + "href": "http://dspace.test/server/api/core/bitstreams/9f54ef33-c454-4d8e-a5fe-79d8291045ba" + } + } + }, + "group": { + "id": "6ecfd145-3b7d-429e-ab31-ef6905a05763", + "uuid": "6ecfd145-3b7d-429e-ab31-ef6905a05763", + "name": "Anonymous", + "handle": null, + "metadata": { + "dc.title": [ + { + "value": "Anonymous", + "language": null, + "authority": null, + "confidence": -1, + "place": 0 + } + ] + }, + "permanent": true, + "type": "group", + "_links": { + "subgroups": { + "href": "http://dspace.test/server/api/eperson/groups/6ecfd145-3b7d-429e-ab31-ef6905a05763/subgroups" + }, + "epersons": { + "href": "http://dspace.test/server/api/eperson/groups/6ecfd145-3b7d-429e-ab31-ef6905a05763/epersons" + }, + "object": { + "href": "http://dspace.test/server/api/eperson/groups/6ecfd145-3b7d-429e-ab31-ef6905a05763/object" + }, + "self": { + "href": "http://dspace.test/server/api/eperson/groups/6ecfd145-3b7d-429e-ab31-ef6905a05763" + } + } + } + } + } + ] + }, + "_links": { + "self": { + "href": "http://dspace.test/server/api/authz/resourcepolicies/search/resource?uuid=9f54ef33-c454-4d8e-a5fe-79d8291045ba&action=READ" + } + }, + "page": { + "size": 20, + "totalElements": 2, + "totalPages": 1, + "number": 0 + } +} diff --git a/tests/test_client_read.py b/tests/test_client_read.py index f48b04a..fc77295 100644 --- a/tests/test_client_read.py +++ b/tests/test_client_read.py @@ -12,7 +12,8 @@ import _helpers # noqa: F401 from _helpers import ( make_client, sent_params, embedded, item_json, bundle_json, - bitstream_json, policy_json, API, ITEM_UUID, BITSTREAM_UUID) + bitstream_json, policy_json, load_fixture, API, ITEM_UUID, BITSTREAM_UUID, + ANON_GROUP_UUID, no_warnings_logged) from dspace_rest_client.models import Item, Bundle, Collection, Community @@ -296,6 +297,24 @@ def test_action_none_omits_the_action_filter(self): self.assertEqual(p["uuid"], [BITSTREAM_UUID]) self.assertNotIn("action", p) + def test_eperson_policy_next_to_group_policy_parses_both(self): + # Recorded TUL response: a policy granted to a person carries + # "_embedded": {"group": null, "eperson": {...}}. It used to raise + # AttributeError, losing the valid group policy next to it too. + c = make_client() + body = load_fixture("resourcepolicies_eperson_and_group.json") + with requests_mock.Mocker() as m: + m.get(f"{API}/authz/resourcepolicies/search/resource", json=body) + with no_warnings_logged(self, "dspace.models"): + rps = c.get_resourcepolicy(BITSTREAM_UUID, action="READ") + self.assertEqual([rp.id for rp in rps], [2481446, 3390404]) + person, group = rps + self.assertIsNone(person.groupName) + self.assertIsNone(person.groupUUID) + self.assertEqual(group.groupName, "Anonymous") + self.assertEqual(group.groupUUID, ANON_GROUP_UUID) + self.assertEqual(group.policyType, "TYPE_INHERITED") + def test_empty_result_set_returns_empty_list(self): # The live endpoint returns an _embedded envelope even when empty. c = make_client() diff --git a/tests/test_models.py b/tests/test_models.py index 8167548..00a30a5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,6 +10,7 @@ import unittest import _helpers # noqa: F401 (bootstraps sys.path for direct runs) +from _helpers import no_warnings_logged from dspace_rest_client.models import ( Item, Community, Collection, Bundle, Bitstream, ResourcePolicy) @@ -147,6 +148,27 @@ def test_as_dict_roundtrip_keeps_group_and_action(self): self.assertEqual(rp2.groupUUID, "anon") self.assertEqual(rp2.action, "READ") + def test_eperson_policy_has_no_group_and_no_warning(self): + # a policy granted to a person, not a group: "group" is null + with no_warnings_logged(self, "dspace.models"): + rp = ResourcePolicy({"id": 3, "action": "READ", + "_embedded": {"group": None, + "eperson": {"uuid": "ep-uuid"}}}) + self.assertIsNone(rp.groupName) + self.assertIsNone(rp.groupUUID) + rp2 = ResourcePolicy(rp.as_dict()) + self.assertIsNone(rp2.groupUUID) + self.assertEqual(rp2.action, "READ") + + def test_policy_without_group_and_eperson_warns_with_id(self): + with self.assertLogs("dspace.models", level="WARNING") as logs: + rp = ResourcePolicy({"id": 4, "action": "READ", + "_embedded": {"group": None, "eperson": None}}) + self.assertIsNone(rp.groupUUID) + self.assertEqual(len(logs.records), 1) + self.assertEqual(logs.records[0].levelname, "WARNING") + self.assertIn("[4]", logs.records[0].getMessage()) + if __name__ == "__main__": unittest.main()