folio_data_import 0.6.2__tar.gz → 0.6.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folio_data_import
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: A python module to perform bulk import of data into a FOLIO environment. Currently supports MARC and user data import.
5
5
  Author: Brooks Travis
6
6
  Author-email: Brooks Travis <brooks.travis@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "folio_data_import"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "A python module to perform bulk import of data into a FOLIO environment. Currently supports MARC and user data import."
5
5
  authors = [{ name = "Brooks Travis", email = "brooks.travis@gmail.com" }]
6
6
  license = "MIT"
@@ -848,6 +848,7 @@ class BatchPoster:
848
848
  except folioclient.FolioClientError as e:
849
849
  logger.error(f"Batch failed: {e} - {e.response.text}")
850
850
  self.stats.records_failed += len(batch)
851
+ self.stats.batches_failed += 1
851
852
  self._write_failed_batch(batch)
852
853
 
853
854
  # Update progress bar if available
@@ -864,6 +865,7 @@ class BatchPoster:
864
865
  except folioclient.FolioConnectionError as e:
865
866
  logger.error(f"Batch failed due to connection error: {e}")
866
867
  self.stats.records_failed += len(batch)
868
+ self.stats.batches_failed += 1
867
869
  self._write_failed_batch(batch)
868
870
 
869
871
  # Update progress bar if available
@@ -42,6 +42,7 @@ PREFERRED_CONTACT_TYPES_MAP = {
42
42
  "004": "phone",
43
43
  "005": "mobile",
44
44
  }
45
+ PREFERRED_CONTACT_TYPES_NAME_TO_ID = {v: k for k, v in PREFERRED_CONTACT_TYPES_MAP.items()}
45
46
 
46
47
 
47
48
  USER_MATCH_KEYS = ["username", "barcode", "externalSystemId"]
@@ -527,6 +528,8 @@ class UserImporter: # noqa: R0902
527
528
  Raises:
528
529
  HTTPError: If the HTTP request to create the user fails.
529
530
  """
531
+ # Normalize preferred contact type to numeric ID before create.
532
+ await self.set_preferred_contact_type(user_obj, user_obj)
530
533
  response = await self.http_client.post(
531
534
  "/users",
532
535
  headers=self.folio_client.okapi_headers,
@@ -544,35 +547,48 @@ class UserImporter: # noqa: R0902
544
547
  user object has a valid preferred contact type set. In that case, the existing preferred
545
548
  contact type is used.
546
549
  """
550
+ default_preferred_contact_type = self.normalize_preferred_contact_type(
551
+ self.config.default_preferred_contact_type
552
+ )
547
553
  if "personal" in user_obj and "preferredContactTypeId" in user_obj["personal"]:
548
554
  current_pref_contact = user_obj["personal"].get("preferredContactTypeId", "")
549
- if mapped_contact_type := {v: k for k, v in PREFERRED_CONTACT_TYPES_MAP.items()}.get(
550
- current_pref_contact,
551
- "",
552
- ):
553
- existing_user["personal"]["preferredContactTypeId"] = mapped_contact_type
554
- else:
555
- existing_user["personal"]["preferredContactTypeId"] = (
556
- current_pref_contact
557
- if current_pref_contact in PREFERRED_CONTACT_TYPES_MAP
558
- else self.config.default_preferred_contact_type
559
- )
555
+ if "personal" not in existing_user:
556
+ existing_user["personal"] = {}
557
+ existing_user["personal"]["preferredContactTypeId"] = (
558
+ self.normalize_preferred_contact_type(current_pref_contact)
559
+ )
560
560
  else:
561
561
  logger.warning(
562
562
  f"Preferred contact type not provided or is not a valid option: "
563
563
  f"{PREFERRED_CONTACT_TYPES_MAP} Setting preferred contact type to "
564
564
  f"{self.config.default_preferred_contact_type} or using existing value"
565
565
  )
