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
@@ -1,48 +1,57 @@
1
+ import ast
2
+ import copy
3
+ import json
1
4
  import logging
2
5
  import sys
3
- import json
4
- import requests
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple, Union
10
+
11
+ import i18n
12
+ from folio_uuid.folio_namespaces import FOLIONamespaces
13
+ from folio_uuid.folio_uuid import FolioUUID
14
+ from folioclient import FolioClient
15
+ from pymarc import Record
5
16
 
6
17
  from folio_migration_tools.custom_exceptions import (
18
+ TransformationFieldMappingError,
7
19
  TransformationProcessError,
8
20
  TransformationRecordFailedError,
9
21
  )
10
- from folioclient import FolioClient
11
- from folio_uuid.folio_namespaces import FOLIONamespaces
22
+ from folio_migration_tools.extradata_writer import ExtradataWriter
23
+ from folio_migration_tools.helper import Helper
24
+ from folio_migration_tools.library_configuration import FileDefinition, LibraryConfiguration
12
25
  from folio_migration_tools.mapping_file_transformation.ref_data_mapping import (
13
26
  RefDataMapping,
14
27
  )
15
- from folio_migration_tools.library_configuration import LibraryConfiguration
16
28
  from folio_migration_tools.migration_report import MigrationReport
17
- from folio_migration_tools.report_blurbs import Blurbs
29
+ from folio_migration_tools.task_configuration import AbstractTaskConfiguration
18
30
 
19
31
 
20
32
  class MapperBase:
33
+ legacy_id_template = "Identifier(s) from previous system:"
34
+ bib_id_template = "Bib id: "
35
+
21
36
  def __init__(
22
37
  self,
23
38
  library_configuration: LibraryConfiguration,
39
+ task_configuration: AbstractTaskConfiguration,
24
40
  folio_client: FolioClient,
41
+ parent_id_map: Dict[str, Tuple] = {},
25
42
  ):
26
43
  logging.info("MapperBase initiating")
27
- self.folio_client = folio_client
28
- self.hrid_path = "/hrid-settings-storage/hrid-settings"
44
+ self.parent_id_map: dict[str, tuple] = parent_id_map
45
+ self.extradata_writer: ExtradataWriter = ExtradataWriter(Path(""))
46
+ self.start_datetime = datetime.now(timezone.utc)
47
+ self.folio_client: FolioClient = folio_client
29
48
  self.library_configuration: LibraryConfiguration = library_configuration
30
- self.hrid_settings = self.folio_client.folio_get_single_object(self.hrid_path)
31
- self.instance_hrid_prefix = self.hrid_settings["instances"]["prefix"]
32
- self.instance_hrid_counter = self.hrid_settings["instances"]["startNumber"]
33
- self.holdings_hrid_prefix = self.hrid_settings["holdings"]["prefix"]
34
- self.holdings_hrid_counter = self.hrid_settings["holdings"]["startNumber"]
35
- logging.info("Fetched HRID settings.")
36
- logging.info("Instance HRID prefix is %s", self.instance_hrid_prefix)
37
- logging.info("Instance start number is %s", self.instance_hrid_counter)
38
- logging.info("Holdings HRID prefix is %s", self.instance_hrid_prefix)
39
- logging.info("Holdings start number is %s", self.holdings_hrid_counter)
40
-
41
- self.mapped_folio_fields = {}
42
- self.migration_report = MigrationReport()
49
+ self.task_configuration: AbstractTaskConfiguration = task_configuration
50
+ self.mapped_folio_fields: dict = {}
51
+ self.migration_report: MigrationReport = MigrationReport()
43
52
  self.num_criticalerrors = 0
44
- self.num_exeptions = 0
45
- self.mapped_legacy_fields = {}
53
+ self.num_exceptions = 0
54
+ self.mapped_legacy_fields: dict = {}
46
55
  self.schema_properties = None
47
56
 
48
57
  def report_legacy_mapping(self, field_name, present, mapped):
@@ -55,7 +64,7 @@ class MapperBase:
55
64
 
