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.
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/PKG-INFO +1 -1
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/pyproject.toml +1 -1
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/BatchPoster.py +2 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/UserImport.py +32 -16
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/marc_preprocessors/_preprocessors.py +102 -13
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/README.md +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/DILogRetriever.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/MARCDataImport.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/__init__.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/__main__.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/_postgres.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/_progress.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/custom_exceptions.py +0 -0
- {folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/marc_preprocessors/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: folio_data_import
|
|
3
|
-
Version: 0.6.
|
|
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.
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{folio_data_import-0.6.2 → folio_data_import-0.6.4}/src/folio_data_import/custom_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|