folio-data-import 0.2.5__py3-none-any.whl → 0.2.7__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.

Potentially problematic release.


This version of folio-data-import might be problematic. Click here for more details.

@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import asyncio
3
3
  import glob
4
+ import importlib
4
5
  import io
5
6
  import os
6
7
  import sys
@@ -73,6 +74,7 @@ class MARCImportJob:
73
74
  import_profile_name: str,
74
75
  batch_size=10,
75
76
  batch_delay=0,
77
+ marc_record_preprocessor=None,
76
78
  consolidate=False,
77
79
  no_progress=False,
78
80
  ) -> None:
@@ -84,6 +86,7 @@ class MARCImportJob:
84
86
  self.batch_size = batch_size
85
87
  self.batch_delay = batch_delay
86
88
  self.current_retry_timeout = None
89
+ self.marc_record_preprocessor = marc_record_preprocessor
87
90
 
88
91
  async def do_work(self) -> None:
89
92
  """
@@ -334,6 +337,10 @@ class MARCImportJob:
334
337
  await self.get_job_status()
335
338
  sleep(0.25)
336
339
  if record:
340
+ if self.marc_record_preprocessor:
341
+ record = await self.apply_marc_record_preprocessing(
342
+ record, self.marc_record_preprocessor
343
+ )
337
344
  self.record_batch.append(record.as_marc())
338
345
  counter += 1
339
346
  else:
@@ -343,6 +350,39 @@ class MARCImportJob:
343
350
  await self.create_batch_payload(counter, total_records, True),
344
351
  )
345
352
 
353
+ @staticmethod
354
+ async def apply_marc_record_preprocessing(record: pymarc.Record, func_or_path) -> pymarc.Record:
355
+ """
356
+ Apply preprocessing to the MARC record before sending it to FOLIO.
357
+
358
+ Args:
359
+ record (pymarc.Record): The MARC record to preprocess.
360
+ func_or_path (Union[Callable, str]): The preprocessing function or its import path.
361
+
362
+ Returns:
363
+ pymarc.Record: The preprocessed MARC record.
364
+ """
365
+ if isinstance(func_or_path, str):
366
+ try:
367
+ path_parts = func_or_path.rsplit('.')
368
+ module_path, func_name = ".".join(path_parts[:-1]), path_parts[-1]
369
+ module = importlib.import_module(module_path)
370
+ func = getattr(module, func_name)
371
+ except (ImportError, AttributeError) as e:
372
+ print(f"Error importing preprocessing function {func_or_path}: {e}. Skipping preprocessing.")
373
+ return record
374
+ elif callable(func_or_path):
375
+ func = func_or_path
376
+ else:
377
+ print(f"Invalid preprocessing function: {func_or_path}. Skipping preprocessing.")
378
+ return record
379
+
380
+ try:
381
+ return func(record)
382
+ except Exception as e:
383
+ print(f"Error applying preprocessing function: {e}. Skipping preprocessing.")
384
+ return record
385
+
346
386
  async def create_batch_payload(self, counter, total_records, is_last) -> dict:
347
387
  """
348
388
  Create a batch payload for data import.
@@ -508,6 +548,15 @@ async def main() -> None:
508
548
  help="The number of seconds to wait between record batches.",
509
549
  default=0.0,
510
550
  )
551
+ parser.add_argument(
552
+ "--preprocessor",
553
+ type=str,
554
+ help=(
555
+ "The path to a Python module containing a preprocessing function "
556
+ "to apply to each MARC record before sending to FOLIO."
557
+ ),
558
+ default=None,
559
+ )
511
560
  parser.add_argument(
512
561
  "--consolidate",
513
562
  action="store_true",
@@ -570,6 +619,7 @@ async def main() -> None:
570
619
  args.import_profile_name,
571
620
  batch_size=args.batch_size,
572
621
  batch_delay=args.batch_delay,
622
+ marc_record_preprocessor=args.preprocessor,
573
623
  consolidate=bool(args.consolidate),
574
624
  no_progress=bool(args.no_progress),
575
625
  ).do_work()
@@ -21,6 +21,14 @@ except AttributeError:
21
21
 
22
22
  utc = zoneinfo.ZoneInfo("UTC")
23
23
 
24
+ # Mapping of preferred contact type IDs to their corresponding values
25
+ PREFERRED_CONTACT_TYPES_MAP = {
26
+ "001": "mail",
27
+ "002": "email",
28
+ "003": "text",
29
+ "004": "phone",
30
+ "005": "mobile",
31
+ }
24
32
 
25
33
  class UserImporter: # noqa: R0902
