folio_data_import 0.6.0__tar.gz → 0.6.2__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,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folio_data_import
3
- Version: 0.6.0
3
+ Version: 0.6.2
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>
7
7
  License-Expression: MIT
8
- Requires-Dist: folioclient>=1.0.3,<2.0
8
+ Requires-Dist: folioclient>=1.0.8,<2.0
9
9
  Requires-Dist: pymarc>=5.2.2
10
10
  Requires-Dist: pyhumps>=3.8.0
11
11
  Requires-Dist: tabulate>=0.9.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "folio_data_import"
3
- version = "0.6.0"
3
+ version = "0.6.2"
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"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10,<4.0"
9
9
  dependencies = [
10
- "folioclient>=1.0.3,<2.0",
10
+ "folioclient>=1.0.8,<2.0",
11
11
  "pymarc>=5.2.2",
12
12
  "pyhumps>=3.8.0",
13
13
  "tabulate>=0.9.0",
@@ -152,6 +152,15 @@ class UserImporter: # noqa: R0902
152
152
  description="Whether to delete existing users, rather than create/update.",
153
153
  ),
154
154
  ] = False
155
+ delete_pus: Annotated[
156
+ bool,
157
+ Field(
158
+ title="Delete permission user objects when deleting users",
159
+ description=(
160
+ "Whether to delete associated permission user objects when deleting users."
161
+ ),
162
+ ),
163
+ ] = False
155
164
 
156
165
  logfile: AsyncTextIOWrapper
157
166
  errorfile: AsyncTextIOWrapper
@@ -174,6 +183,7 @@ class UserImporter: # noqa: R0902
174
183
  )
175
184
  else:
176
185
  self.reporter = reporter
186
+ self.delete_pus = config.delete_pus
177
187
  self.limit_simultaneous_requests = asyncio.Semaphore(config.limit_simultaneous_requests)
178
188
  # Build reference data maps (these need processing)
179
189
  self.patron_group_map: dict = self.build_ref_data_id_map(
@@ -800,36 +810,54 @@ class UserImporter: # noqa: R0902
800
810
  "skipping deletion\n"
801
811
  )
802
812
  return
803
- try:
804
- if existing_user:
813
+ if existing_user:
814
+ try:
805
815
  await self.folio_client.folio_delete_async(
806
816
  f"/users/{existing_user['id']}",
807
817
  )
808
- if existing_rp:
809
- await self.folio_client.folio_delete_async(
810
- f"/request-preference-storage/request-preference/{existing_rp.get('id', '')}"
811
- )
812
- if existing_pu:
813
- await self.folio_client.folio_delete_async(
814
- f"/perms/users/{existing_pu.get('id', '')}"
815
- )
816
- if existing_spu:
817
- await self.folio_client.folio_delete_async(
818
- f"/service-points-users/{existing_spu.get('id', '')}"
819
- )
820
-
821
- if existing_user:
822
818
  logger.debug(f"Row {line_number}: Deleted user {existing_user['id']}\n")
823
819
 
824
- async with self.lock:
825
820
  self.stats.deleted += 1
826
- except folioclient.FolioError as ee:
827
- logger.error(
828
- f"Row {line_number}: Failed to delete user {existing_user['id']}: "
829
- f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
830
- )
831
- async with self.lock:
821
+ except folioclient.FolioError as ee:
822
+ logger.exception(
823
+ f"Row {line_number}: Failed to delete user {existing_user['id']}: "
824
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
825
+ )
832
826
  self.stats.failed += 1
827
+ try:
828
+ if existing_rp:
829
+ await self.folio_client.folio_delete_async(
830
+ "/request-preference-storage/request-preference/"
831
+ f"{existing_rp.get('id', '')}"
832
+ )
833
+ except folioclient.FolioError as ee:
834
+ logger.exception(
835
+ f"Row {line_number}: Failed to delete request preference for user "
836
+ f"{existing_user['id']}: "
837
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
838
+ )
839
+ try:
840
+ if existing_pu and self.delete_pus:
841
+ await self.folio_client.folio_delete_async(
842
+ f"/perms/users/{existing_pu.get('id', '')}"
843
+ )
844
+ except folioclient.FolioError as ee:
845
+ logger.exception(
846
+ f"Row {line_number}: Failed to delete permission user for user "
847
+ f"{existing_user['id']}: "
848
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
849
+ )
850
+ try:
851
+ if existing_spu:
852
+ await self.folio_client.folio_delete_async(
853
+ f"/service-points-users/{existing_spu.get('id', '')}"
854
+ )
855
+ except folioclient.FolioError as ee:
856
+ logger.exception(
857
+ f"Row {line_number}: Failed to delete service points user for user "
858
+ f"{existing_user['id']}: "
859
+ f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
860
+ )
833
861
 