566
- mapped_contact_type = (
567
- existing_user.get("personal", {}).get("preferredContactTypeId", "")
568
- or self.config.default_preferred_contact_type
566
+ existing_contact_type = existing_user.get("personal", {}).get(
567
+ "preferredContactTypeId", ""
569
568
  )
570
569
  if "personal" not in existing_user:
571
570
  existing_user["personal"] = {}
572
571
  existing_user["personal"]["preferredContactTypeId"] = (
573
- mapped_contact_type or self.config.default_preferred_contact_type
572
+ self.normalize_preferred_contact_type(existing_contact_type)
573
+ if existing_contact_type
574
+ else default_preferred_contact_type
574
575
  )
575
576
 
577
+ def normalize_preferred_contact_type(self, preferred_contact_type: str) -> str:
578
+ """Normalize preferred contact type names/IDs to a valid numeric ID."""
579
+ if preferred_contact_type in PREFERRED_CONTACT_TYPES_MAP:
580
+ return preferred_contact_type
581
+
582
+ normalized_value = (preferred_contact_type or "").strip().lower()
583
+ if mapped_contact_type := PREFERRED_CONTACT_TYPES_NAME_TO_ID.get(normalized_value):
584
+ return mapped_contact_type
585
+
586
+ default_preferred_contact_type = self.config.default_preferred_contact_type
587
+ if default_preferred_contact_type in PREFERRED_CONTACT_TYPES_MAP:
588
+ return default_preferred_contact_type
589
+
590
+ return PREFERRED_CONTACT_TYPES_NAME_TO_ID.get(default_preferred_contact_type, "002")
591
+
576
592
  async def create_or_update_user(
577
593
  self, user_obj, existing_user, protected_fields, line_number: int
578
594
  ) -> dict:
@@ -621,7 +637,7 @@ class UserImporter: # noqa: R0902
621
637
 
622
638
  async def process_user_obj(self, user: str) -> dict:
623
639
  """
624
- Process a user object. If not type is found in the source object, type is set to "patron".
640
+ Process a user object. If type is not found in the source object, type is set to "patron".
625
641
 
626
642
  Args:
627
643
  user (str): The user data to be processed, as a json string.
@@ -11,6 +11,33 @@ from pymarc.record import Record
11
11
  logger = logging.getLogger("folio_data_import.MARCDataImport")
12
12
 
13
13
 
14
+ def _get_record_id(
15
+ record: Record,
16
+ record_id_field: str = "001",
17
+ record_id_subfield: str | None = None,
18
+ **kwargs,
19
+ ) -> str:
20
+ """
21
+ Extract a record identifier from the specified field and optional subfield.
22
+ Returns 'UNKNOWN' if the field is absent or the subfield value is empty.
23
+
24
+ Args:
25
+ record (Record): The MARC record.
26
+ record_id_field (str): The tag of the field to use as the identifier. Defaults to '001'.
27
+ record_id_subfield (str | None): The subfield code to use. If None, uses the full field
28
+ value (appropriate for control fields). Defaults to None.
29
+
30
+ Returns:
31
+ str: The record identifier, or 'UNKNOWN' if not found.
32
+ """
33
+ field = record.get(record_id_field)
34
+ if field is None:
35
+ return "UNKNOWN"
36
+ if record_id_subfield is not None:
37
+ return field.get(record_id_subfield) or "UNKNOWN"
38
+ return field.value() or "UNKNOWN"
39
+
40
+
14
41
  class MARCPreprocessor:
15
42
  """
16
43
  A class to preprocess MARC records for data import into FOLIO.
@@ -234,7 +261,7 @@ def clean_non_ff_999_fields(record: Record, **kwargs) -> Record:
234
261
  logger.log(
235
262
  26,
236
263
  "DATA ISSUE\t%s\t%s\t%s",
237
- record["001"].value(),
264
+ _get_record_id(record, **kwargs),
238
265
  "Record contains a 999 field with non-ff indicators: Moving field to a 945 with"
239
266
  ' indicators "99"',
240
267
  field,
@@ -395,6 +422,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
395
422
  "856": ["u", "y", "z"],
396
423
  }
397
424
 
425
+ record_id = _get_record_id(record, **kwargs)
398
426
  for field in record.get_fields(*MAPPED_FIELDS.keys()):
399
427
  len_subs = len(field.subfields)
400
428
  subfield_value = (
@@ -404,7 +432,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
404
432
  logger.log(
405
433
  26,
406
434
  "DATA ISSUE\t%s\t%s\t%s",
407
- record["001"].value(),
435
+ record_id,
408
436
  f"{field.tag} is empty, removing field",
409
437
  field,
410
438
  )
@@ -413,7 +441,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
413
441
  logger.log(
414
442
  26,
415
443
  "DATA ISSUE\t%s\t%s\t%s",
416
- record["001"].value(),
444
+ record_id,
417
445
  f"{field.tag}${field.subfields[0].code} is empty,"
418
446
  " no other subfields present, removing field",
419
447
  field,
@@ -424,7 +452,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
424
452
  logger.log(
425
453
  26,
426
454
  "DATA ISSUE\t%s\t%s\t%s",
427
- record["001"].value(),
455
+ record_id,
428
456
  f"{field.tag}$a is empty, removing subfield",
429
457
  field,
430
458
  )
@@ -434,7 +462,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
434
462
  logger.log(
435
463
  26,
436
464
  "DATA ISSUE\t%s\t%s\t%s",
437
- record["001"].value(),
465
+ record_id,
438
466
  f"{field.tag}${subfield.code} ({ordinal(idx)} subfield) is empty, but "
439
467
  "other subfields have values, removing subfield",
440
468
  field,
@@ -444,7 +472,7 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
444
472
  logger.log(
445
473
  26,
446
474
  "DATA ISSUE\t%s\t%s\t%s",
447
- record["001"].value(),
475
+ record_id,
448
476
  f"{field.tag} has no non-empty subfields after cleaning, removing field",
449
477
  field,
450
478
  )
@@ -473,7 +501,7 @@ def clean_empty_contributors(record: Record, **kwargs) -> Record:
473
501
  logger.log(
474
502
  26,
475
503
  "DATA ISSUE\t%s\t%s\t%s",
476
- record["001"].value(),
504
+ _get_record_id(record, **kwargs),
477
505
  f"{field.tag} contributor field has empty name subfields, removing field",
478
506
  field,
479
507
  )
@@ -494,11 +522,12 @@ def fix_bib_leader(record: Record, **kwargs) -> Record:
494
522
  """
495
523
  VALID_STATUSES = ["a", "c", "d", "n", "p"]
496
524
  VALID_TYPES = ["a", "c", "d", "e", "f", "g", "i", "j", "k", "m", "o", "p", "r", "t"]
525
+ record_id = _get_record_id(record, **kwargs)
497
526
  if record.leader[5] not in VALID_STATUSES:
498
527
  logger.log(
499
528
  26,
500
529
  "DATA ISSUE\t%s\t%s\t%s",
501
- record["001"].value(),
530
+ record_id,
502
531
  f"Invalid record status: {record.leader[5]}, setting to 'c'",
503
532
  record.leader,
504
533
  )
@@ -507,7 +536,7 @@ def fix_bib_leader(record: Record, **kwargs) -> Record:
507
536
  logger.log(
508
537
  26,
509
538
  "DATA ISSUE\t%s\t%s\t%s",
510
- record["001"].value(),
539
+ record_id,
511
540
  f"Invalid record type: {record.leader[6]}, setting to 'a'",
512
541
  record.leader,
513
542
  )
@@ -556,7 +585,7 @@ def move_authority_subfield_9_to_0_all_controllable_fields(record: Record, **kwa
556
585
  logger.log(
557
586
  26,
558
587
  "DATA ISSUE\t%s\t%s\t%s",
559
- record["001"].value(),
588
+ _get_record_id(record, **kwargs),
560
589
  f"Subfield 9 moved to subfield 0 in {field.tag}",
561
590
  field,
562
591
  )
@@ -587,13 +616,14 @@ def remove_non_numeric_fields(record: Record, **kwargs) -> Record:
587
616
  Returns:
588
617
  Record: The preprocessed MARC record.
589
618
  """
619
+ record_id = _get_record_id(record, **kwargs)
590
620
  for field in record.get_fields():
591
621
  if not re.fullmatch(r"\d{3}", field.tag) or field.tag == "000":
592
622
  reason = "invalid tag 000" if field.tag == "000" else f"non-numeric tag {field.tag}"
593
623
  logger.log(
594
624
  26,
595
625
  "DATA ISSUE\t%s\t%s\t%s",
596
- record["001"].value(),
626
+ record_id,
597
627
  f"Field removed: {reason}",
598
628
  field,
599
629
  )
@@ -613,7 +643,7 @@ def populate_blank_008_0_5(record: Record, **kwargs) -> Record:
613
643
  logger.log(
614
644
  26,
615
645
  "DATA ISSUE\t%s\t%s\t%s",
616
- record["001"].value(),
646
+ _get_record_id(record, **kwargs),
617
647
  "First 6 characters of 008 were blank, populated with today's date",
618
648
  record["008"],
619
649
  )
@@ -639,13 +669,72 @@ def move_856z_to_856y(record: Record, **kwargs) -> Record:
639
669
  logger.log(
640
670
  26,
641
671
  "DATA ISSUE\t%s\t%s\t%s",
642
- record["001"].value(),
672
+ _get_record_id(record, **kwargs),
643
673
  f"Subfield z moved to subfield y in {field.tag}",
644
674
  field,
645
675
  )
646
676
  return record
647
677
 
648
678
 
679
+ def normalize_subfield_codes(record: Record, **kwargs) -> Record:
680
+ """
681
+ Normalize subfield codes to lowercase. Fixes common import error in FOLIO. Preserves the order
682
+ of subfields in each field.
683
+
684
+ Args:
685
+ record (Record): The MARC record to preprocess.
686
+ Returns:
687
+ Record: The preprocessed MARC record.
688
+ """
689
+ record_id = _get_record_id(record, **kwargs)
690
+ for field in record.get_fields():
691
+ if not any(sf.code != sf.code.lower() for sf in field.subfields):
692
+ continue
693
+ normalized_subfields = []
694
+ for subfield in field.subfields:
695
+ if subfield.code != subfield.code.lower():
696
+ logger.log(
697
+ 26,
698
+ "DATA ISSUE\t%s\t%s\t%s",
699
+ record_id,
700
+ f"Invalid subfield code {field.tag}${subfield.code}:"
701
+ f" normalizing to ${subfield.code.lower()}",
702
+ field,
703
+ )
704
+ normalized_subfields.append(
705
+ pymarc.field.Subfield(subfield.code.lower(), subfield.value)
706
+ )
707
+ field.subfields = normalized_subfields
708
+ return record
709
+
710
+
711
+ def copy_240_to_245_if_no_245(record: Record, **kwargs) -> Record:
712
+ """
713
+ Copy subfields from 240 fields to 245 fields. This is a workaround for FOLIO requiring 245s.
714
+
715
+ Args:
716
+ record (Record): The MARC record to preprocess.
717
+ Returns:
718
+ Record: The preprocessed MARC record.
719
+ """
720
+ if "245" not in record and "240" in record:
721
+ logger.log(
722
+ 26,
723
+ "DATA ISSUE\t%s\t%s\t%s",
724
+ _get_record_id(record, **kwargs),
725
+ "No 245 field found: copying subfields from 240 to 245",
726
+ record,
727
+ )
728
+ field_240 = record.get_fields("240")[0]
729
+ field_245 = pymarc.Field(
730
+ tag="245",
731
+ indicators=["0", "0"],
732
+ subfields=field_240.subfields,
733
+ )
734
+ record.add_ordered_field(field_245)
735
+ return record
736
+
737
+
649
738
  def ordinal(n: int) -> str:
650
739
  s = ("th", "st", "nd", "rd") + ("th",) * 10
651
740
  v = n % 100