diff --git a/arkindex/images/models.py b/arkindex/images/models.py index b27083a368043ddfec4f8dd59bfed3361cd9e37d..4f0091823e1802663497ab03ca09ea9fdec04b49 100644 --- a/arkindex/images/models.py +++ b/arkindex/images/models.py @@ -231,7 +231,6 @@ class Image(S3FileModelMixin, IndexableModel): """ Check the image's existence and update width, height and status properties """ - url = self.url if not url.endswith('/'): url += '/' @@ -243,7 +242,8 @@ class Image(S3FileModelMixin, IndexableModel): # Load data data = resp.json() - assert all(item in data for item in ('@id', 'width', 'height')) + assert all(item in data for item in ('@id', 'width', 'height', 'profile')), 'Missing required properties' + assert isinstance(data['profile'], list), 'Profile is not a list' # Check id image_id = data['@id'] @@ -262,6 +262,32 @@ class Image(S3FileModelMixin, IndexableModel): self.path = image_id[len(self.server.url) + 1:] self.width, self.height = int(data['width']), int(data['height']) + # Handle optional maxWidth and maxHeight properties in the profile description + # `profile` is a list with one or more Image API compliance level URIs, + # and optionally an object, the profile description. + max_width, max_height = None, None + if len(data['profile']) >= 2: + profile_desc = next((item for item in data['profile'] if isinstance(item, dict)), {}) + max_width = int(profile_desc.get('maxWidth', 0)) + max_height = int(profile_desc.get('maxHeight', 0)) + + # Special case for Harvard IDS: those properties are wrongly defined in the main object + if not max_width and 'maxWidth' in data: + max_width = int(data.get('maxWidth', 0)) + if not max_height and 'maxHeight' in data: + max_height = int(data.get('maxHeight', 0)) + + # From the Image API specification: If maxWidth is specified and maxHeight is not, + # then clients should infer that maxHeight = maxWidth. + if max_width and not max_height: + max_height = max_width + + # Update the image size to reflect the maximum allowed size + if (max_width or max_height) and (self.width > max_width or self.height > max_height): + ratio = min(max_width / self.width, max_height / self.height) + self.width = round(ratio * self.width) + self.height = round(ratio * self.height) + self.status = S3FileStatus.Checked if save: self.save() diff --git a/arkindex/images/tests/test_check_images.py b/arkindex/images/tests/test_check_images.py index e1de93ae05f2298789430be34c398208991542a1..84c2f33ae53817fdcfc718829e85d7389f6186b0 100644 --- a/arkindex/images/tests/test_check_images.py +++ b/arkindex/images/tests/test_check_images.py @@ -7,11 +7,6 @@ from unittest.mock import patch class TestCheckImages(FixtureTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.check_mock = patch('arkindex.images.management.commands.check_images.Image.perform_check').start() - @classmethod def setUpTestData(cls): super().setUpTestData() @@ -25,29 +20,28 @@ class TestCheckImages(FixtureTestCase): # Create an image linked to zero elements cls.imgsrv.images.create(path='am-outside') - def tearDown(self): - super().tearDown() - self.check_mock.reset_mock() - - def test_nothing(self): + @patch('arkindex.images.management.commands.check_images.Image.perform_check') + def test_nothing(self, check_mock): call_command( 'check_images', ) - self.assertEqual(self.check_mock.call_count, 5) + self.assertEqual(check_mock.call_count, 5) - def test_corpus(self): + @patch('arkindex.images.management.commands.check_images.Image.perform_check') + def test_corpus(self, check_mock): call_command( 'check_images', corpus=self.corpus, ) - self.assertEqual(self.check_mock.call_count, 4) + self.assertEqual(check_mock.call_count, 4) - def test_element(self): + @patch('arkindex.images.management.commands.check_images.Image.perform_check') + def test_element(self, check_mock): call_command( 'check_images', element=[self.p1, self.p2], ) - self.assertEqual(self.check_mock.call_count, 2) + self.assertEqual(check_mock.call_count, 2) def test_corpus_xor_element(self): with self.assertRaises(CommandError): @@ -57,9 +51,10 @@ class TestCheckImages(FixtureTestCase): element=[self.p1, ], ) - def test_force(self): + @patch('arkindex.images.management.commands.check_images.Image.perform_check') + def test_force(self, check_mock): call_command( 'check_images', force=True, ) - self.assertEqual(self.check_mock.call_count, 7) + self.assertEqual(check_mock.call_count, 7) diff --git a/arkindex/images/tests/test_perform_check.py b/arkindex/images/tests/test_perform_check.py new file mode 100644 index 0000000000000000000000000000000000000000..e8436a672e5ba300a58725884a20314592786ac5 --- /dev/null +++ b/arkindex/images/tests/test_perform_check.py @@ -0,0 +1,149 @@ +from arkindex.project.aws import S3FileStatus +from arkindex.project.tests import FixtureTestCase +import requests +import responses + + +class TestImagePerformCheck(FixtureTestCase): + + def setUp(self): + self.img = self.imgsrv.images.get(path='img1') + + @responses.activate + def test_status(self): + """ + Test Image.perform_check fails on a HTTP error code + """ + responses.add(responses.GET, 'http://server/img1/info.json', status=418) + self.img.status = S3FileStatus.Unchecked + with self.assertRaises(requests.HTTPError): + self.img.perform_check(raise_exc=True, save=False) + self.assertEqual(self.img.status, S3FileStatus.Error) + + @responses.activate + def test_required_items(self): + """ + Test Image.perform_check fails on a HTTP error code + """ + base_payload = { + '@id': 'http://server/img1', + 'width': 42, + 'height': 42, + 'profile': ['http://iiif.io/api/image/2/level2.json'], + } + for key in base_payload: + payload = base_payload.copy() + del payload[key] + responses.add(responses.GET, 'http://server/img1/info.json', json=payload) + with self.assertRaises(AssertionError, msg='Missing required properties'): + self.img.perform_check(raise_exc=True, save=False) + self.assertEqual(self.img.status, S3FileStatus.Error) + + @responses.activate + def test_check_id(self): + """ + Test Image.perform_check ensures the image is on the expected server + """ + responses.add(responses.GET, 'http://server/img1/info.json', json={ + '@id': 'http://wrongserver/img1', + 'width': 42, + 'height': 42, + 'profile': ['http://iiif.io/api/image/2/level2.json'], + }) + self.img.status = S3FileStatus.Unchecked + with self.assertRaisesRegex(AssertionError, 'Image id does not start with server url'): + self.img.perform_check(raise_exc=True, save=False) + self.assertEqual(self.img.status, S3FileStatus.Error) + + @responses.activate + def test_update_fields(self): + """ + Test Image.perform_check updates the path, width and height + """ + responses.add(responses.GET, 'http://server/img1/info.json', json={ + '@id': 'http://server/img1111', + 'width': 9000, + 'height': 8000, + 'profile': ['http://iiif.io/api/image/2/level2.json'], + }) + self.assertEqual(self.img.path, 'img1') + self.assertEqual(self.img.width, 1000) + self.assertEqual(self.img.height, 1000) + self.img.status = S3FileStatus.Unchecked + + self.img.perform_check(raise_exc=True, save=False) + + self.assertEqual(self.img.status, S3FileStatus.Checked) + self.assertEqual(self.img.path, 'img1111') + self.assertEqual(self.img.width, 9000) + self.assertEqual(self.img.height, 8000) + + @responses.activate + def test_max_size(self): + """ + Test Image.perform_check handles the IIIF maxWidth and maxHeight optional properties + """ + responses.add(responses.GET, 'http://server/img1/info.json', json={ + '@id': 'http://server/img1', + 'width': 9000, + 'height': 8000, + 'profile': [ + 'http://iiif.io/api/image/2/level2.json', + { + "maxWidth": 4500, + "maxHeight": 10000, + } + ], + }) + self.img.status = S3FileStatus.Unchecked + self.img.perform_check(raise_exc=True, save=False) + + self.assertEqual(self.img.status, S3FileStatus.Checked) + self.assertEqual(self.img.width, 4500) + self.assertEqual(self.img.height, 4000) + + @responses.activate + def test_max_height_deduced(self): + """ + Test Image.perform_check assumes maxHeight is equal to maxWidth + when maxHeight is missing and maxWidth is defined + """ + responses.add(responses.GET, 'http://server/img1/info.json', json={ + '@id': 'http://server/img1', + 'width': 1000, # width < maxWidth + 'height': 8000, # height > maxWidth + 'profile': [ + 'http://iiif.io/api/image/2/level2.json', + { + "maxWidth": 4000, + } + ], + }) + self.img.status = S3FileStatus.Unchecked + self.img.perform_check(raise_exc=True, save=False) + + self.assertEqual(self.img.status, S3FileStatus.Checked) + self.assertEqual(self.img.width, 500) + self.assertEqual(self.img.height, 4000) + + @responses.activate + def test_harvard_non_conform(self): + """ + Test Image.perform_check falls back to properties from the Image Information object + when they are not defined in the profile description object or when it is missing. + """ + responses.add(responses.GET, 'http://server/img1/info.json', json={ + '@id': 'http://server/img1', + 'width': 9001, + 'maxWidth': 9000, + 'height': 9001, + 'maxHeight': 8999, + 'profile': ['http://iiif.io/api/image/2/level2.json'], + }) + + self.img.status = S3FileStatus.Unchecked + self.img.perform_check(raise_exc=True, save=False) + + self.assertEqual(self.img.status, S3FileStatus.Checked) + self.assertEqual(self.img.width, 8999) + self.assertEqual(self.img.height, 8999) diff --git a/tests-requirements.txt b/tests-requirements.txt index 243c31c1b200b537ce04f5aa4dc6d2033824c9e3..6c1358c164ff9bac9813f2de73bb6a8f2e9d9b3d 100644 --- a/tests-requirements.txt +++ b/tests-requirements.txt @@ -3,3 +3,4 @@ tripoli django-nose coverage uritemplate==3 +responses