26
34
  """
@@ -33,14 +41,15 @@ class UserImporter: # noqa: R0902
33
41
  self,
34
42
  folio_client: folioclient.FolioClient,
35
43
  library_name: str,
36
- user_file_path: Path,
37
44
  batch_size: int,
38
45
  limit_simultaneous_requests: asyncio.Semaphore,
39
46
  logfile: AsyncTextIOWrapper,
40
47
  errorfile: AsyncTextIOWrapper,
41
48
  http_client: httpx.AsyncClient,
49
+ user_file_path: Path = None,
42
50
  user_match_key: str = "externalSystemId",
43
51
  only_update_present_fields: bool = False,
52
+ default_preferred_contact_type: str = "002",
44
53
  ) -> None:
45
54
  self.limit_simultaneous_requests = limit_simultaneous_requests
46
55
  self.batch_size = batch_size
@@ -56,10 +65,14 @@ class UserImporter: # noqa: R0902
56
65
  self.department_map: dict = self.build_ref_data_id_map(
57
66
  self.folio_client, "/departments", "departments", "name"
58
67
  )
68
+ self.service_point_map: dict = self.build_ref_data_id_map(
69
+ self.folio_client, "/service-points", "servicepoints", "code"
70
+ )
59
71
  self.logfile: AsyncTextIOWrapper = logfile
60
72
  self.errorfile: AsyncTextIOWrapper = errorfile
61
73
  self.http_client: httpx.AsyncClient = http_client
62
74
  self.only_update_present_fields: bool = only_update_present_fields
75
+ self.default_preferred_contact_type: str = default_preferred_contact_type
63
76
  self.match_key = user_match_key
64
77
  self.lock: asyncio.Lock = asyncio.Lock()
65
78
  self.logs: dict = {"created": 0, "updated": 0, "failed": 0}
@@ -87,7 +100,11 @@ class UserImporter: # noqa: R0902
87
100
 
88
101
  This method triggers the process of importing users by calling the `process_file` method.
89
102
  """
90
- await self.process_file()
103
+ if self.user_file_path:
104
+ with open(self.user_file_path, "r", encoding="utf-8") as openfile:
105
+ await self.process_file(openfile)
106
+ else:
107
+ raise FileNotFoundError("No user objects file provided")
91
108
 
92
109
  async def get_existing_user(self, user_obj) -> dict:
93
110
  """
@@ -255,13 +272,14 @@ class UserImporter: # noqa: R0902
255
272
  if mapped_departments:
256
273
  user_obj["departments"] = mapped_departments
257
274
 
258
- async def update_existing_user(self, user_obj, existing_user) -> Tuple[dict, dict]:
275
+ async def update_existing_user(self, user_obj, existing_user, protected_fields) -> Tuple[dict, dict]:
259
276
  """
260
277
  Updates an existing user with the provided user object.
261
278
 
262
279
  Args:
263
280
  user_obj (dict): The user object containing the updated user information.
264
281
  existing_user (dict): The existing user object to be updated.
282
+ protected_fields (dict): A dictionary containing the protected fields and their values.
265
283
 
266
284
  Returns:
267
285
  tuple: A tuple containing the updated existing user object and the API response.
@@ -270,6 +288,8 @@ class UserImporter: # noqa: R0902
270
288
  None
271
289
 
272
290
  """
291
+ await self.set_preferred_contact_type(user_obj, existing_user)
292
+ preferred_contact_type = {"preferredContactTypeId": existing_user.get("personal", {}).pop("preferredContactTypeId")}
273
293
  if self.only_update_present_fields:
274
294
  new_personal = user_obj.pop("personal", {})
275
295
  existing_personal = existing_user.pop("personal", {})
@@ -290,6 +310,18 @@ class UserImporter: # noqa: R0902
290
310
  existing_user["personal"] = existing_personal
291
311
  else:
292
312
  existing_user.update(user_obj)
313
+ if "personal" in existing_user:
314
+ existing_user["personal"].update(preferred_contact_type)
315
+ else:
316
+ existing_user["personal"] = preferred_contact_type
317
+ for key, value in protected_fields.items():
318
+ if type(value) is dict:
319
+ try:
320
+ existing_user[key].update(value)
321
+ except KeyError:
322
+ existing_user[key] = value
323
+ else:
324
+ existing_user[key] = value
293
325
  create_update_user = await self.http_client.put(
294
326
  self.folio_client.okapi_url + f"/users/{existing_user['id']}",
295
327
  headers=self.folio_client.okapi_headers,
@@ -320,7 +352,41 @@ class UserImporter: # noqa: R0902
320
352
  self.logs["created"] += 1
321
353
  return response.json()
322
354
 
323
- async def create_or_update_user(self, user_obj, existing_user, line_number) -> dict:
355
+ async def set_preferred_contact_type(self, user_obj, existing_user) -> None:
356
+ """
357
+ Sets the preferred contact type for a user object. If the provided preferred contact type
358
+ is not valid, the default preferred contact type is used, unless the previously existing
359
+ user object has a valid preferred contact type set. In that case, the existing preferred
360
+ contact type is used.
361
+ """
362
+ if "personal" in user_obj and "preferredContactTypeId" in user_obj["personal"]:
363
+ current_pref_contact = user_obj["personal"].get(
364
+ "preferredContactTypeId", ""
365
+ )
366
+ if mapped_contact_type := dict([(v, k) for k, v in PREFERRED_CONTACT_TYPES_MAP.items()]).get(
367
+ current_pref_contact,
368
+ "",
369
+ ):
370
+ existing_user["personal"]["preferredContactTypeId"] = mapped_contact_type
371
+ else:
372
+ existing_user["personal"]["preferredContactTypeId"] = current_pref_contact if current_pref_contact in PREFERRED_CONTACT_TYPES_MAP else self.default_preferred_contact_type
373
+ else:
374
+ print(
375
+ f"Preferred contact type not provided or is not a valid option: {PREFERRED_CONTACT_TYPES_MAP}\n"
376
+ f"Setting preferred contact type to {self.default_preferred_contact_type} or using existing value"
377
+ )
378
+ await self.logfile.write(
379
+ f"Preferred contact type not provided or is not a valid option: {PREFERRED_CONTACT_TYPES_MAP}\n"
380
+ f"Setting preferred contact type to {self.default_preferred_contact_type} or using existing value\n"
381
+ )
382
+ mapped_contact_type = existing_user.get("personal", {}).get(
383
+ "preferredContactTypeId", ""
384
+ ) or self.default_preferred_contact_type
385
+ if "personal" not in existing_user:
386
+ existing_user["personal"] = {}
387
+ existing_user["personal"]["preferredContactTypeId"] = mapped_contact_type or self.default_preferred_contact_type
388
+
389
+ async def create_or_update_user(self, user_obj, existing_user, protected_fields, line_number) -> dict:
324
390
  """