834
862
  async def process_line(
835
863
  self,
@@ -2,6 +2,7 @@ import importlib
2
2
  import logging
3
3
  import re
4
4
  import sys
5
+ from datetime import datetime
5
6
  from typing import Callable, Dict, List, Tuple, Union
6
7
 
7
8
  import pymarc
@@ -106,6 +107,29 @@ class MARCPreprocessor:
106
107
  return record
107
108
 
108
109
 
110
+ def remove_sudoc_prefixes(record: Record) -> Record:
111
+ """Remove common ABES/SUDOC prefixes from the 001 of a record.
112
+ Useful when importing records from the ABES SUDOC catalog that
113
+ you want to match to existing records in FOLIO without the prefix.
114
+
115
+ Args:
116
+ record (Record): The MARC record to preprocess.
117
+ """
118
+ sudoc_prefixes = ["PPN", "ABES"]
119
+ if "001" in record:
120
+ if any(record["001"].data.startswith(prefix) for prefix in sudoc_prefixes):
121
+ logger.debug("Field '001' starts with a known SUDOC prefix. Removing prefix from 001.")
122
+ for prefix in sudoc_prefixes:
123
+ record["001"].data = record["001"].data.replace(prefix, "").strip()
124
+ else:
125
+ logger.debug(
126
+ "Field '001' does not start with a known SUDOC prefix. No changes made to 001."
127
+ )
128
+ else:
129
+ logger.warning("Field '001' not found in record. Skipping prefix removal.")
130
+ return record
131
+
132
+
109
133
  def prepend_prefix_001(record: Record, prefix: str) -> Record:
110
134
  """
111
135
  Prepend a prefix to the record's 001 field.
@@ -117,9 +141,11 @@ def prepend_prefix_001(record: Record, prefix: str) -> Record:
117
141
  Returns:
118
142
  Record: The preprocessed MARC record.
119
143
  """
120
- if "001" in record:
144
+ if "001" in record and record["001"].data:
121
145
  record["001"].data = (
122
- f"({prefix})" + record["001"].data if record["001"].data else f"({prefix})"
146
+ f"({prefix})" + record["001"].data
147
+ if not record["001"].data.startswith(f"({prefix})")
148
+ else record["001"].data
123
149
  )
124
150
  else:
125
151
  logger.warning("Field '001' not found in record. Skipping prefix prepend.")
@@ -426,6 +452,35 @@ def clean_empty_fields(record: Record, **kwargs) -> Record:
426
452
  return record
427
453
 
428
454
 
455
+ def clean_empty_contributors(record: Record, **kwargs) -> Record:
456
+ """
457
+ Remove contributor fields (100, 110, 111, 700, 710, 711) that have empty subfield a. These can
458
+ cause data import mapping issues in FOLIO. Removals are logged at custom log level 26, which is
459
+ used by folio_migration_tools to populate the data issues report.
460
+
461
+ Args:
462
+ record (Record): The MARC record to preprocess.
463
+
464
+ Returns:
465
+ Record: The preprocessed MARC record.
466
+ """
467
+ for field in record.get_fields("100", "110", "111", "700", "710", "711"):
468
+ if all(
469
+ not subfield.value.strip()
470
+ for subfield in field.subfields
471
+ if subfield.code in ["a", "b", "c", "d", "q"]
472
+ ):
473
+ logger.log(
474
+ 26,
475
+ "DATA ISSUE\t%s\t%s\t%s",
476
+ record["001"].value(),
477
+ f"{field.tag} contributor field has empty name subfields, removing field",
478
+ field,
479
+ )
480
+ record.remove_field(field)
481
+ return record
482
+
483
+
429
484
  def fix_bib_leader(record: Record, **kwargs) -> Record:
430
485
  """
431
486
  Fixes the leader of the record by setting the record status to 'c' (modified
@@ -546,6 +601,51 @@ def remove_non_numeric_fields(record: Record, **kwargs) -> Record:
546
601
  return record
547
602
 
548
603
 
604
+ def populate_blank_008_0_5(record: Record, **kwargs) -> Record:
605
+ """
606
+ Populate the first 6 characters of the 008 with today's date in the format YYMMDD if they are
607
+ blank. Workaround for FOLIO issue: https://folio-org.atlassian.net/browse/MODQM-515
608
+ """
609
+ if "008" in record and record["008"].data:
610
+ if len(record["008"].data) >= 6 and record["008"].data[:6] == " ":
611
+ today = datetime.today().strftime("%y%m%d")
612
+ record["008"].data = today + record["008"].data[6:]
613
+ logger.log(
614
+ 26,
615
+ "DATA ISSUE\t%s\t%s\t%s",
616
+ record["001"].value(),
617
+ "First 6 characters of 008 were blank, populated with today's date",
618
+ record["008"],
619
+ )
620
+ else:
621
+ logger.warning("Field '008' not found in record. Skipping 008 population.")
622
+ return record
623
+
624
+
625
+ def move_856z_to_856y(record: Record, **kwargs) -> Record:
626
+ """
627
+ Move subfield z from 856 fields to subfield y. This is useful when
628
+ importing records from the ABES SUDOC catalog.
629
+
630
+ Args:
631
+ record (Record): The MARC record to preprocess.
632
+ Returns:
633
+ Record: The preprocessed MARC record.
634
+ """
635
+ for field in record.get_fields("856"):
636
+ if "z" in field:
637
+ field.add_subfield("y", field["z"])
638
+ field.delete_subfield("z")
639
+ logger.log(
640
+ 26,
641
+ "DATA ISSUE\t%s\t%s\t%s",
642
+ record["001"].value(),
643
+ f"Subfield z moved to subfield y in {field.tag}",
644
+ field,
645
+ )
646
+ return record
647
+
648
+
549
649
  def ordinal(n: int) -> str:
550
650
  s = ("th", "st", "nd", "rd") + ("th",) * 10
551
651
  v = n % 100