folio-migration-tools 1.2.1__py3-none-any.whl → 1.9.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. folio_migration_tools/__init__.py +11 -0
  2. folio_migration_tools/__main__.py +169 -85
  3. folio_migration_tools/circulation_helper.py +96 -59
  4. folio_migration_tools/config_file_load.py +66 -0
  5. folio_migration_tools/custom_dict.py +6 -4
  6. folio_migration_tools/custom_exceptions.py +21 -19
  7. folio_migration_tools/extradata_writer.py +46 -0
  8. folio_migration_tools/folder_structure.py +63 -66
  9. folio_migration_tools/helper.py +29 -21
  10. folio_migration_tools/holdings_helper.py +57 -34
  11. folio_migration_tools/i18n_config.py +9 -0
  12. folio_migration_tools/library_configuration.py +173 -13
  13. folio_migration_tools/mapper_base.py +317 -106
  14. folio_migration_tools/mapping_file_transformation/courses_mapper.py +203 -0
  15. folio_migration_tools/mapping_file_transformation/holdings_mapper.py +83 -69
  16. folio_migration_tools/mapping_file_transformation/item_mapper.py +98 -94
  17. folio_migration_tools/mapping_file_transformation/manual_fee_fines_mapper.py +352 -0
  18. folio_migration_tools/mapping_file_transformation/mapping_file_mapper_base.py +702 -223
  19. folio_migration_tools/mapping_file_transformation/notes_mapper.py +90 -0
  20. folio_migration_tools/mapping_file_transformation/order_mapper.py +492 -0
  21. folio_migration_tools/mapping_file_transformation/organization_mapper.py +389 -0
  22. folio_migration_tools/mapping_file_transformation/ref_data_mapping.py +38 -27
  23. folio_migration_tools/mapping_file_transformation/user_mapper.py +149 -361
  24. folio_migration_tools/marc_rules_transformation/conditions.py +650 -246
  25. folio_migration_tools/marc_rules_transformation/holdings_statementsparser.py +292 -130
  26. folio_migration_tools/marc_rules_transformation/hrid_handler.py +244 -0
  27. folio_migration_tools/marc_rules_transformation/loc_language_codes.xml +20846 -0
  28. folio_migration_tools/marc_rules_transformation/marc_file_processor.py +300 -0
  29. folio_migration_tools/marc_rules_transformation/marc_reader_wrapper.py +136 -0
  30. folio_migration_tools/marc_rules_transformation/rules_mapper_authorities.py +241 -0
  31. folio_migration_tools/marc_rules_transformation/rules_mapper_base.py +681 -201
  32. folio_migration_tools/marc_rules_transformation/rules_mapper_bibs.py +395 -429
  33. folio_migration_tools/marc_rules_transformation/rules_mapper_holdings.py +531 -100
  34. folio_migration_tools/migration_report.py +85 -38
  35. folio_migration_tools/migration_tasks/__init__.py +1 -3
  36. folio_migration_tools/migration_tasks/authority_transformer.py +119 -0
  37. folio_migration_tools/migration_tasks/batch_poster.py +911 -198
  38. folio_migration_tools/migration_tasks/bibs_transformer.py +121 -116
  39. folio_migration_tools/migration_tasks/courses_migrator.py +192 -0
  40. folio_migration_tools/migration_tasks/holdings_csv_transformer.py +252 -247
  41. folio_migration_tools/migration_tasks/holdings_marc_transformer.py +321 -115
  42. folio_migration_tools/migration_tasks/items_transformer.py +264 -84
  43. folio_migration_tools/migration_tasks/loans_migrator.py +506 -195
  44. folio_migration_tools/migration_tasks/manual_fee_fines_transformer.py +187 -0
  45. folio_migration_tools/migration_tasks/migration_task_base.py +364 -74
  46. folio_migration_tools/migration_tasks/orders_transformer.py +373 -0
  47. folio_migration_tools/migration_tasks/organization_transformer.py +451 -0
  48. folio_migration_tools/migration_tasks/requests_migrator.py +130 -62
  49. folio_migration_tools/migration_tasks/reserves_migrator.py +253 -0
  50. folio_migration_tools/migration_tasks/user_transformer.py +180 -139
  51. folio_migration_tools/task_configuration.py +46 -0
  52. folio_migration_tools/test_infrastructure/__init__.py +0 -0
  53. folio_migration_tools/test_infrastructure/mocked_classes.py +406 -0
  54. folio_migration_tools/transaction_migration/legacy_loan.py +148 -34
  55. folio_migration_tools/transaction_migration/legacy_request.py +65 -25
  56. folio_migration_tools/transaction_migration/legacy_reserve.py +47 -0
  57. folio_migration_tools/transaction_migration/transaction_result.py +12 -1
  58. folio_migration_tools/translations/en.json +476 -0
  59. folio_migration_tools-1.9.10.dist-info/METADATA +169 -0
  60. folio_migration_tools-1.9.10.dist-info/RECORD +67 -0
  61. {folio_migration_tools-1.2.1.dist-info → folio_migration_tools-1.9.10.dist-info}/WHEEL +1 -2
  62. folio_migration_tools-1.9.10.dist-info/entry_points.txt +3 -0
  63. folio_migration_tools/generate_schemas.py +0 -46
  64. folio_migration_tools/mapping_file_transformation/mapping_file_mapping_base_impl.py +0 -44
  65. folio_migration_tools/mapping_file_transformation/user_mapper_base.py +0 -212
  66. folio_migration_tools/marc_rules_transformation/bibs_processor.py +0 -163
  67. folio_migration_tools/marc_rules_transformation/holdings_processor.py +0 -284
  68. folio_migration_tools/report_blurbs.py +0 -219
  69. folio_migration_tools/transaction_migration/legacy_fee_fine.py +0 -36
  70. folio_migration_tools-1.2.1.dist-info/METADATA +0 -134
  71. folio_migration_tools-1.2.1.dist-info/RECORD +0 -50
  72. folio_migration_tools-1.2.1.dist-info/top_level.txt +0 -1
  73. {folio_migration_tools-1.2.1.dist-info → folio_migration_tools-1.9.10.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,90 @@
1
+ import logging
2
+ import i18n
3
+
4
+ from folio_uuid.folio_namespaces import FOLIONamespaces
5
+ from folioclient import FolioClient
6
+
7
+ from folio_migration_tools.library_configuration import LibraryConfiguration
8
+ from folio_migration_tools.mapping_file_transformation.mapping_file_mapper_base import (
9
+ MappingFileMapperBase,
10
+ )
11
+
12
+
13
+ class NotesMapper(MappingFileMapperBase):
14
+ def __init__(
15
+ self,
16
+ library_configuration: LibraryConfiguration,
17
+ task_configuration,
18
+ folio_client: FolioClient,
19
+ record_map: dict,
20
+ object_type: FOLIONamespaces,
21
+ ignore_legacy_identifier: bool = False,
22
+ ) -> None:
23
+ self.folio_client: FolioClient = folio_client
24
+ self.setup_notes_schema()
25
+ super().__init__(
26
+ folio_client,
27
+ self.notes_schema,
28
+ record_map,
29
+ None,
30
+ object_type,
31
+ library_configuration,
32
+ task_configuration,
33
+ ignore_legacy_identifier,
34
+ )
35
+
36
+ self.noteprops = {
37
+ "data": [p for p in record_map["data"] if p["folio_field"].startswith("notes[")]
38
+ }
39
+ logging.info("Set %s props used for note mapping", len(self.noteprops["data"]))
40
+ logging.info("Initiated mapper for Notes")
41
+
42
+ def setup_notes_schema(self):
43
+ notes_schemas = self.get_notes_schema()
44
+ self.notes_schema = notes_schemas["noteCollection"]
45
+ self.notes_schema["properties"]["notes"]["items"] = notes_schemas["note"]
46
+ self.notes_schema["required"] = []
47
+
48
+ def map_notes(self, legacy_object, legacy_id, object_uuid: str, record_type: FOLIONamespaces):
49
+ if any(self.noteprops["data"]):
50
+ for note in self.do_map(legacy_object, legacy_id, FOLIONamespaces.note)[0].get(
51
+ "notes", []
52
+ ):
53
+ if note.get("content", "").strip():
54
+ type_string = {
55
+ FOLIONamespaces.users: "user",
56
+ FOLIONamespaces.course: "course",
57
+ FOLIONamespaces.organizations: "organization",
58
+ FOLIONamespaces.orders: "orders",
59
+ }.get(record_type)
60
+ note["links"] = [{"id": object_uuid, "type": type_string}]
61
+ if "type" in note:
62
+ del note["type"]
63
+ self.extradata_writer.write("notes", note)
64
+ self.migration_report.add_general_statistics(
65
+ i18n.t("Number of linked notes created")
66
+ )
67
+ self.migration_report.add("MappedNoteTypes", note["typeId"])
68
+ else:
69
+ self.migration_report.add_general_statistics(
70
+ i18n.t("Number of discarded notes with no content")
71
+ )
72
+
73
+ def get_notes_schema(self):
74
+ notes_schema = self.folio_client.get_from_github(
75
+ "folio-org",
76
+ "mod-notes",
77
+ "src/main/resources/swagger.api/schemas/note.yaml",
78
+ )
79
+ notes_common = self.folio_client.get_from_github(
80
+ "folio-org",
81
+ "mod-notes",
82
+ "src/main/resources/swagger.api/schemas/common.yaml",
83
+ )
84
+ for prop in notes_schema["note"]["properties"].items():
85
+ if prop[1].get("$ref", "") == "common.yaml#/uuid":
86
+ prop[1]["type"] = notes_common["uuid"]["type"]
87
+
88
+ for p in ["links", "metadata", "id"]:
89
+ del notes_schema["note"]["properties"][p]
90
+ return notes_schema
@@ -0,0 +1,492 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import sys
6
+ import urllib.parse
7
+ import uuid
8
+
9
+ import httpx
10
+ import i18n
11
+ from folio_uuid.folio_uuid import FOLIONamespaces
12
+ from folioclient import FolioClient
13
+ from httpx import HTTPError
14
+
15
+ from folio_migration_tools.custom_exceptions import TransformationRecordFailedError
16
+ from folio_migration_tools.helper import Helper
17
+ from folio_migration_tools.library_configuration import LibraryConfiguration
18
+ from folio_migration_tools.mapping_file_transformation.mapping_file_mapper_base import (
19
+ MappingFileMapperBase,
20
+ )
21
+ from folio_migration_tools.mapping_file_transformation.notes_mapper import NotesMapper
22
+ from folio_migration_tools.mapping_file_transformation.ref_data_mapping import (
23
+ RefDataMapping,
24
+ )
25
+
26
+
27
+ class CompositeOrderMapper(MappingFileMapperBase):
28
+
29
+ def __init__(
30
+ self,
31
+ folio_client: FolioClient,
32
+ library_configuration: LibraryConfiguration,
33
+ task_configuration,
34
+ composite_order_map: dict,
35
+ organizations_id_map: dict,
36
+ instance_id_map: dict,
37
+ acquisition_method_map,
38
+ payment_status_map,
39
+ receipt_status_map,
40
+ workflow_status_map,
41
+ location_map,
42
+ funds_map,
43
+ funds_expense_class_map=None,
44
+ ):
45
+ # Get organization schema
46
+ self.composite_order_schema = CompositeOrderMapper.get_latest_acq_schemas_from_github(
47
+ "folio-org", "mod-orders", "mod-orders", "composite_purchase_order"
48
+ )
49
+
50
+ super().__init__(
51
+ folio_client,
52
+ self.composite_order_schema,
53
+ composite_order_map,
54
+ None,
55
+ FOLIONamespaces.orders,
56
+ library_configuration,
57
+ task_configuration,
58
+ )
59
+ logging.info("Loading Instance ID map...")
60
+ self.instance_id_map = instance_id_map
61
+ self.organizations_id_map = organizations_id_map
62
+ self.folio_organization_cache = {}
63
+
64
+ self.acquisitions_methods_mapping = RefDataMapping(
65
+ self.folio_client,
66
+ "/orders/acquisition-methods",
67
+ "acquisitionMethods",
68
+ acquisition_method_map,
69
+ "value",
70
+ "AcquisitionMethodMapping",
71
+ )
72
+ logging.info("Init done")
73
+ self.location_mapping = RefDataMapping(
74
+ self.folio_client,
75
+ "/locations",
76
+ "locations",
77
+ location_map,
78
+ "code",
79
+ "OrderLineLocationMapping",
80
+ )
81
+ self.funds_mapping = RefDataMapping(
82
+ self.folio_client,
83
+ "/finance/funds",
84
+ "funds",
85
+ funds_map,
86
+ "code",
87
+ "FundsMapping",
88
+ )
89
+
90
+ self.folio_client: FolioClient = folio_client
91
+ self.notes_mapper: NotesMapper = NotesMapper(
92
+ library_configuration,
93
+ None,
94
+ self.folio_client,
95
+ composite_order_map,
96
+ FOLIONamespaces.note,
97
+ True,
98
+ )
99
+ self.notes_mapper.migration_report = self.migration_report
100
+
101
+ def get_prop(self, legacy_order, folio_prop_name: str, index_or_id, schema_default_value):
102
+ if folio_prop_name.endswith(".acquisitionMethod"):
103
+ return self.get_mapped_ref_data_value(
104
+ self.acquisitions_methods_mapping,
105
+ legacy_order,
106
+ folio_prop_name,
107
+ index_or_id,
108
+ False,
109
+ )
110
+
111
+ elif re.compile(r"compositePoLines\[(\d+)\]\.id").fullmatch(folio_prop_name):
112
+ return str(uuid.uuid4())
113
+
114
+ elif re.compile(r"notes\[\d+\]\.").match(folio_prop_name):
115
+ return ""
116
+
117
+ if folio_prop_name.endswith(".locationId"):
118
+ return self.get_mapped_ref_data_value(
119
+ self.location_mapping,
120
+ legacy_order,
121
+ folio_prop_name,
122
+ index_or_id,
123
+ False,
124
+ )
125
+
126
+ if folio_prop_name.endswith(".fundId"):
127
+ return self.get_mapped_ref_data_value(
128
+ self.location_mapping,
129
+ legacy_order,
130
+ folio_prop_name,
131
+ index_or_id,
132
+ False,
133
+ )
134
+
135
+ mapped_value = super().get_prop(
136
+ legacy_order, folio_prop_name, index_or_id, schema_default_value
137
+ )
138
+
139
+ return mapped_value
140
+
141
+ @staticmethod
142
+ def get_latest_acq_schemas_from_github(owner, repo, module, object):
143
+ """
144
+ Given a repository owner, a repository, a module name and the name
145
+ of a FOLIO acquisition object, returns a schema for that object that
146
+ also includes the schemas of any other referenced acq objects.
147
+
148
+ Args:
149
+ owner (_type_): _description_
150
+ repo (_type_): _description_
151
+ module (_type_): _description_
152
+ object (_type_): _description_
153
+
154
+ Returns:
155
+ _type_: _description_
156
+ """
157
+ try:
158
+ # Authenticate when calling GitHub, using an API key stored in .env
159
+ github_headers = {
160
+ "content-type": "application/json",
161
+ "User-Agent": "FOLIO Migration Tools (https://github.com/FOLIO-FSE/folio_migration_tools/)", # noqa:E501,B950
162
+ }
163
+
164
+ if os.environ.get("GITHUB_TOKEN"):
165
+ logging.info("Using GITHUB_TOKEN environment variable for GitHub API Access")
166
+ github_headers["authorization"] = f"token {os.environ.get('GITHUB_TOKEN')}"
167
+
168
+ # Start talkign to GitHub...
169
+ github_path = "https://raw.githubusercontent.com"
170
+ submodules = CompositeOrderMapper.get_submodules_of_latest_release(
171
+ owner, repo, github_headers
172
+ )
173
+
174
+ # Get the sha's of submodules acq-models and raml_utils
175
+ acq_models_sha = next(
176
+ (item["sha"] for item in submodules if item["path"] == "acq-models")
177
+ )
178
+
179
+ # # TODO Maybe - fetch raml_utils schemas if deemed necessary
180
+ # raml_utils_sha = next((item["sha"] for item in submodules
181
+ # if item["path"] == "raml-utils"))
182
+
183
+ acq_models_path = (
184
+ f"{github_path}/{owner}/acq-models/{acq_models_sha}/{module}/schemas/"
185
+ )
186
+
187
+ req = httpx.get(
188
+ f"{acq_models_path}/{object}.json",
189
+ headers=github_headers,
190
+ follow_redirects=True,
191
+ timeout=None,
192
+ )
193
+ req.raise_for_status()
194
+
195
+ object_schema = json.loads(req.text)
196
+
197
+ return CompositeOrderMapper.build_extended_object(
198
+ object_schema, acq_models_path, github_headers
199
+ )
200
+ except httpx.HTTPError as http_error:
201
+ logging.critical(f"Halting! \t{http_error}")
202
+ sys.exit(2)
203
+
204
+ except json.decoder.JSONDecodeError as json_error:
205
+ logging.critical(json_error)
206
+ sys.exit(2)
207
+
208
+ @staticmethod
209
+ def get_submodules_of_latest_release(owner, repo, github_headers):
210
+ """
211
+ Given a repository owner and a repository, identifies the latest
212
+ release of the repository and returns the submodules associated with
213
+ this release.
214
+
215
+ Args:
216
+ owner (_type_): _description_
217
+ repo (_type_): _description_
218
+ github_headers (_type_): _description_
219
+
220
+ Returns:
221
+ _type_: _description_
222
+
223
+
224
+ """
225
+
226
+ github_path = "https://api.github.com/repos"
227
+
228
+ # Get metadata for the latest release
229
+ latest_release_path = f"{github_path}/{owner}/{repo}/releases/latest"
230
+ req = httpx.get(
231
+ f"{latest_release_path}", headers=github_headers, follow_redirects=True, timeout=None
232
+ )
233
+ req.raise_for_status()
234
+ latest_release = json.loads(req.text)
235
+
236
+ # Get the tag assigned to the latest release
237
+ release_tag = latest_release["tag_name"]
238
+ logging.info(f"Using schemas from latest {repo} release: {release_tag}")
239
+
240
+ # Get the tree for the latest release
241
+ tree_path = f"{github_path}/{owner}/{repo}/git/trees/{release_tag}"
242
+ req = httpx.get(tree_path, headers=github_headers, follow_redirects=True, timeout=None)
243
+ req.raise_for_status()
244
+ release_tree = json.loads(req.text)
245
+
246
+ # Loop through the tree to get the sha of the folder with path "ramls"
247
+ ramls_sha = next((item["sha"] for item in release_tree["tree"] if item["path"] == "ramls"))
248
+
249
+ # Get the tree for the ramls folder
250
+ ramls_path = f"{github_path}/{owner}/{repo}/git/trees/{ramls_sha}"
251
+ req = httpx.get(ramls_path, headers=github_headers, follow_redirects=True, timeout=None)
252
+ req.raise_for_status()
253
+ ramls_tree = json.loads(req.text)
254
+
255
+ # Loop through the tree to get the sha of submodules
256
+ submodules = [item for item in ramls_tree["tree"] if item["mode"] == "160000"]
257
+
258
+ return submodules
259
+
260
+ @staticmethod
261
+ def build_extended_object(object_schema, submodule_path, github_headers):
262
+ """
263
+ Takes an object schema (for example an organization) and the path to a
264
+ submodule repository and returns the same schema with the full schemas
265
+ of subordinate objects (for example aliases).
266
+
267
+ Args:
268
+ object_schema (_type_): _description_
269
+ submodule_path (_type_): _description_
270
+ github_headers (_type_): _description_
271
+
272
+ Returns:
273
+ _type_: _description_
274
+ """
275
+
276
+ supported_types = [
277
+ "string",
278
+ "boolean",
279
+ "number",
280
+ "integer",
281
+ "text",
282
+ "object",
283
+ "array",
284
+ ]
285
+
286
+ try:
287
+ if (
288
+ "properties" not in object_schema
289
+ and "$ref" in object_schema
290
+ and object_schema["type"] == "object"
291
+ ):
292
+ object_schema["properties"] = CompositeOrderMapper.inject_schema_by_ref(
293
+ submodule_path, github_headers, object_schema
294
+ ).get("properties", {})#TODO: Investigate new CustomFields schema and figure out how to actually handle it
295
+
296
+ for property_name_level1, property_level1 in object_schema.get(
297
+ "properties", {}
298
+ ).items():
299
+ # Report and discard unhandled properties
300
+ if property_level1.get("type") not in supported_types:
301
+ logging.info(f"Property not yet supported: {property_name_level1}")
302
+ property_level1["type"] = "Deprecated"
303
+
304
+ # Handle object properties
305
+ elif property_level1.get("type") == "object" and property_level1.get("$ref"):
306
+ logging.info("Fecthing referenced schema for object %s", property_name_level1)
307
+ actual_path = urllib.parse.urljoin(
308
+ f"{submodule_path}", object_schema.get("$ref", "")
309
+ )
310
+
311
+ p1 = CompositeOrderMapper.inject_schema_by_ref(
312
+ actual_path, github_headers, property_level1
313
+ )
314
+
315
+ p2 = CompositeOrderMapper.build_extended_object(
316
+ p1, actual_path, github_headers
317
+ )
318
+ object_schema["properties"][property_name_level1] = p2
319
+
320
+ # Handle arrays of items properties
321
+ elif property_level1.get("type") == "array" and property_level1.get("items").get(
322
+ "$ref"
323
+ ):
324
+ logging.info(
325
+ "Fetching referenced schema for array object %s", property_name_level1
326
+ )
327
+ actual_path = urllib.parse.urljoin(
328
+ f"{submodule_path}", object_schema.get("$ref", "")
329
+ )
330
+
331
+ p1 = CompositeOrderMapper.inject_items_schema_by_ref(
332
+ actual_path, github_headers, property_level1
333
+ )
334
+ p2 = CompositeOrderMapper.build_extended_object(
335
+ p1, actual_path, github_headers
336
+ )
337
+ property_level1["items"] = p2
338
+ elif property_level1.get("type") == "string" and property_level1.get("$ref"):
339
+ logging.info("Fetching referenced schema for object %s", property_name_level1)
340
+ actual_path = urllib.parse.urljoin(
341
+ f"{submodule_path}", object_schema.get("$ref", "")
342
+ )
343
+ p1 = CompositeOrderMapper.inject_schema_by_ref(
344
+ actual_path, github_headers, property_level1
345
+ )
346
+ object_schema["properties"][property_name_level1] = p1
347
+
348
+ return object_schema
349
+
350
+ except HTTPError as he:
351
+ logging.error(he)
352
+
353
+ @staticmethod
354
+ def inject_schema_by_ref(submodule_path, github_headers, property: dict):
355
+ base_raml = "https://raw.githubusercontent.com/folio-org/raml/master/"
356
+ try:
357
+ u1 = urllib.parse.urlparse(submodule_path)
358
+ schema_url = urllib.parse.urljoin(u1.geturl(), property["$ref"])
359
+ if schema_url.endswith("tags.schema"):
360
+ schema_url = f"{base_raml}schemas/tags.schema"
361
+ if schema_url.endswith("metadata.schema"):
362
+ schema_url = f"{base_raml}schemas/metadata.schema"
363
+
364
+ req = httpx.get(
365
+ schema_url, headers=github_headers, follow_redirects=True, timeout=None
366
+ )
367
+ req.raise_for_status()
368
+ return dict(property, **json.loads(req.text))
369
+ except Exception as ee:
370
+ logging.error(ee)
371
+ return {}
372
+
373
+ @staticmethod
374
+ def inject_items_schema_by_ref(submodule_path, github_headers, property: dict):
375
+ try:
376
+ u1 = urllib.parse.urlparse(submodule_path)
377
+ schema_url = urllib.parse.urljoin(u1.geturl(), property["items"]["$ref"])
378
+ req = httpx.get(
379
+ schema_url, headers=github_headers, follow_redirects=True, timeout=None
380
+ )
381
+ req.raise_for_status()
382
+ return dict(property["items"], **json.loads(req.text))
383
+ except Exception as ee:
384
+ logging.error(ee)
385
+ return {}
386
+
387
+ def perform_additional_mapping(self, index_or_id, composite_order):
388
+ self.validate_po_number(index_or_id, composite_order.get("poNumber"))
389
+
390
+ # Get organization UUID from FOLIO
391
+ composite_order["vendor"] = self.get_folio_organization_uuid(
392
+ index_or_id, composite_order.get("vendor")
393
+ )
394
+
395
+ # Replace legacy bib ID with instance UUID from map
396
+ bib_id = composite_order["compositePoLines"][0].pop("instanceId", "")
397
+ if matching_instance := self.get_folio_instance_uuid(index_or_id, bib_id):
398
+ composite_order["compositePoLines"][0]["instanceId"] = matching_instance
399
+
400
+ return composite_order
401
+
402
+ def validate_po_number(
403
+ self,
404
+ index_or_id: str,
405
+ po_number: str,
406
+ ):
407
+ if not self.is_valid_po_number(po_number):
408
+ self.migration_report.add(
409
+ "PurchaseOrderVendorLinking",
410
+ i18n.t("RECORD FAILED: PO number has invalid character(s)"),
411
+ )
412
+ raise TransformationRecordFailedError(
413
+ index_or_id,
414
+ "Purchase Order number has invalid character(s)",
415
+ po_number,
416
+ )
417
+
418
+ @staticmethod
419
+ def is_valid_po_number(po_number: str) -> bool:
420
+ valid_po_number_characters = r"[A-Za-z0-9]"
421
+ return re.sub(valid_po_number_characters, "", po_number) == ""
422
+
423
+ def get_matching_record_from_folio(
424
+ self,
425
+ index_or_id,
426
+ cache: dict,
427
+ path: str,
428
+ match_property: str,
429
+ match_value: str,
430
+ result_type: str,
431
+ ):
432
+ if match_value in cache:
433
+ return cache[match_value]
434
+ else:
435
+ query = f'?query=({match_property}=="{match_value}")'
436
+ if matching_record := next(
437
+ self.folio_client.folio_get_all(path, result_type, query), None
438
+ ):
439
+ cache[match_value] = matching_record
440
+ return matching_record
441
+
442
+ def get_folio_organization_uuid(self, index_or_id, org_code):
443
+ if self.organizations_id_map:
444
+ self.migration_report.add(
445
+ "PurchaseOrderVendorLinking",
446
+ i18n.t("Organizations linked using organizations_id_map"),
447
+ )
448
+ if matching_org := self.organizations_id_map.get(org_code):
449
+ return matching_org[1]
450
+
451
+ if matching_org := self.get_matching_record_from_folio(
452
+ index_or_id,
453
+ self.folio_organization_cache,
454
+ "/organizations-storage/organizations",
455
+ "code",
456
+ org_code,
457
+ "organizations",
458
+ ):
459
+ self.migration_report.add(
460
+ "PurchaseOrderVendorLinking",
461
+ i18n.t("Organizations not in ID map, linked using FOLIO lookup"),
462
+ )
463
+ return matching_org["id"]
464
+
465
+ else:
466
+ self.migration_report.add(
467
+ "PurchaseOrderVendorLinking",
468
+ i18n.t("RECORD FAILED Organization identifier not in ID map/FOLIO"),
469
+ )
470
+ raise TransformationRecordFailedError(
471
+ index_or_id,
472
+ "No matching Organization for organization identifier",
473
+ org_code,
474
+ )
475
+
476
+ def get_folio_instance_uuid(self, index_or_id, bib_id):
477
+ if matching_instance := self.instance_id_map.get(bib_id):
478
+ self.migration_report.add(
479
+ "PurchaseOrderInstanceLinking",
480
+ i18n.t("Instances linked using instances_id_map"),
481
+ )
482
+ return matching_instance[1]
483
+ else:
484
+ self.migration_report.add(
485
+ "PurchaseOrderInstanceLinking",
486
+ i18n.t("Bib identifier not in instances_id_map, no instance linked"),
487
+ )
488
+ Helper.log_data_issue(
489
+ index_or_id,
490
+ "No matching instance for bib identifier",
491
+ bib_id,
492
+ )