From 23bd1996d39dda57d6c56f08c1b3feaad4f50364 Mon Sep 17 00:00:00 2001 From: Hillel Arnold Date: Mon, 5 Oct 2026 18:31:47 -0400 Subject: [PATCH 1/5] add file version resource and controlled values --- src/resources/configs.py | 6 ++++++ src/resources/source.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/resources/configs.py b/src/resources/configs.py index 1e1eeba..4c9c312 100644 --- a/src/resources/configs.py +++ b/src/resources/configs.py @@ -509,3 +509,9 @@ ('temporal', 'Temporal'), ('topical', 'Topical'), ) + +FILE_VERSION_USE_STATEMENT_CHOICES = ( + ('aip', 'aip'), + ('download', 'download'), + ('iiif-manifest', 'iiif-manifest'), +) diff --git a/src/resources/source.py b/src/resources/source.py index 3a9f1cb..edda74f 100644 --- a/src/resources/source.py +++ b/src/resources/source.py @@ -65,10 +65,17 @@ class SourceStructuredDate(odin.Resource): structured_date_range = odin.DictAs(SourceStructuredDateRange, null=True) +class SourceDigitalObjectFileVersion(odin.Resource): + """A file version for digital objects.""" + use_statement = odin.StringField(choices=configs.FILE_VERSION_USE_STATEMENT_CHOICES) + + class SourceDigitalObject(odin.Resource): + """A digital representation of an archival object.""" title = odin.StringField() digital_object_id = odin.StringField() publish = odin.BooleanField() + file_versions = odin.ListOf(SourceDigitalObjectFileVersion, null=True) class SourceExtent(odin.Resource): From 4dde882232a4ba451000de578fd3285bbc4aded3 Mon Sep 17 00:00:00 2001 From: Hillel Arnold Date: Mon, 5 Oct 2026 18:32:13 -0400 Subject: [PATCH 2/5] handle file version mapping --- src/mappings.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/mappings.py b/src/mappings.py index 0e07255..3bf460a 100644 --- a/src/mappings.py +++ b/src/mappings.py @@ -653,21 +653,20 @@ def files(self, value): files = [] for instance in value: if instance.digital_object and instance.digital_object.publish: - files.append( - FileObject( - title=instance.digital_object.title, - download=generate_download_identifier( - self.source.to_dict(), - instance.digital_object.to_dict(), - self.context - ), - manifest=generate_manifest_identifier( - self.source.to_dict(), - instance.digital_object.to_dict(), - self.context - ) + file_object = FileObject(title=instance.digital_object.title) + if any([f.use_statement == 'download' for f in instance.digital_object.file_versions]): + file_object.download = generate_download_identifier( + self.source.to_dict(), + instance.digital_object.to_dict(), + self.context ) - ) + if any([f.use_statement == 'iiif-manifest' for f in instance.digital_object.file_versions]): + file_object.manifest = generate_manifest_identifier( + self.source.to_dict(), + instance.digital_object.to_dict(), + self.context) + if any([file_object.download, file_object.manifest]): + files.append(file_object) return files @odin.map_field(from_field="group", to_field="group") From 06626680ac4e29e3251b83c09dec699913ecf8b2 Mon Sep 17 00:00:00 2001 From: Hillel Arnold Date: Mon, 5 Oct 2026 18:32:34 -0400 Subject: [PATCH 3/5] remove keys with null values --- src/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transformer.py b/src/transformer.py index 7e67789..b5a070e 100644 --- a/src/transformer.py +++ b/src/transformer.py @@ -103,7 +103,7 @@ def remove_keys_from_dict(self, data, target_key="$"): modified_dict = {} if hasattr(data, "items"): for key, value in data.items(): - if key != target_key: + if (value is not None and key != target_key): if isinstance(value, dict): modified_dict[key] = self.remove_keys_from_dict( value, target_key=target_key) From 2d429ad7b1550e0948575bc0b6bb8896d6205ab8 Mon Sep 17 00:00:00 2001 From: Hillel Arnold Date: Mon, 5 Oct 2026 18:33:06 -0400 Subject: [PATCH 4/5] update tests to cover new logic; --- .../transformer/archival_object/2329.json | 2 +- tests/test_mapping_classes.py | 64 +++++++++++++++---- tests/test_transformer.py | 17 +++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/tests/fixtures/transformer/archival_object/2329.json b/tests/fixtures/transformer/archival_object/2329.json index 2f7d67d..c1a77fb 100644 --- a/tests/fixtures/transformer/archival_object/2329.json +++ b/tests/fixtures/transformer/archival_object/2329.json @@ -259,7 +259,7 @@ "lock_version": 0, "publish": true, "system_mtime": "2022-05-14T15:33:00Z", - "use_statement": "master", + "use_statement": "download", "user_mtime": "2022-05-14T15:33:00Z" } ], diff --git a/tests/test_mapping_classes.py b/tests/test_mapping_classes.py index fcc07fc..849890b 100644 --- a/tests/test_mapping_classes.py +++ b/tests/test_mapping_classes.py @@ -672,27 +672,65 @@ def test_online(self, mock_online_instance): @patch('src.mappings.generate_download_identifier') @patch('src.mappings.generate_manifest_identifier') def test_files(self, mock_manifest_id, mock_download_id): + # TODO add tests for file versions mock_manifest_id.return_value = "manifest_id" mock_download_id.return_value = "download_id" self.mapping.source = RecursiveNamespace.from_obj({}) self.mapping.context = "https://context.org" output = self.mapping.files([ - RecursiveNamespace.from_obj({"digital_object": {"title": "digital object title", "publish": True}}), - RecursiveNamespace.from_obj({"digital_object": {"title": "digital object title", "publish": False}}), - RecursiveNamespace.from_obj({"digital_object": None})]) + RecursiveNamespace.from_obj({ + "digital_object": { + "title": "digital object title", + "publish": True, + "file_versions": [ + RecursiveNamespace.from_obj({"use_statement": "download"}), + RecursiveNamespace.from_obj({"use_statement": "iiif-manifest"}) + ]}}), + RecursiveNamespace.from_obj({ + "digital_object": { + "title": "digital object title", + "publish": True, + "file_versions": [ + RecursiveNamespace.from_obj({"use_statement": "download"})]}}), + RecursiveNamespace.from_obj({ + "digital_object": { + "title": "digital object title", + "publish": True, + "file_versions": [ + RecursiveNamespace.from_obj({"use_statement": "aip"})]}}), + RecursiveNamespace.from_obj({ + "digital_object": { + "title": "digital object title", + "publish": False, + "file_versions": []}}), + RecursiveNamespace.from_obj({"digital_object": None}) + ]) self.assertEqual( json.loads(json_codec.dumps(output)), - [{ - 'title': 'digital object title', - 'download': 'download_id', - 'manifest': 'manifest_id', - '$': 'src.resources.rac.FileObject' - }]) - - mock_download_id.assert_called_once_with( - {}, {"title": "digital object title", "publish": True}, "https://context.org") + [ + { + "$": "src.resources.rac.FileObject", + "title": "digital object title", + "download": "download_id", + "manifest": "manifest_id" + }, + { + "$": "src.resources.rac.FileObject", + "title": "digital object title", + "download": "download_id", + "manifest": None + } + ]) + + mock_download_id.assert_has_calls([ + call({}, {'title': 'digital object title', 'publish': True, 'file_versions': [ + {'use_statement': 'download'}, {'use_statement': 'iiif-manifest'}]}, 'https://context.org'), + call({}, {'title': 'digital object title', 'publish': True, 'file_versions': [{'use_statement': 'download'}]}, 'https://context.org') + ]) mock_manifest_id.assert_called_once_with( - {}, {"title": "digital object title", "publish": True}, "https://context.org") + {}, {'title': 'digital object title', 'publish': True, 'file_versions': [ + {'use_statement': 'download'}, {'use_statement': 'iiif-manifest'}]}, 'https://context.org' + ) @patch('src.mappings.transform_group') def test_group(self, mock_group): diff --git a/tests/test_transformer.py b/tests/test_transformer.py index fc9927c..224db8e 100644 --- a/tests/test_transformer.py +++ b/tests/test_transformer.py @@ -185,6 +185,23 @@ def test_remove_keys_from_dict_removes_nested_dollar_keys(self): self.assertEqual(cleaned["lst"][0], "a") self.assertEqual(cleaned["lst"][1], {"c": 4}) + def test_remove_keys_from_dict_removes_null_values(self): + payload = { + "uri": "/x/1", + "null": None, + "nested": {"keep": 1, "items": [{"a": 1}, {"b": None}]}, + "lst": ["a", {"c": None}], + } + cleaned = self.transformer.remove_keys_from_dict( + payload, target_key="$") + self.assertEqual( + cleaned, + { + "uri": "/x/1", + "nested": {"keep": 1, "items": [{"a": 1}, {}]}, + "lst": ["a", {}], + }) + @patch('src.transformer.is_valid') def test_validate_transformed(self, mock_is_valid): schema_name = 'agent.json' From ac2246cf9fd840d5e0476a3210977d21ed13b160 Mon Sep 17 00:00:00 2001 From: Hillel Arnold Date: Tue, 6 Oct 2026 10:45:12 -0400 Subject: [PATCH 5/5] remove null from file version --- src/resources/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/source.py b/src/resources/source.py index edda74f..96b0554 100644 --- a/src/resources/source.py +++ b/src/resources/source.py @@ -75,7 +75,7 @@ class SourceDigitalObject(odin.Resource): title = odin.StringField() digital_object_id = odin.StringField() publish = odin.BooleanField() - file_versions = odin.ListOf(SourceDigitalObjectFileVersion, null=True) + file_versions = odin.ListOf(SourceDigitalObjectFileVersion) class SourceExtent(odin.Resource):