325
391
  Creates or updates a user based on the given user object and existing user.
326
392
 
@@ -334,7 +400,7 @@ class UserImporter: # noqa: R0902
334
400
  """
335
401
  if existing_user:
336
402
  existing_user, update_user = await self.update_existing_user(
337
- user_obj, existing_user
403
+ user_obj, existing_user, protected_fields
338
404
  )
339
405
  try:
340
406
  update_user.raise_for_status()
@@ -375,7 +441,7 @@ class UserImporter: # noqa: R0902
375
441
 
376
442
  async def process_user_obj(self, user: str) -> dict:
377
443
  """
378
- Process a user object.
444
+ Process a user object. If not type is found in the source object, type is set to "patron".
379
445
 
380
446
  Args:
381
447
  user (str): The user data to be processed, as a json string.
@@ -386,17 +452,34 @@ class UserImporter: # noqa: R0902
386
452
  """
387
453
  user_obj = json.loads(user)
388
454
  user_obj["type"] = user_obj.get("type", "patron")
389
- if "personal" in user_obj:
390
- current_pref_contact = user_obj["personal"].get(
391
- "preferredContactTypeId", ""
392
- )
393
- user_obj["personal"]["preferredContactTypeId"] = (
394
- current_pref_contact
395
- if current_pref_contact in ["001", "002", "003"]
396
- else "002"
397
- )
398
455
  return user_obj
399
456
 
457
+ async def get_protected_fields(self, existing_user) -> dict:
458
+ """
459
+ Retrieves the protected fields from the existing user object.
460
+
461
+ Args:
462
+ existing_user (dict): The existing user object.
463
+
464
+ Returns:
465
+ dict: A dictionary containing the protected fields and their values.
466
+ """
467
+ protected_fields = {}
468
+ protected_fields_list = existing_user.get("customFields", {}).get("protectedFields", "").split(",")
469
+ for field in protected_fields_list:
470
+ if len(field.split(".")) > 1:
471
+ field, subfield = field.split(".")
472
+ if field not in protected_fields:
473
+ protected_fields[field] = {}
474
+ protected_fields[field][subfield] = existing_user.get(field, {}).pop(subfield, None)
475
+ if protected_fields[field][subfield] is None:
476
+ protected_fields[field].pop(subfield)
477
+ else:
478
+ protected_fields[field] = existing_user.pop(field, None)
479
+ if protected_fields[field] is None:
480
+ protected_fields.pop(field)
481
+ return protected_fields
482
+
400
483
  async def process_existing_user(self, user_obj) -> Tuple[dict, dict, dict, dict]:
401
484
  """
402
485
  Process an existing user.
@@ -410,14 +493,19 @@ class UserImporter: # noqa: R0902
410
493
  and the existing PU object (existing_pu).
411
494
  """
412
495
  rp_obj = user_obj.pop("requestPreference", {})
496
+ spu_obj = user_obj.pop("servicePointsUser")
413
497
  existing_user = await self.get_existing_user(user_obj)
414
498
  if existing_user:
415
499
  existing_rp = await self.get_existing_rp(user_obj, existing_user)
416
500
  existing_pu = await self.get_existing_pu(user_obj, existing_user)
501
+ existing_spu = await self.get_existing_spu(existing_user)
502
+ protected_fields = await self.get_protected_fields(existing_user)
417
503
  else:
418
504
  existing_rp = {}
419
505
  existing_pu = {}
420
- return rp_obj, existing_user, existing_rp, existing_pu
506
+ existing_spu = {}
507
+ protected_fields = {}
508
+ return rp_obj, spu_obj, existing_user, protected_fields, existing_rp, existing_pu, existing_spu
421
509
 
422
510
  async def create_or_update_rp(self, rp_obj, existing_rp, new_user_obj):
423
511
  """