56
65
  def report_folio_mapping(self, folio_record, schema):
57
66
  try:
58
- for field_name in flatten(folio_record):
67
+ for field_name in set(flatten(folio_record)):
59
68
  try:
60
69
  self.mapped_folio_fields[field_name][0] += 1
61
70
  except KeyError:
@@ -73,29 +82,42 @@ class MapperBase:
73
82
  logging.error(ee, stack_info=True)
74
83
  raise ee from ee
75
84
 
85
+ def report_legacy_mapping_no_schema(self, legacy_object):
86
+ for field_name, value in legacy_object.items():
87
+ v = 1 if value else 0
88
+ if field_name not in self.mapped_legacy_fields:
89
+ self.mapped_legacy_fields[field_name] = [1, v]
90
+ else:
91
+ self.mapped_legacy_fields[field_name][0] += 1
92
+ self.mapped_legacy_fields[field_name][1] += v
93
+
94
+ def report_folio_mapping_no_schema(self, folio_object):
95
+ for field_name in set(flatten(folio_object)):
96
+ if field_name not in self.mapped_folio_fields:
97
+ self.mapped_folio_fields[field_name] = [1, 1]
98
+ else:
99
+ self.mapped_folio_fields[field_name][0] += 1
100
+ self.mapped_folio_fields[field_name][1] += 1
101
+
76
102
  def get_mapped_name(
77
103
  self,
78
104
  ref_data_mapping: RefDataMapping,
79
105
  legacy_object,
80
106
  index_or_id,
81
- folio_property_name="",
82
107
  prevent_default=False,
83
108
  ):
84
109
  try:
85
110
  # Get the values in the fields that will be used for mapping
86
- fieldvalues = [
87
- legacy_object.get(k) for k in ref_data_mapping.mapped_legacy_keys
88
- ]
111
+ fieldvalues = [legacy_object.get(k) for k in ref_data_mapping.mapped_legacy_keys]
89
112
 
90
113
  # Gets the first line in the map satisfying all legacy mapping values.
91
114
  # Case insensitive, strips away whitespace
92
- # TODO: add option for Wild card matching in individual columns
93
115
  right_mapping = ref_data_mapping.get_ref_data_mapping(legacy_object)
94
116
 
95
117
  if not right_mapping:
96
118
  raise StopIteration()
97
119
  self.migration_report.add(
98
- ref_data_mapping.blurb,
120
+ ref_data_mapping.blurb_id,
99
121
  (
100
122
  f'{" - ".join(fieldvalues)} '
101
123
  f'-> {right_mapping[f"folio_{ref_data_mapping.key_type}"]}'
@@ -106,15 +128,12 @@ class MapperBase:
106
128
  except StopIteration:
107
129
  if prevent_default:
108
130
  self.migration_report.add(
109
- ref_data_mapping.blurb,
110
- (
111
- f"Not to be mapped. "
112
- f'(No default) -- {" - ".join(fieldvalues)} -> ""'
113
- ),
131
+ ref_data_mapping.blurb_id,
132
+ (f"Not to be mapped. " f'(No default) -- {" - ".join(fieldvalues)} -> ""'),
114
133
  )
115
134
  return ""
116
135
  self.migration_report.add(
117
- ref_data_mapping.blurb,
136
+ ref_data_mapping.blurb_id,
118
137
  (
119
138
  f"Unmapped (Default value was set) -- "
120
139
  f'{" - ".join(fieldvalues)} -> {ref_data_mapping.default_name}'
@@ -130,8 +149,17 @@ class MapperBase:
130
149
  "a recognized field in the legacy data."
131
150
  ),
132
151
  ) from exception
152
+ except KeyError as exception:
153
+ raise TransformationProcessError(
154
+ index_or_id,
155
+ (
156
+ f"{ref_data_mapping.name} mapping - folio_{ref_data_mapping.key_type} "
157
+ f"({ref_data_mapping.mapped_legacy_keys}) is not "
158
+ f"a recognized field in the legacy data. KeyError: {exception}"
159
+ ),
160
+ ) from exception
133
161
  except Exception as exception:
134
- raise TransformationRecordFailedError(
162
+ raise TransformationProcessError(
135
163
  index_or_id,
136
164
  (
137
165
  f"{ref_data_mapping.name} - folio_{ref_data_mapping.key_type} "
@@ -139,7 +167,7 @@ class MapperBase:
139
167
  ),
140
168
  ) from exception
141
169
 
142
- def get_mapped_value(
170
+ def get_mapped_ref_data_value(
143
171
  self,
144
172
  ref_data_mapping: RefDataMapping,
145
173
  legacy_object,
@@ -147,17 +175,13 @@ class MapperBase:
147
175
  folio_property_name="",
148
176
  prevent_default=False,
149
177
  ):
150
-
151
178
  # Gets mapped value from mapping file, translated to the right FOLIO UUID
152
179
  try:
153
180
  # Get the values in the fields that will be used for mapping
154
- fieldvalues = [
155
- legacy_object.get(k) for k in ref_data_mapping.mapped_legacy_keys
156
- ]
181
+ fieldvalues = [legacy_object.get(k) for k in ref_data_mapping.mapped_legacy_keys]
157
182
 
158
183
  # Gets the first line in the map satisfying all legacy mapping values.
159
184
  # Case insensitive, strips away whitespace
160
- # TODO: add option for Wild card matching in individual columns
161
185
  right_mapping = ref_data_mapping.get_ref_data_mapping(legacy_object)
162
186
  if not right_mapping:
163
187
  # Not all fields matched. Could it be a hybrid wildcard map?
@@ -166,7 +190,7 @@ class MapperBase:
166
190
  if not right_mapping:
167
191
  raise StopIteration()
168
192
  self.migration_report.add(
169
- ref_data_mapping.blurb,
193
+ ref_data_mapping.blurb_id,
170
194
  (
171
195
  f'{" - ".join(fieldvalues)} '
172
196
  f'-> {right_mapping[f"folio_{ref_data_mapping.key_type}"]}'
@@ -176,15 +200,12 @@ class MapperBase:
176
200
  except StopIteration:
177
201
  if prevent_default:
178
202
  self.migration_report.add(
179
- ref_data_mapping.blurb,
180
- (
181
- f"Not to be mapped. "
182
- f'(No default) -- {" - ".join(fieldvalues)} -> ""'
183
- ),
203
+ ref_data_mapping.blurb_id,
204
+ (f"Not to be mapped. " f'(No default) -- {" - ".join(fieldvalues)} -> ""'),
184
205
  )
185
206
  return ""
186
207
  self.migration_report.add(
187
- ref_data_mapping.blurb,
208
+ ref_data_mapping.blurb_id,
188
209
  (
189
210
  f"Unmapped (Default value was set) -- "
190
211
  f'{" - ".join(fieldvalues)} -> {ref_data_mapping.default_name}'
@@ -210,15 +231,13 @@ class MapperBase:
210
231
  ) from exception
211
232
 
212
233
  def handle_transformation_field_mapping_error(self, index_or_id, error):
213
- self.migration_report.add(Blurbs.FieldMappingErrors, error)
234
+ self.migration_report.add("FieldMappingErrors", error)
214
235
  error.id = error.id or index_or_id
215
236
  error.log_it()
216
- self.migration_report.add_general_statistics("Field Mapping Errors found")
237
+ self.migration_report.add_general_statistics(i18n.t("Field Mapping Errors found"))
217
238
 
218
- def handle_transformation_process_error(
219
- self, idx, error: TransformationProcessError
220
- ):
221
- self.migration_report.add_general_statistics("Transformation process error")
239
+ def handle_transformation_process_error(self, idx, error: TransformationProcessError):
240
+ self.migration_report.add_general_statistics(i18n.t("Transformation process error"))
222
241
  logging.critical("%s\t%s", idx, error)
223
242
  print(f"\n{error.message}: {error.data_value}")
224
243
  sys.exit(1)
@@ -227,16 +246,15 @@ class MapperBase:
227
246
  self, records_processed: int, error: TransformationRecordFailedError
228
247
  ):
229
248
  self.migration_report.add(
230
- Blurbs.GeneralStatistics, "Records failed due to an error"
249
+ "GeneralStatistics", i18n.t("FAILED Records failed due to an error")
231
250
  )
232
- error.id = error.index_or_id or records_processed
251
+ error.index_or_id = error.index_or_id or records_processed
233
252
  error.log_it()
234
253
  self.num_criticalerrors += 1
235
254
  if (
236
255
  self.num_criticalerrors / (records_processed + 1)
237
256
  > (self.library_configuration.failed_percentage_threshold / 100)
238
- and self.num_criticalerrors
239
- > self.library_configuration.failed_records_threshold
257
+ and self.num_criticalerrors > self.library_configuration.failed_records_threshold
240
258
  ):
241
259
  logging.fatal(
242
260
  (
@@ -252,78 +270,76 @@ class MapperBase:
252
270
  )
253
271
  sys.exit(1)
254
272
 
255
- @staticmethod
256
- def get_id_map_dict(legacy_id, folio_record):
257
- return {"legacy_id": legacy_id, "folio_id": folio_record["id"]}
273
+ def get_id_map_tuple(self, legacy_id: str, folio_record: dict, object_type: FOLIONamespaces):
274
+ if all(
275
+ [
276
+ object_type == FOLIONamespaces.instances,
277
+ (not getattr(self.task_configuration, "data_import_marc", False)),
278
+ getattr(self, "create_source_records", True),
279
+ ]
280
+ ):
281
+ return (legacy_id, folio_record["id"], folio_record["hrid"])
282
+ return (legacy_id, folio_record["id"])
258
283
 
259
- def handle_generic_exception(self, idx, excepion: Exception):
260
- self.num_exeptions += 1
284
+ def handle_generic_exception(self, idx, exception: Exception):
285
+ self.num_exceptions += 1
261
286
  print("\n=======ERROR===========")
262
287
  print(
263
- f"Row {idx:,} failed with the following unhandled Exception: {excepion} "
264
- f"of type {type(excepion).__name__}"
288
+ f"Row {idx:,} failed with the following unhandled Exception: {exception} "
289
+ f"of type {type(exception).__name__}"
265
290
  )
266
- logging.error(excepion, exc_info=True)
267
- if self.num_exeptions > 50:
291
+ logging.error(exception, exc_info=True)
292
+ if self.num_exceptions > self.library_configuration.generic_exception_threshold:
268
293
  logging.fatal(
269
294
  "Stopping. More than %s unhandled exceptions. Code needs fixing",
270
- self.num_exeptions,
295
+ self.num_exceptions,
271
296
  )
272
297
  sys.exit(1)
273
298
 
274
- @staticmethod
275
- def save_id_map_file(path, legacy_map: dict):
299
+ def save_id_map_file(self, path, legacy_map: dict):
276
300
  with open(path, "w") as legacy_map_file:
277
301
  for id_string in legacy_map.values():
278
302
  legacy_map_file.write(f"{json.dumps(id_string)}\n")
279
- logging.info("Wrote %s id:s to legacy map", len(legacy_map))
280
-
281
- def store_hrid_settings(self):
282
- logging.info("Setting HRID counter to current +1")
283
- try:
284
- self.hrid_settings["instances"]["startNumber"] = (
285
- self.instance_hrid_counter + 1
286
- )
287
- self.hrid_settings["holdings"]["startNumber"] = (
288
- self.holdings_hrid_counter + 1
289
- )
290
- url = self.folio_client.okapi_url + self.hrid_path
291
- resp = requests.put(
292
- url,
293
- data=json.dumps(self.hrid_settings),
294
- headers=self.folio_client.okapi_headers,
295
- )
296
- resp.raise_for_status()
297
- logging.info("%s Successfully set HRID settings.", resp.status_code)
298
- a = self.folio_client.folio_get_single_object(self.hrid_path)
299
- logging.info("Current hrid settings: %s", json.dumps(a, indent=4))
300
- except Exception:
301
- logging.exception(
302
- f"Something went wrong when setting the HRID settings. "
303
- f"Update them manually. {json.dumps(self.hrid_settings)}"
304
- )
303
+ self.migration_report.add(
304
+ "GeneralStatistics", i18n.t("Unique ID:s written to legacy map")
305
+ )
306
+ logging.info("Wrote legacy id map to %s", path)
305
307
 
306
308
  @staticmethod
307
309
  def validate_required_properties(
308
310
  legacy_id, folio_object: dict, schema: dict, object_type: FOLIONamespaces
309
311
  ):
310
312
  cleaned_folio_object = MapperBase.clean_none_props(folio_object)
311
- required = schema["required"]
313
+ required = []
312
314
  missing = []
313
- for required_prop in required:
314
- if required_prop not in cleaned_folio_object:
315
- missing.append(f"Missing: {required_prop}")
316
- elif not cleaned_folio_object[required_prop]:
317
- missing.append(f"Empty: {required_prop}")
315
+ if object_type != FOLIONamespaces.note:
316
+ required = schema.get("required", [])
317
+ missing = list(MapperBase.list_missing(required, cleaned_folio_object))
318
+ else:
319
+ required = (
320
+ schema.get("properties", {}).get("notes", {}).get("items", {}).get("required", [])
321
+ )
322
+ for note in cleaned_folio_object.get("notes", []):
323
+ missing.extend(MapperBase.list_missing(required, note))
324
+
318
325
  if any(missing):
319
326
  raise TransformationRecordFailedError(
320
327
  legacy_id,
321
328
  "One or many required properties empty",
322
329
  f"{json.dumps(missing)}",
323
330
  )
324
- cleaned_folio_object.pop("type", None)
331
+ if object_type not in [FOLIONamespaces.users]:
332
+ cleaned_folio_object.pop("type", None)
325
333
  return cleaned_folio_object
326
334
 
335
+ @staticmethod
336
+ def list_missing(required: list, cleaned_folio_object: dict):
337
+ for required_prop in required:
338
+ if required_prop not in cleaned_folio_object:
339
+ yield f"Missing: {required_prop}"
340
+ elif not cleaned_folio_object[required_prop]:
341
+ yield f"Empty: {required_prop}"
342
+
327
343
  @staticmethod
328
344
  def clean_none_props(d: dict):
329
345
  clean = {}
@@ -338,15 +354,210 @@ class MapperBase:
338
354
  clean[k] = v
339
355
  return clean
340
356
 
357
+ def add_legacy_id_to_admin_note(self, folio_record: dict, legacy_id: str):
358
+ if not legacy_id:
359
+ raise TransformationFieldMappingError(
360
+ legacy_id, i18n.t("Legacy id is empty"), legacy_id
361
+ )
362
+ if "administrativeNotes" not in folio_record:
363
+ folio_record["administrativeNotes"] = []
364
+ if id_string := next(
365
+ (f for f in folio_record["administrativeNotes"] if MapperBase.legacy_id_template in f),
366
+ None,
367
+ ):
368
+ if legacy_id not in id_string:
369
+ folio_record["administrativeNotes"] = [
370
+ f
371
+ for f in folio_record["administrativeNotes"]
372
+ if MapperBase.legacy_id_template not in f
373
+ ]
374
+
375
+ folio_record["administrativeNotes"].append(f"{id_string}, {legacy_id}")
376
+ else:
377
+ folio_record["administrativeNotes"].append(
378
+ f"{MapperBase.legacy_id_template} {legacy_id}"
379
+ )
380
+
381
+ def create_and_write_boundwith_part(self, legacy_item_id: str, bound_with_holding_uuid: dict):
382
+ part = {
383
+ "id": str(uuid.uuid4()),
384
+ "holdingsRecordId": bound_with_holding_uuid,
385
+ "itemId": str(
386
+ FolioUUID(
387
+ self.base_string_for_folio_uuid,
388
+ FOLIONamespaces.items,
389
+ legacy_item_id,
390
+ )
391
+ ),
392
+ }
393
+ self.extradata_writer.write("boundwithPart", part)
394
+
395
+ def create_bound_with_holdings(
396
+ self,
397
+ folio_holding: dict,
398
+ instance_ids: list,
399
+ bound_with_holdings_type_id: str,
400
+ ):
401
+ if not bound_with_holdings_type_id:
402
+ raise TransformationProcessError(
403
+ "Missing task setting holdingsTypeUuidForBoundwiths. Add a "
404
+ "holdingstype specifically for boundwith holdings and reference "
405
+ "the UUID in this parameter."
406
+ )
407
+ for bwidx, instance_uuid in enumerate(instance_ids):
408
+ if not instance_uuid:
409
+ raise ValueError(f"No Instance ID for record {folio_holding}")
410
+ bound_with_holding = copy.deepcopy(folio_holding)
411
+ bound_with_holding["instanceId"] = instance_uuid
412
+
413
+ if call_number := folio_holding.get("callNumber", None):
414
+ if "[" in call_number:
415
+ try:
416
+ call_numbers: List = ast.literal_eval(str(folio_holding["callNumber"]))
417
+ bound_with_holding["callNumber"] = call_numbers[bwidx]
418
+ except IndexError:
419
+ if call_numbers:
420
+ bound_with_holding["callNumber"] = call_numbers[0]
421
+ except (SyntaxError, ValueError):
422
+ bound_with_holding["callNumber"] = call_number
423
+ else:
424
+ bound_with_holding["callNumber"] = call_number
425
+ bound_with_holding["holdingsTypeId"] = bound_with_holdings_type_id
426
+
427
+ # The subsequent copies gets different ids, but the original is maintained.
428
+ if bwidx > 0:
429
+ bound_with_holding["id"] = self.generate_boundwith_holding_uuid(
430
+ folio_holding["id"], instance_uuid
431
+ )
432
+ if bound_with_holding.get("hrid", ""):
433
+ bound_with_holding["hrid"] = f'{bound_with_holding["hrid"]}_bw_{bwidx}'
434
+ self.migration_report.add_general_statistics(i18n.t("Bound-with holdings created"))
435
+ yield bound_with_holding
436
+
437
+ def generate_boundwith_holding_uuid(self, holding_uuid, instance_uuid):
438
+ return str(
439
+ FolioUUID(
440
+ self.base_string_for_folio_uuid,
441
+ FOLIONamespaces.holdings,
442
+ f"{holding_uuid}-{instance_uuid}",
443
+ )
444
+ )
445
+
446
+ def map_statistical_codes(self, folio_record: dict, file_def: FileDefinition, legacy_record: Optional[Union[dict, Record]] = None):
447
+ """Map statistical codes to the folio record.
448
+
449
+ This method checks if the file definition contains statistical codes and
450
+ if so, it splits the codes by the multi-field delimiter and adds them to
451
+ the folio record's 'statisticalCodeIds' field. If the field does not exist,
452
+ it initializes it as an empty list before appending the codes.
453
+
454
+ Args:
455
+ folio_record (dict): The FOLIO record to which the statistical codes will be added.
456
+ file_def (FileDefinition): The file definition containing the statistical codes.
457
+ legacy_record (Optional[Union[dict, Record]]): The legacy record from which the statistical codes are derived.
458
+ """
459
+ if file_def.statistical_code:
460
+ code_strings = file_def.statistical_code.split(
461
+ self.library_configuration.multi_field_delimiter
462
+ )
463
+ folio_record["statisticalCodeIds"] = folio_record.get("statisticalCodeIds", []) + code_strings
464
+
465
+ def setup_statistical_codes_map(self, statistical_codes_map):
466
+ if statistical_codes_map:
467
+ self.statistical_codes_mapping = RefDataMapping(
468
+ self.folio_client,
469
+ "/statistical-codes",
470
+ "statisticalCodes",
471
+ statistical_codes_map,
472
+ "code",
473
+ "StatisticalCodeMapping",
474
+ )
475
+ logging.info(f"Statistical codes mapping set up {self.statistical_codes_mapping.mapped_legacy_keys}")
476
+ else:
477
+ self.statistical_codes_mapping = None
478
+ logging.info("Statistical codes map is not set up")
479
+
480
+ def get_statistical_code(self, legacy_item: dict, folio_prop_name: str, index_or_id):
481
+ if self.statistical_codes_mapping:
482
+ return self.get_mapped_ref_data_value(
483
+ self.statistical_codes_mapping,
484
+ legacy_item,
485
+ index_or_id,
486
+ folio_prop_name,
487
+ True,
488
+ )
489
+ self.migration_report.add(
490
+ "StatisticalCodeMapping",
491
+ i18n.t("Mapping not set up"),
492
+ )
493
+ return ""
494
+
495
+ def map_statistical_code_ids(
496
+ self, legacy_ids, folio_record: dict
497
+ ):
498
+ if stat_codes := {x: None for x in folio_record.pop("statisticalCodeIds", [])}:
499
+ folio_code_ids = set()
500
+ for stat_code in stat_codes:
501
+ if stat_code_id := self.get_statistical_code({"legacy_stat_code": stat_code}, "statisticalCodeId", legacy_ids):
502
+ folio_code_ids.add(stat_code_id)
503
+ else:
504
+ Helper.log_data_issue(
505
+ legacy_ids,
506
+ i18n.t(
507
+ "Statistical code '%{code}' not found in FOLIO",
508
+ code=stat_code,
509
+ ),
510
+ stat_code,
511
+ )
512
+ folio_record["statisticalCodeIds"] = list(folio_code_ids)
513
+
514
+ @property
515
+ def base_string_for_folio_uuid(self):
516
+ if self.library_configuration.use_gateway_url_for_uuids and not self.library_configuration.is_ecs:
517
+ return str(self.folio_client.gateway_url)
518
+ elif self.library_configuration.ecs_tenant_id:
519
+ return str(self.library_configuration.ecs_tenant_id)
520
+ else:
521
+ return str(self.library_configuration.tenant_id)
522
+
523
+ @staticmethod
524
+ def validate_location_map(location_map: List[Dict], locations: List[Dict]) -> List[Dict]:
525
+ mapped_codes = [x['folio_code'] for x in location_map]
526
+ existing_codes = [x['code'] for x in locations]
527
+ missing_codes = set(mapped_codes) - set(existing_codes)
528
+ if missing_codes:
529
+ raise TransformationProcessError(
530
+ "",
531
+ f"Location map contains codes not found in locations: {', '.join(missing_codes)}",
532
+ "",
533
+ )
534
+ return location_map
535
+
536
+ @staticmethod
537
+ def get_object_type() -> FOLIONamespaces:
538
+ raise NotImplementedError("This method should be overridden in subclasses")
341
539
 
342
540
  def flatten(my_dict: dict, path=""):
343
541
  for k, v in iter(my_dict.items()):
542
+ if not path:
543
+ yield k
344
544
  if v:
345
545
  if isinstance(v, list):
546
+ if path and check_if_list_with_dict_keys(v):
547
+ yield f"{path}.{k}".strip(".")
346
548
  for e in v:
347
549
  if isinstance(e, dict):
348
550
  yield from flatten(dict(e), f"{path}.{k}")
551
+ elif isinstance(e, str) and path:
552
+ yield f"{path}.{k}".strip(".")
553
+
349
554
  elif isinstance(v, dict):
555
+ if path:
556
+ yield f"{path}.{k}".strip(".")
350
557
  yield from flatten(dict(v), f"{path}.{k}")
351
- else:
558
+ elif path:
352
559
  yield f"{path}.{k}".strip(".")
560
+
561
+
562
+ def check_if_list_with_dict_keys(data):
563
+ return isinstance(data, list) and all(isinstance(x, dict) for x in data)