@@ -528,14 +616,14 @@ class UserImporter: # noqa: R0902
528
616
  """
529
617
  async with self.limit_simultaneous_requests:
530
618
  user_obj = await self.process_user_obj(user)
531
- rp_obj, existing_user, existing_rp, existing_pu = (
619
+ rp_obj, spu_obj, existing_user, protected_fields, existing_rp, existing_pu, existing_spu = (
532
620
  await self.process_existing_user(user_obj)
533
621
  )
534
622
  await self.map_address_types(user_obj, line_number)
535
623
  await self.map_patron_groups(user_obj, line_number)
536
624
  await self.map_departments(user_obj, line_number)
537
625
  new_user_obj = await self.create_or_update_user(
538
- user_obj, existing_user, line_number
626
+ user_obj, existing_user, protected_fields, line_number
539
627
  )
540
628
  if new_user_obj:
541
629
  try:
@@ -572,42 +660,162 @@ class UserImporter: # noqa: R0902
572
660
  )
573
661
  print(pu_error_message)
574
662
  await self.logfile.write(pu_error_message + "\n")
663
+ await self.handle_service_points_user(spu_obj, existing_spu, new_user_obj)
664
+
665
+ async def map_service_points(self, spu_obj, existing_user):
666
+ """
667
+ Maps the service points of a user object using the provided service point map.
668
+
669
+ Args:
670
+ spu_obj (dict): The service-points-user object to update.
671
+ existing_user (dict): The existing user object associated with the spu_obj.
672
+
673
+ Returns:
674
+ None
675
+ """
676
+ if "servicePointsIds" in spu_obj:
677
+ mapped_service_points = []
678
+ for sp in spu_obj.pop("servicePointsIds", []):
679
+ try:
680
+ mapped_service_points.append(self.service_point_map[sp])
681
+ except KeyError:
682
+ print(
683
+ f'Service point "{sp}" not found, excluding service point from user: '
684
+ f'{self.service_point_map}'
685
+ )
686
+ if mapped_service_points:
687
+ spu_obj["servicePointsIds"] = mapped_service_points
688
+ if "defaultServicePointId" in spu_obj:
689
+ sp_code = spu_obj.pop('defaultServicePointId', '')
690
+ try:
691
+ mapped_sp_id = self.service_point_map[sp_code]
692
+ if mapped_sp_id not in spu_obj.get('servicePointsIds', []):
693
+ print(
694
+ f'Default service point "{sp_code}" not found in assigned service points, '
695
+ 'excluding default service point from user'
696
+ )
697
+ else:
698
+ spu_obj['defaultServicePointId'] = mapped_sp_id
699
+ except KeyError:
700
+ print(
701
+ f'Default service point "{sp_code}" not found, excluding default service '
702
+ f'point from user: {existing_user["id"]}'
703
+ )
704
+
705
+ async def handle_service_points_user(self, spu_obj, existing_spu, existing_user):
706
+ """
707
+ Handles processing a service-points-user object for a user.
708
+
709
+ Args:
710
+ spu_obj (dict): The service-points-user object to process.
711
+ existing_spu (dict): The existing service-points-user object, if it exists.
712
+ existing_user (dict): The existing user object associated with the spu_obj.
713
+ """
714
+ if spu_obj is not None:
715
+ await self.map_service_points(spu_obj, existing_user)
716
+ if existing_spu:
717
+ await self.update_existing_spu(spu_obj, existing_spu)
718
+ else:
719
+ await self.create_new_spu(spu_obj, existing_user)
575
720
 
576
- async def process_file(self) -> None:
721
+ async def get_existing_spu(self, existing_user):
722
+ """
723
+ Retrieves the existing service-points-user object for a given user.
724
+
725
+ Args:
726
+ existing_user (dict): The existing user object.
727
+
728
+ Returns:
729
+ dict: The existing service-points-user object.
730
+ """
731
+ try:
732
+ existing_spu = await self.http_client.get(
733
+ self.folio_client.okapi_url + "/service-points-users",
734
+ headers=self.folio_client.okapi_headers,
735
+ params={"query": f"userId=={existing_user['id']}"},
736
+ )
737
+ existing_spu.raise_for_status()
738
+ existing_spu = existing_spu.json().get("servicePointsUsers", [])
739
+ existing_spu = existing_spu[0] if existing_spu else {}
740
+ except httpx.HTTPError:
741
+ existing_spu = {}
742
+ return existing_spu
743
+
744
+ async def create_new_spu(self, spu_obj, existing_user):
745
+ """
746
+ Creates a new service-points-user object for a given user.
747
+
748
+ Args:
749
+ spu_obj (dict): The service-points-user object to create.
750
+ existing_user (dict): The existing user object.
751
+
752
+ Returns:
753
+ None
754
+ """
755
+ spu_obj["userId"] = existing_user["id"]
756
+ response = await self.http_client.post(
757
+ self.folio_client.okapi_url + "/service-points-users",
758
+ headers=self.folio_client.okapi_headers,
759
+ json=spu_obj,
760
+ )
761
+ response.raise_for_status()
762
+
763
+ async def update_existing_spu(self, spu_obj, existing_spu):
764
+ """
765
+ Updates an existing service-points-user object with the provided service-points-user object.
766
+
767
+ Args:
768
+ spu_obj (dict): The service-points-user object containing the updated values.
769
+ existing_spu (dict): The existing service-points-user object to be updated.
770
+
771
+ Returns:
772
+ None
773
+ """
774
+ existing_spu.update(spu_obj)
775
+ response = await self.http_client.put(
776
+ self.folio_client.okapi_url + f"/service-points-users/{existing_spu['id']}",
777
+ headers=self.folio_client.okapi_headers,
778
+ json=existing_spu,
779
+ )
780
+ response.raise_for_status()
781
+
782
+ async def process_file(self, openfile) -> None:
577
783
  """
578
784
  Process the user object file.
785
+
786
+ Args:
787
+ openfile: The file or file-like object to process.
579
788
  """
580
- with open(self.user_file_path, "r", encoding="utf-8") as openfile:
581
- tasks = []
582
- for line_number, user in enumerate(openfile):
583
- tasks.append(self.process_line(user, line_number))
584
- if len(tasks) == self.batch_size:
585
- start = time.time()
586
- await asyncio.gather(*tasks)
587
- duration = time.time() - start
588
- async with self.lock:
589
- message = (
590
- f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
591
- f"Batch of {self.batch_size} users processed in {duration:.2f} "
592
- f"seconds. - Users created: {self.logs['created']} - Users updated: "
593
- f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
594
- )
595
- print(message)
596
- await self.logfile.write(message + "\n")
597
- tasks = []
598
- if tasks:
789
+ tasks = []
790
+ for line_number, user in enumerate(openfile):
791
+ tasks.append(self.process_line(user, line_number))
792
+ if len(tasks) == self.batch_size:
599
793
  start = time.time()
600
794
  await asyncio.gather(*tasks)
601
795
  duration = time.time() - start
602
796
  async with self.lock:
603
797
  message = (
604
798
  f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
605
- f"Batch of {self.batch_size} users processed in {duration:.2f} seconds. - "
606
- f"Users created: {self.logs['created']} - Users updated: "
799
+ f"Batch of {self.batch_size} users processed in {duration:.2f} "
800
+ f"seconds. - Users created: {self.logs['created']} - Users updated: "
607
801
  f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
608
802
  )
609
803
  print(message)
610
804
  await self.logfile.write(message + "\n")
805
+ tasks = []
806
+ if tasks:
807
+ start = time.time()
808
+ await asyncio.gather(*tasks)
809
+ duration = time.time() - start
810
+ async with self.lock:
811
+ message = (
812
+ f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
813
+ f"Batch of {len(tasks)} users processed in {duration:.2f} seconds. - "
814
+ f"Users created: {self.logs['created']} - Users updated: "
815
+ f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
816
+ )
817
+ print(message)
818
+ await self.logfile.write(message + "\n")
611
819
 
612
820
 
613
821
  async def main() -> None:
@@ -626,6 +834,10 @@ async def main() -> None:
626
834
  --batch_size (int): How many records to process before logging statistics. Default 250.
627
835
  --folio_password (str): The FOLIO password.
628
836
  --user_match_key (str): The key to use to match users. Default "externalSystemId".
837
+ --report_file_base_path (str): The base path for the log and error files. Default "./".
838
+ --update_only_present_fields (bool): Only update fields that are present in the new user object.
839
+ --default_preferred_contact_type (str): The default preferred contact type to use if the provided \
840
+ value is not valid or not present. Default "002".
629
841
 
630
842
  Raises:
631
843
  Exception: If an unknown error occurs during the import process.
@@ -663,11 +875,26 @@ async def main() -> None:
663
875
  choices=["externalSystemId", "barcode", "username"],
664
876
  default="externalSystemId",
665
877
  )
878
+ parser.add_argument(
879
+ "--report_file_base_path",
880
+ help="The base path for the log and error files",
881
+ default="./",
882
+ )
666
883
  parser.add_argument(
667
884
  "--update_only_present_fields",
668
885
  help="Only update fields that are present in the user object",
669
886
  action="store_true",
670
887
  )
888
+ parser.add_argument(
889
+ "--default_preferred_contact_type",
890
+ help=(
891
+ "The default preferred contact type to use if the provided value is not present or not valid. "
892
+ "Note: '002' is the default, and will be used if the provided value is not valid or not present, "
893
+ "unless the existing user object being updated has a valid preferred contact type set."
894
+ ),
895
+ choices=list(PREFERRED_CONTACT_TYPES_MAP.keys()) + list(PREFERRED_CONTACT_TYPES_MAP.values()),
896
+ default="002",
897
+ )
671
898
  args = parser.parse_args()
672
899
 
673
900
  library_name = args.library_name
@@ -692,13 +919,13 @@ async def main() -> None:
692
919
  folio_client.okapi_headers["x-okapi-tenant"] = args.member_tenant_id
693
920
 
694
921
  user_file_path = Path(args.user_file_path)
922
+ report_file_base_path = Path(args.report_file_base_path)
695
923
  log_file_path = (
696
- user_file_path.parent.parent
697
- / "reports"
924
+ report_file_base_path
698
925
  / f"log_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.log"
699
926
  )
700
927
  error_file_path = (
701
- user_file_path.parent
928
+ report_file_base_path
702
929
  / f"failed_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.txt"
703
930
  )
704
931
  async with aiofiles.open(
@@ -711,14 +938,15 @@ async def main() -> None:
711
938
  importer = UserImporter(
712
939
  folio_client,
713
940
  library_name,
714
- user_file_path,
715
941
  batch_size,
716
942
  limit_async_requests,
717
943
  logfile,
718
944
  errorfile,
719
945
  http_client,
946
+ user_file_path,
720
947
  args.user_match_key,
721
948
  args.update_only_present_fields,
949
+ args.default_preferred_contact_type,
722
950
  )
723
951
  await importer.do_import()
724
952
  except Exception as ee:
@@ -0,0 +1 @@
1
+ from ._preprocessors import prepend_ppn_prefix_001, strip_999_ff_fields
@@ -0,0 +1,31 @@
1
+ import pymarc
2
+
3
+ def prepend_ppn_prefix_001(record: pymarc.Record) -> pymarc.Record:
4
+ """
5
+ Prepend the PPN prefix to the record's 001 field. Useful when
6
+ importing records from the ABES SUDOC catalog
7
+
8
+ Args:
9
+ record (pymarc.Record): The MARC record to preprocess.
10
+
11
+ Returns:
12
+ pymarc.Record: The preprocessed MARC record.
13
+ """
14
+ record['001'].data = '(PPN)' + record['001'].data
15
+ return record
16
+
17
+ def strip_999_ff_fields(record: pymarc.Record) -> pymarc.Record:
18
+ """
19
+ Strip all 999 fields with ff indicators from the record.
20
+ Useful when importing records exported from another FOLIO system
21
+
22
+ Args:
23
+ record (pymarc.Record): The MARC record to preprocess.
24
+
25
+ Returns:
26
+ pymarc.Record: The preprocessed MARC record.
27
+ """
28
+ for field in record.get_fields('999'):
29
+ if field.indicators == pymarc.Indicators(*['f', 'f']):
30
+ record.remove_field(field)
31
+ return record
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.1
2
+ Name: folio_data_import
3
+ Version: 0.2.7
4
+ Summary: A python module to interact with the data importing capabilities of the open-source FOLIO ILS
5
+ License: MIT
6
+ Author: Brooks Travis
7
+ Author-email: brooks.travis@gmail.com
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
17
+ Requires-Dist: flake8-bandit (>=4.1.1,<5.0.0)
18
+ Requires-Dist: flake8-black (>=0.3.6,<0.4.0)
19
+ Requires-Dist: flake8-bugbear (>=24.8.19,<25.0.0)
20
+ Requires-Dist: flake8-docstrings (>=1.7.0,<2.0.0)
21
+ Requires-Dist: flake8-isort (>=6.1.1,<7.0.0)
22
+ Requires-Dist: folioclient (>=0.61.0,<0.62.0)
23
+ Requires-Dist: httpx (>=0.27.2,<0.28.0)
24
+ Requires-Dist: inquirer (>=3.4.0,<4.0.0)
25
+ Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
26
+ Requires-Dist: pymarc (>=5.2.2,<6.0.0)
27
+ Requires-Dist: tabulate (>=0.9.0,<0.10.0)
28
+ Requires-Dist: tqdm (>=4.66.5,<5.0.0)
29
+ Description-Content-Type: text/markdown
30
+
31
+ # folio_data_import
32
+
33
+ ## Description
34
+
35
+ This project is designed to import data into the FOLIO LSP. It provides a simple and efficient way to import data from various sources using FOLIO's REST APIs.
36
+
37
+ ## Features
38
+
39
+ - Import MARC records using FOLIO's Data Import system
40
+ - Import User records using FOLIO's User APIs
41
+
42
+ ## Installation
43
+
44
+ ## Installation
45
+
46
+ To install the project using Poetry, follow these steps:
47
+
48
+ 1. Clone the repository.
49
+ 2. Navigate to the project directory: `$ cd /path/to/folio_data_import`.
50
+ 3. Install Poetry if you haven't already: `$ pip install poetry`.
51
+ 4. Install the project and its dependencies: `$ poetry install`.
52
+ 6. Run the application using Poetry: `$ poetry run python -m folio_data_import --help`.
53
+
54
+ Make sure to activate the virtual environment created by Poetry before running the application.
55
+
56
+ ## Usage
57
+
58
+ 1. Prepare the data to be imported in the specified format.
59
+ 2. Run the application and follow the prompts to import the data.
60
+ 3. Monitor the import progress and handle any errors or conflicts that may arise.
61
+
62
+ ### folio-user-import
63
+ When this package is installed via PyPI or using `poetry install` from this repository, it installs a convenience script in your `$PATH` called `folio-user-import`. To view all command line options for this script, run `folio-user-import -h`. In addition to supporting `mod-user-import`-style JSON objects, this script also allows you to manage service point assignments for users by specifying a `servicePointsUser` object in the JSON object, using service point codes in place of UUIDs in the `defaultServicePointId` and `servicePointIds` fields:
64
+ ```
65
+ {
66
+ "username": "checkin-all",
67
+ "barcode": "1728439497039848103",
68
+ "active": true,
69
+ "type": "patron",
70
+ "patronGroup": "staff",
71
+ "departments": [],
72
+ "personal": {
73
+ "lastName": "Admin",
74
+ "firstName": "checkin-all",
75
+ "addresses": [
76
+ {
77
+ "countryId": "HU",
78
+ "addressLine1": "Andrássy Street 1.",
79
+ "addressLine2": "",
80
+ "city": "Budapest",
81
+ "region": "Pest",
82
+ "postalCode": "1061",
83
+ "addressTypeId": "Home",
84
+ "primaryAddress": true
85
+ }
86
+ ],
87
+ "preferredContactTypeId": "email"
88
+ },
89
+ "requestPreference": {
90
+ "holdShelf": true,
91
+ "delivery": false,
92
+ "fulfillment": "Hold Shelf"
93
+ }
94
+ "servicePointsUser": {
95
+ "defaultServicePointId": "cd1",
96
+ "servicePointsIds": [
97
+ "cd1",
98
+ "Online",
99
+ "000",
100
+ "cd2"
101
+ ]
102
+ }
103
+ }
104
+ ```
105
+ #### Matching Existing Users
106
+
107
+ Unlike mod-user-import, this importer does not require `externalSystemId` as the match point for your objects. If the user objects have `id` values, that will be used, falling back to `externalSystemId`. However, you can also specify `username` or `barcode` as the match point if desired, using the `--user_match_key` argument.
108
+
109
+ #### Preferred Contact Type Mapping
110
+
111
+ Another point of departure from the behavior of `mod-user-import` is the handling of `preferredContactTypeId`. This importer will accept either the `"001", "002", "003"...` values stored by the FOLIO, or the human-friendly strings used by `mod-user-import` (`"mail", "email", "text", "phone", "mobile"`). It will also __*set a customizable default for all users that do not otherwise have a valid value specified*__ (using `--default_preferred_contact_type`), unless a (valid) value is already present in the user record being updated.
112
+
113
+ #### Field Protection (*experimental*)
114
+
115
+ This script offers a rudimentary field protection implementation using custom fields. To enable this functionality, create a text custom field that has the field name `protectedFields`. In this field, you ca specify a comma-separated list of User schema field names, using dot-notation for nested fields. This protection should support all standard fields except addresses within `personal.addresses`. If you include `personal.addresses` in a user record, any existing addresses will be replaced by the new values.
116
+
117
+ ##### Example
118
+
119
+ ```
120
+ {
121
+ "protectedFields": "customFields.protectedFields,personal.preferredFirstName,barcode,personal.telephone,personal.addresses"
122
+ }
123
+ ```
124
+
125
+ Would result in `preferredFirstName`, `barcode`, and `telephone` remaining unchanged, regardless of the contents of the incoming records.
126
+
127
+
128
+ #### How to use:
129
+ 1. Generate a JSON lines (one JSON object per line) file of FOLIO user objects in the style of [mod-user-import](https://github.com/folio-org/mod-user-import)
130
+ 2. Run the script and specify the required arguments (and any desired optional arguments), including the path to your file of user objects
131
+
132
+
133
+ ## Contributing
134
+
135
+ Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request.
136
+
137
+ ## License
138
+
139
+ This project is licensed under the [MIT License](LICENSE).
140
+
@@ -0,0 +1,11 @@
1
+ folio_data_import/MARCDataImport.py,sha256=gFBq6DwghC3hXPkkM-c0XlPjtoZwITVAeEhH8joPIQo,23450
2
+ folio_data_import/UserImport.py,sha256=DPZz6yG2SGWlDvOthohjybOVs7_r494mtNOwv6q66m0,38588
3
+ folio_data_import/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ folio_data_import/__main__.py,sha256=kav_uUsnrIjGjVxQkk3exLKrc1mah9t2x3G6bGS-5I0,3710
5
+ folio_data_import/marc_preprocessors/__init__.py,sha256=Wt-TKkMhUyZWFS-WhAmbShKQLPjXmHKPb2vL6kvkqVA,72
6
+ folio_data_import/marc_preprocessors/_preprocessors.py,sha256=srx36pgY0cwl6_0z6CVOyM_Uzr_g2RObo1jJJjSEZJs,944
7
+ folio_data_import-0.2.7.dist-info/LICENSE,sha256=qJX7wxMC7ky9Kq4v3zij8MjGEiC5wsB7pYeOhLj5TDk,1083
8
+ folio_data_import-0.2.7.dist-info/METADATA,sha256=YR-xCFmHuQvwIpMGZu4VC_VVlUd2US2m7ANJ6GGvto8,6112
9
+ folio_data_import-0.2.7.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
10
+ folio_data_import-0.2.7.dist-info/entry_points.txt,sha256=498SxWVXeEMRNw3PUf-eoReZvKewmYwPBtZhIUPr_Jg,192
11
+ folio_data_import-0.2.7.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 1.9.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,68 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: folio_data_import
3
- Version: 0.2.5
4
- Summary: A python module to interact with the data importing capabilities of the open-source FOLIO ILS
5
- License: MIT
6
- Author: Brooks Travis
7
- Author-email: brooks.travis@gmail.com
8
- Requires-Python: >=3.9,<4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
16
- Requires-Dist: flake8-bandit (>=4.1.1,<5.0.0)
17
- Requires-Dist: flake8-black (>=0.3.6,<0.4.0)
18
- Requires-Dist: flake8-bugbear (>=24.8.19,<25.0.0)
19
- Requires-Dist: flake8-docstrings (>=1.7.0,<2.0.0)
20
- Requires-Dist: flake8-isort (>=6.1.1,<7.0.0)
21
- Requires-Dist: folioclient (>=0.60.5,<0.61.0)
22
- Requires-Dist: httpx (>=0.27.2,<0.28.0)
23
- Requires-Dist: inquirer (>=3.4.0,<4.0.0)
24
- Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
25
- Requires-Dist: pymarc (>=5.2.2,<6.0.0)
26
- Requires-Dist: tabulate (>=0.9.0,<0.10.0)
27
- Requires-Dist: tqdm (>=4.66.5,<5.0.0)
28
- Description-Content-Type: text/markdown
29
-
30
- # folio_data_import
31
-
32
- ## Description
33
-
34
- This project is designed to import data into the FOLIO LSP. It provides a simple and efficient way to import data from various sources using FOLIO's REST APIs.
35
-
36
- ## Features
37
-
38
- - Import MARC records using FOLIO's Data Import system
39
- - Import User records using FOLIO's User APIs
40
-
41
- ## Installation
42
-
43
- ## Installation
44
-
45
- To install the project using Poetry, follow these steps:
46
-
47
- 1. Clone the repository.
48
- 2. Navigate to the project directory: `$ cd /path/to/folio_data_import`.
49
- 3. Install Poetry if you haven't already: `$ pip install poetry`.
50
- 4. Install the project dependencies: `$ poetry install`.
51
- 6. Run the application using Poetry: `$ poetry run python -m folio_data_import --help`.
52
-
53
- Make sure to activate the virtual environment created by Poetry before running the application.
54
-
55
- ## Usage
56
-
57
- 1. Prepare the data to be imported in the specified format.
58
- 2. Run the application and follow the prompts to import the data.
59
- 3. Monitor the import progress and handle any errors or conflicts that may arise.
60
-
61
- ## Contributing
62
-
63
- Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request.
64
-
65
- ## License
66
-
67
- This project is licensed under the [MIT License](LICENSE).
68
-
@@ -1,9 +0,0 @@
1
- folio_data_import/MARCDataImport.py,sha256=Agm3y-BTYK5YiD3SvihQykRhqfmsXzEGmY-AXMGMrmc,21409
2
- folio_data_import/UserImport.py,sha256=9oDVSax-E6HXy6ViBgY97EprXRgtywguFizT3vfh2zE,27951
3
- folio_data_import/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- folio_data_import/__main__.py,sha256=kav_uUsnrIjGjVxQkk3exLKrc1mah9t2x3G6bGS-5I0,3710
5
- folio_data_import-0.2.5.dist-info/LICENSE,sha256=qJX7wxMC7ky9Kq4v3zij8MjGEiC5wsB7pYeOhLj5TDk,1083
6
- folio_data_import-0.2.5.dist-info/METADATA,sha256=cOqxonYSLtuOAo68E_rz8zuojl9iiRnE-KMnePEhePI,2420
7
- folio_data_import-0.2.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
8
- folio_data_import-0.2.5.dist-info/entry_points.txt,sha256=498SxWVXeEMRNw3PUf-eoReZvKewmYwPBtZhIUPr_Jg,192
9
- folio_data_import-0.2.5.dist-info/RECORD,,