folio-data-import 0.5.0b3__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.
- folio_data_import/BatchPoster.py +1265 -0
- folio_data_import/MARCDataImport.py +1252 -0
- folio_data_import/UserImport.py +1270 -0
- folio_data_import/__init__.py +31 -0
- folio_data_import/__main__.py +14 -0
- folio_data_import/_progress.py +737 -0
- folio_data_import/custom_exceptions.py +35 -0
- folio_data_import/marc_preprocessors/__init__.py +29 -0
- folio_data_import/marc_preprocessors/_preprocessors.py +517 -0
- folio_data_import-0.5.0b3.dist-info/METADATA +467 -0
- folio_data_import-0.5.0b3.dist-info/RECORD +13 -0
- folio_data_import-0.5.0b3.dist-info/WHEEL +4 -0
- folio_data_import-0.5.0b3.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from io import TextIOWrapper
|
|
10
|
+
from datetime import datetime as dt
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Literal, Tuple, Annotated
|
|
13
|
+
|
|
14
|
+
import aiofiles
|
|
15
|
+
import cyclopts
|
|
16
|
+
import folioclient
|
|
17
|
+
import httpx
|
|
18
|
+
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
from rich.logging import RichHandler
|
|
21
|
+
|
|
22
|
+
from folio_data_import import get_folio_connection_parameters
|
|
23
|
+
from folio_data_import._progress import (
|
|
24
|
+
RichProgressReporter,
|
|
25
|
+
ProgressReporter,
|
|
26
|
+
NoOpProgressReporter,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
utc = datetime.UTC
|
|
31
|
+
except AttributeError:
|
|
32
|
+
import zoneinfo
|
|
33
|
+
|
|
34
|
+
utc = zoneinfo.ZoneInfo("UTC")
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Mapping of preferred contact type IDs to their corresponding values
|
|
39
|
+
PREFERRED_CONTACT_TYPES_MAP = {
|
|
40
|
+
"001": "mail",
|
|
41
|
+
"002": "email",
|
|
42
|
+
"003": "text",
|
|
43
|
+
"004": "phone",
|
|
44
|
+
"005": "mobile",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
USER_MATCH_KEYS = ["username", "barcode", "externalSystemId"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UserImporterStats(BaseModel):
|
|
52
|
+
"""Statistics for user import operations."""
|
|
53
|
+
|
|
54
|
+
created: int = 0
|
|
55
|
+
updated: int = 0
|
|
56
|
+
failed: int = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UserImporter: # noqa: R0902
|
|
60
|
+
"""
|
|
61
|
+
Class to import mod-user-import compatible user objects
|
|
62
|
+
(eg. from folio_migration_tools UserTransformer task)
|
|
63
|
+
from a JSON-lines file into FOLIO
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
class Config(BaseModel):
|
|
67
|
+
"""Configuration for UserImporter operations."""
|
|
68
|
+
|
|
69
|
+
library_name: Annotated[
|
|
70
|
+
str,
|
|
71
|
+
Field(
|
|
72
|
+
title="Library name",
|
|
73
|
+
description="The library name associated with the import job",
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
batch_size: Annotated[
|
|
77
|
+
int,
|
|
78
|
+
Field(
|
|
79
|
+
title="Batch size",
|
|
80
|
+
description="Number of users to process in each batch",
|
|
81
|
+
ge=1,
|
|
82
|
+
le=1000,
|
|
83
|
+
),
|
|
84
|
+
] = 250
|
|
85
|
+
user_match_key: Annotated[
|
|
86
|
+
Literal["externalSystemId", "username", "barcode"],
|
|
87
|
+
Field(
|
|
88
|
+
title="User match key",
|
|
89
|
+
description="The key to use for matching existing users",
|
|
90
|
+
),
|
|
91
|
+
] = "externalSystemId"
|
|
92
|
+
only_update_present_fields: Annotated[
|
|
93
|
+
bool,
|
|
94
|
+
Field(
|
|
95
|
+
title="Only update present fields",
|
|
96
|
+
description=(
|
|
97
|
+
"When enabled, only fields present in the input will be updated. "
|
|
98
|
+
"Missing fields will be left unchanged in existing records."
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
] = False
|
|
102
|
+
default_preferred_contact_type: Annotated[
|
|
103
|
+
Literal["001", "002", "003", "004", "005", "mail", "email", "text", "phone", "mobile"],
|
|
104
|
+
Field(
|
|
105
|
+
title="Default preferred contact type",
|
|
106
|
+
description=(
|
|
107
|
+
"Default preferred contact type for users. "
|
|
108
|
+
"Can be specified as ID (001-005) or name (mail/email/text/phone/mobile). "
|
|
109
|
+
"Will be applied to users without a valid value already set."
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
] = "002"
|
|
113
|
+
fields_to_protect: Annotated[
|
|
114
|
+
List[str],
|
|
115
|
+
Field(
|
|
116
|
+
title="Fields to protect",
|
|
117
|
+
description=(
|
|
118
|
+
"List of field paths to protect from updates "
|
|
119
|
+
"(e.g., ['personal.email', 'barcode']). "
|
|
120
|
+
"Protected fields will not be modified during updates."
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
] = []
|
|
124
|
+
limit_simultaneous_requests: Annotated[
|
|
125
|
+
int,
|
|
126
|
+
Field(
|
|
127
|
+
title="Limit simultaneous requests",
|
|
128
|
+
description="Maximum number of concurrent async HTTP requests",
|
|
129
|
+
ge=1,
|
|
130
|
+
le=100,
|
|
131
|
+
),
|
|
132
|
+
] = 10
|
|
133
|
+
user_file_paths: Annotated[
|
|
134
|
+
Path | List[Path] | None,
|
|
135
|
+
Field(
|
|
136
|
+
title="User file paths",
|
|
137
|
+
description="Path or list of paths to JSON-lines file(s) containing user data",
|
|
138
|
+
),
|
|
139
|
+
] = None
|
|
140
|
+
no_progress: Annotated[
|
|
141
|
+
bool,
|
|
142
|
+
Field(
|
|
143
|
+
title="No progress bar",
|
|
144
|
+
description="Disable the progress bar display",
|
|
145
|
+
),
|
|
146
|
+
] = False
|
|
147
|
+
|
|
148
|
+
logfile: AsyncTextIOWrapper
|
|
149
|
+
errorfile: AsyncTextIOWrapper
|
|
150
|
+
http_client: httpx.AsyncClient
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
folio_client: folioclient.FolioClient,
|
|
155
|
+
config: "UserImporter.Config",
|
|
156
|
+
reporter: ProgressReporter | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.config = config
|
|
159
|
+
self.folio_client: folioclient.FolioClient = folio_client
|
|
160
|
+
self.reporter = reporter or NoOpProgressReporter()
|
|
161
|
+
self.limit_simultaneous_requests = asyncio.Semaphore(config.limit_simultaneous_requests)
|
|
162
|
+
# Build reference data maps (these need processing)
|
|
163
|
+
self.patron_group_map: dict = self.build_ref_data_id_map(
|
|
164
|
+
self.folio_client, "/groups", "usergroups", "group"
|
|
165
|
+
)
|
|
166
|
+
self.address_type_map: dict = self.build_ref_data_id_map(
|
|
167
|
+
self.folio_client, "/addresstypes", "addressTypes", "addressType"
|
|
168
|
+
)
|
|
169
|
+
self.department_map: dict = self.build_ref_data_id_map(
|
|
170
|
+
self.folio_client, "/departments", "departments", "name"
|
|
171
|
+
)
|
|
172
|
+
self.service_point_map: dict = self.build_ref_data_id_map(
|
|
173
|
+
self.folio_client, "/service-points", "servicepoints", "code"
|
|
174
|
+
)
|
|
175
|
+
# Convert fields_to_protect to a set to dedupe
|
|
176
|
+
self.fields_to_protect = set(config.fields_to_protect)
|
|
177
|
+
self.lock: asyncio.Lock = asyncio.Lock()
|
|
178
|
+
self.stats = UserImporterStats()
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def build_ref_data_id_map(
|
|
182
|
+
folio_client: folioclient.FolioClient, endpoint: str, key: str, name: str
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""
|
|
185
|
+
Builds a map of reference data IDs.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
folio_client (folioclient.FolioClient): A FolioClient object.
|
|
189
|
+
endpoint (str): The endpoint to retrieve the reference data from.
|
|
190
|
+
key (str): The key to use as the map key.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
dict: A dictionary mapping reference data keys to their corresponding IDs.
|
|
194
|
+
"""
|
|
195
|
+
return {x[name]: x["id"] for x in folio_client.folio_get_all(endpoint, key)}
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def validate_uuid(uuid_string: str) -> bool:
|
|
199
|
+
"""
|
|
200
|
+
Validate a UUID string.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
uuid_string (str): The UUID string to validate.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
bool: True if the UUID is valid, otherwise False.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
uuid.UUID(uuid_string)
|
|
210
|
+
return True
|
|
211
|
+
except ValueError:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
async def setup(self, error_file_path: Path) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Sets up the importer by initializing necessary resources.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
log_file_path (Path): The path to the log file.
|
|
220
|
+
error_file_path (Path): The path to the error file.
|
|
221
|
+
"""
|
|
222
|
+
self.errorfile = await aiofiles.open(error_file_path, "w", encoding="utf-8")
|
|
223
|
+
|
|
224
|
+
async def close(self) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Closes the importer by releasing any resources.
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
await self.errorfile.close()
|
|
230
|
+
|
|
231
|
+
async def do_import(self) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Main method to import users.
|
|
234
|
+
|
|
235
|
+
This method triggers the process of importing users by calling the `process_file` method.
|
|
236
|
+
Supports both single file path and list of file paths.
|
|
237
|
+
"""
|
|
238
|
+
async with httpx.AsyncClient() as client:
|
|
239
|
+
self.http_client = client
|
|
240
|
+
if not self.config.user_file_paths:
|
|
241
|
+
raise FileNotFoundError("No user objects file provided")
|
|
242
|
+
|
|
243
|
+
# Normalize to list of paths
|
|
244
|
+
file_paths = (
|
|
245
|
+
[self.config.user_file_paths]
|
|
246
|
+
if isinstance(self.config.user_file_paths, Path)
|
|
247
|
+
else self.config.user_file_paths
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Process each file
|
|
251
|
+
for idx, file_path in enumerate(file_paths, start=1):
|
|
252
|
+
if len(file_paths) > 1:
|
|
253
|
+
logger.info(f"Processing file {idx} of {len(file_paths)}: {file_path.name}")
|
|
254
|
+
with open(file_path, "r", encoding="utf-8") as openfile:
|
|
255
|
+
await self.process_file(openfile)
|
|
256
|
+
|
|
257
|
+
async def get_existing_user(self, user_obj) -> dict:
|
|
258
|
+
"""
|
|
259
|
+
Retrieves an existing user from FOLIO based on the provided user object.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
user_obj: The user object containing the information to match against existing users.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The existing user object if found, otherwise an empty dictionary.
|
|
266
|
+
"""
|
|
267
|
+
match_key = "id" if ("id" in user_obj) else self.config.user_match_key
|
|
268
|
+
try:
|
|
269
|
+
existing_user = await self.http_client.get(
|
|
270
|
+
self.folio_client.gateway_url + "/users",
|
|
271
|
+
headers=self.folio_client.okapi_headers,
|
|
272
|
+
params={"query": f"{match_key}=={user_obj[match_key]}"},
|
|
273
|
+
)
|
|
274
|
+
existing_user.raise_for_status()
|
|
275
|
+
existing_user = existing_user.json().get("users", [])
|
|
276
|
+
existing_user = existing_user[0] if existing_user else {}
|
|
277
|
+
except httpx.HTTPError:
|
|
278
|
+
existing_user = {}
|
|
279
|
+
return existing_user
|
|
280
|
+
|
|
281
|
+
async def get_existing_rp(self, user_obj, existing_user) -> dict:
|
|
282
|
+
"""
|
|
283
|
+
Retrieves the existing request preferences for a given user.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
user_obj (dict): The user object.
|
|
287
|
+
existing_user (dict): The existing user object.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
dict: The existing request preferences for the user.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
existing_rp = await self.http_client.get(
|
|
294
|
+
self.folio_client.gateway_url + "/request-preference-storage/request-preference",
|
|
295
|
+
headers=self.folio_client.okapi_headers,
|
|
296
|
+
params={"query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"},
|
|
297
|
+
)
|
|
298
|
+
existing_rp.raise_for_status()
|
|
299
|
+
existing_rp = existing_rp.json().get("requestPreferences", [])
|
|
300
|
+
existing_rp = existing_rp[0] if existing_rp else {}
|
|
301
|
+
except httpx.HTTPError:
|
|
302
|
+
existing_rp = {}
|
|
303
|
+
return existing_rp
|
|
304
|
+
|
|
305
|
+
async def get_existing_pu(self, user_obj, existing_user) -> dict:
|
|
306
|
+
"""
|
|
307
|
+
Retrieves the existing permission user for a given user.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
user_obj (dict): The user object.
|
|
311
|
+
existing_user (dict): The existing user object.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
dict: The existing permission user object.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
existing_pu = await self.http_client.get(
|
|
318
|
+
self.folio_client.gateway_url + "/perms/users",
|
|
319
|
+
headers=self.folio_client.okapi_headers,
|
|
320
|
+
params={"query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"},
|
|
321
|
+
)
|
|
322
|
+
existing_pu.raise_for_status()
|
|
323
|
+
existing_pu = existing_pu.json().get("permissionUsers", [])
|
|
324
|
+
existing_pu = existing_pu[0] if existing_pu else {}
|
|
325
|
+
except httpx.HTTPError:
|
|
326
|
+
existing_pu = {}
|
|
327
|
+
return existing_pu
|
|
328
|
+
|
|
329
|
+
async def map_address_types(self, user_obj, line_number: int) -> None:
|
|
330
|
+
"""
|
|
331
|
+
Maps address type names in the user object to the corresponding ID in the address_type_map.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
user_obj (dict): The user object containing personal information.
|
|
335
|
+
address_type_map (dict): A dictionary mapping address type names to their ID values.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
None
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
KeyError: If an address type name in the user object is not found in address_type_map.
|
|
342
|
+
|
|
343
|
+
"""
|
|
344
|
+
if "personal" in user_obj:
|
|
345
|
+
addresses = user_obj["personal"].pop("addresses", [])
|
|
346
|
+
mapped_addresses = []
|
|
347
|
+
for address in addresses:
|
|
348
|
+
try:
|
|
349
|
+
if (
|
|
350
|
+
self.validate_uuid(address["addressTypeId"])
|
|
351
|
+
and address["addressTypeId"] in self.address_type_map.values()
|
|
352
|
+
):
|
|
353
|
+
logger.debug(
|
|
354
|
+
f"Row {line_number}: Address type {address['addressTypeId']} "
|
|
355
|
+
f"is a UUID, skipping mapping\n"
|
|
356
|
+
)
|
|
357
|
+
mapped_addresses.append(address)
|
|
358
|
+
else:
|
|
359
|
+
address["addressTypeId"] = self.address_type_map[address["addressTypeId"]]
|
|
360
|
+
mapped_addresses.append(address)
|
|
361
|
+
except KeyError:
|
|
362
|
+
if address["addressTypeId"] not in self.address_type_map.values():
|
|
363
|
+
logger.error(
|
|
364
|
+
f"Row {line_number}: Address type {address['addressTypeId']} not found"
|
|
365
|
+
f", removing address\n"
|
|
366
|
+
)
|
|
367
|
+
if mapped_addresses:
|
|
368
|
+
user_obj["personal"]["addresses"] = mapped_addresses
|
|
369
|
+
|
|
370
|
+
async def map_patron_groups(self, user_obj, line_number: int) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Maps the patron group of a user object using the provided patron group map.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
user_obj (dict): The user object to update.
|
|
376
|
+
patron_group_map (dict): A dictionary mapping patron group names.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
None
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
if (
|
|
383
|
+
self.validate_uuid(user_obj["patronGroup"])
|
|
384
|
+
and user_obj["patronGroup"] in self.patron_group_map.values()
|
|
385
|
+
):
|
|
386
|
+
logger.debug(
|
|
387
|
+
f"Row {line_number}: Patron group {user_obj['patronGroup']} is a UUID, "
|
|
388
|
+
f"skipping mapping\n"
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
user_obj["patronGroup"] = self.patron_group_map[user_obj["patronGroup"]]
|
|
392
|
+
except KeyError:
|
|
393
|
+
if user_obj["patronGroup"] not in self.patron_group_map.values():
|
|
394
|
+
logger.error(
|
|
395
|
+
f"Row {line_number}: Patron group {user_obj['patronGroup']} not found in, "
|
|
396
|
+
f"removing patron group\n"
|
|
397
|
+
)
|
|
398
|
+
del user_obj["patronGroup"]
|
|
399
|
+
|
|
400
|
+
async def map_departments(self, user_obj, line_number: int) -> None:
|
|
401
|
+
"""
|
|
402
|
+
Maps the departments of a user object using the provided department map.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
user_obj (dict): The user object to update.
|
|
406
|
+
department_map (dict): A dictionary mapping department names.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
None
|
|
410
|
+
"""
|
|
411
|
+
mapped_departments = []
|
|
412
|
+
for department in user_obj.pop("departments", []):
|
|
413
|
+
try:
|
|
414
|
+
if self.validate_uuid(department) and department in self.department_map.values():
|
|
415
|
+
logger.debug(
|
|
416
|
+
f"Row {line_number}: Department {department} is a UUID, skipping mapping\n"
|
|
417
|
+
)
|
|
418
|
+
mapped_departments.append(department)
|
|
419
|
+
else:
|
|
420
|
+
mapped_departments.append(self.department_map[department])
|
|
421
|
+
except KeyError:
|
|
422
|
+
logger.error(
|
|
423
|
+
f'Row {line_number}: Department "{department}" not found, ' # noqa: B907
|
|
424
|
+
f"excluding department from user\n"
|
|
425
|
+
)
|
|
426
|
+
if mapped_departments:
|
|
427
|
+
user_obj["departments"] = mapped_departments
|
|
428
|
+
|
|
429
|
+
async def update_existing_user(
|
|
430
|
+
self, user_obj, existing_user, protected_fields
|
|
431
|
+
) -> Tuple[dict, httpx.Response]:
|
|
432
|
+
"""
|
|
433
|
+
Updates an existing user with the provided user object.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
user_obj (dict): The user object containing the updated user information.
|
|
437
|
+
existing_user (dict): The existing user object to be updated.
|
|
438
|
+
protected_fields (dict): A dictionary containing the protected fields and their values.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
tuple: A tuple containing the updated existing user object and the API response.
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
None
|
|
445
|
+
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
await self.set_preferred_contact_type(user_obj, existing_user)
|
|
449
|
+
preferred_contact_type = {
|
|
450
|
+
"preferredContactTypeId": existing_user.get("personal", {}).pop(
|
|
451
|
+
"preferredContactTypeId"
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
if self.config.only_update_present_fields:
|
|
455
|
+
new_personal = user_obj.pop("personal", {})
|
|
456
|
+
existing_personal = existing_user.pop("personal", {})
|
|
457
|
+
existing_preferred_first_name = existing_personal.pop("preferredFirstName", "")
|
|
458
|
+
existing_addresses = existing_personal.get("addresses", [])
|
|
459
|
+
existing_user.update(user_obj)
|
|
460
|
+
existing_personal.update(new_personal)
|
|
461
|
+
if (
|
|
462
|
+
not existing_personal.get("preferredFirstName", "")
|
|
463
|
+
and existing_preferred_first_name
|
|
464
|
+
):
|
|
465
|
+
existing_personal["preferredFirstName"] = existing_preferred_first_name
|
|
466
|
+
if not existing_personal.get("addresses", []):
|
|
467
|
+
existing_personal["addresses"] = existing_addresses
|
|
468
|
+
if existing_personal:
|
|
469
|
+
existing_user["personal"] = existing_personal
|
|
470
|
+
else:
|
|
471
|
+
existing_user.update(user_obj)
|
|
472
|
+
if "personal" in existing_user:
|
|
473
|
+
existing_user["personal"].update(preferred_contact_type)
|
|
474
|
+
else:
|
|
475
|
+
existing_user["personal"] = preferred_contact_type
|
|
476
|
+
for key, value in protected_fields.items():
|
|
477
|
+
if type(value) is dict:
|
|
478
|
+
try:
|
|
479
|
+
existing_user[key].update(value)
|
|
480
|
+
except KeyError:
|
|
481
|
+
existing_user[key] = value
|
|
482
|
+
else:
|
|
483
|
+
existing_user[key] = value
|
|
484
|
+
create_update_user = await self.http_client.put(
|
|
485
|
+
self.folio_client.gateway_url + f"/users/{existing_user['id']}",
|
|
486
|
+
headers=self.folio_client.okapi_headers,
|
|
487
|
+
json=existing_user,
|
|
488
|
+
)
|
|
489
|
+
return existing_user, create_update_user
|
|
490
|
+
|
|
491
|
+
async def create_new_user(self, user_obj) -> dict:
|
|
492
|
+
"""
|
|
493
|
+
Creates a new user in the system.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
user_obj (dict): A dictionary containing the user information.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
dict: A dictionary representing the response from the server.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
HTTPError: If the HTTP request to create the user fails.
|
|
503
|
+
"""
|
|
504
|
+
response = await self.http_client.post(
|
|
505
|
+
self.folio_client.gateway_url + "/users",
|
|
506
|
+
headers=self.folio_client.okapi_headers,
|
|
507
|
+
json=user_obj,
|
|
508
|
+
)
|
|
509
|
+
response.raise_for_status()
|
|
510
|
+
async with self.lock:
|
|
511
|
+
self.stats.created += 1
|
|
512
|
+
return response.json()
|
|
513
|
+
|
|
514
|
+
async def set_preferred_contact_type(self, user_obj, existing_user) -> None:
|
|
515
|
+
"""
|
|
516
|
+
Sets the preferred contact type for a user object. If the provided preferred contact type
|
|
517
|
+
is not valid, the default preferred contact type is used, unless the previously existing
|
|
518
|
+
user object has a valid preferred contact type set. In that case, the existing preferred
|
|
519
|
+
contact type is used.
|
|
520
|
+
"""
|
|
521
|
+
if "personal" in user_obj and "preferredContactTypeId" in user_obj["personal"]:
|
|
522
|
+
current_pref_contact = user_obj["personal"].get("preferredContactTypeId", "")
|
|
523
|
+
if mapped_contact_type := {v: k for k, v in PREFERRED_CONTACT_TYPES_MAP.items()}.get(
|
|
524
|
+
current_pref_contact,
|
|
525
|
+
"",
|
|
526
|
+
):
|
|
527
|
+
existing_user["personal"]["preferredContactTypeId"] = mapped_contact_type
|
|
528
|
+
else:
|
|
529
|
+
existing_user["personal"]["preferredContactTypeId"] = (
|
|
530
|
+
current_pref_contact
|
|
531
|
+
if current_pref_contact in PREFERRED_CONTACT_TYPES_MAP
|
|
532
|
+
else self.config.default_preferred_contact_type
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
logger.warning(
|
|
536
|
+
f"Preferred contact type not provided or is not a valid option: "
|
|
537
|
+
f"{PREFERRED_CONTACT_TYPES_MAP} Setting preferred contact type to "
|
|
538
|
+
f"{self.config.default_preferred_contact_type} or using existing value"
|
|
539
|
+
)
|
|
540
|
+
mapped_contact_type = (
|
|
541
|
+
existing_user.get("personal", {}).get("preferredContactTypeId", "")
|
|
542
|
+
or self.config.default_preferred_contact_type
|
|
543
|
+
)
|
|
544
|
+
if "personal" not in existing_user:
|
|
545
|
+
existing_user["personal"] = {}
|
|
546
|
+
existing_user["personal"]["preferredContactTypeId"] = (
|
|
547
|
+
mapped_contact_type or self.config.default_preferred_contact_type
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
async def create_or_update_user(
|
|
551
|
+
self, user_obj, existing_user, protected_fields, line_number: int
|
|
552
|
+
) -> dict:
|
|
553
|
+
"""
|
|
554
|
+
Creates or updates a user based on the given user object and existing user.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
user_obj (dict): The user object containing the user details.
|
|
558
|
+
existing_user (dict): The existing user object to be updated, if available.
|
|
559
|
+
logs (dict): A dictionary to keep track of the number of updates and failures.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
dict: The updated or created user object, or an empty dictionary an error occurs.
|
|
563
|
+
"""
|
|
564
|
+
if existing_user:
|
|
565
|
+
existing_user, update_user = await self.update_existing_user(
|
|
566
|
+
user_obj, existing_user, protected_fields
|
|
567
|
+
)
|
|
568
|
+
try:
|
|
569
|
+
update_user.raise_for_status()
|
|
570
|
+
async with self.lock:
|
|
571
|
+
self.stats.updated += 1
|
|
572
|
+
return existing_user
|
|
573
|
+
except Exception as ee:
|
|
574
|
+
logger.error(
|
|
575
|
+
f"Row {line_number}: User update failed: "
|
|
576
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
|
|
577
|
+
)
|
|
578
|
+
await self.errorfile.write(json.dumps(existing_user, ensure_ascii=False) + "\n")
|
|
579
|
+
async with self.lock:
|
|
580
|
+
self.stats.failed += 1
|
|
581
|
+
return {}
|
|
582
|
+
else:
|
|
583
|
+
try:
|
|
584
|
+
new_user = await self.create_new_user(user_obj)
|
|
585
|
+
return new_user
|
|
586
|
+
except Exception as ee:
|
|
587
|
+
logger.error(
|
|
588
|
+
f"Row {line_number}: User creation failed: "
|
|
589
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
|
|
590
|
+
)
|
|
591
|
+
await self.errorfile.write(json.dumps(user_obj, ensure_ascii=False) + "\n")
|
|
592
|
+
async with self.lock:
|
|
593
|
+
self.stats.failed += 1
|
|
594
|
+
return {}
|
|
595
|
+
|
|
596
|
+
async def process_user_obj(self, user: str) -> dict:
|
|
597
|
+
"""
|
|
598
|
+
Process a user object. If not type is found in the source object, type is set to "patron".
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
user (str): The user data to be processed, as a json string.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
dict: The processed user object.
|
|
605
|
+
|
|
606
|
+
"""
|
|
607
|
+
user_obj = json.loads(user)
|
|
608
|
+
user_obj["type"] = user_obj.get("type", "patron")
|
|
609
|
+
return user_obj
|
|
610
|
+
|
|
611
|
+
async def get_protected_fields(self, existing_user) -> dict:
|
|
612
|
+
"""
|
|
613
|
+
Retrieves the protected fields from the existing user object,
|
|
614
|
+
combining both the customFields.protectedFields list *and*
|
|
615
|
+
any fields_to_protect passed on the CLI.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
existing_user (dict): The existing user object.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
dict: A dictionary containing the protected fields and their values.
|
|
622
|
+
"""
|
|
623
|
+
protected_fields = {}
|
|
624
|
+
protected_fields_list = (
|
|
625
|
+
existing_user.get("customFields", {}).get("protectedFields", "").split(",")
|
|
626
|
+
)
|
|
627
|
+
cli_fields = list(self.fields_to_protect)
|
|
628
|
+
# combine and dedupe:
|
|
629
|
+
all_fields = list(dict.fromkeys(protected_fields_list + cli_fields))
|
|
630
|
+
for field in all_fields:
|
|
631
|
+
if "." in field:
|
|
632
|
+
fld, subfld = field.split(".", 1)
|
|
633
|
+
val = existing_user.get(fld, {}).pop(subfld, None)
|
|
634
|
+
if val is not None:
|
|
635
|
+
protected_fields.setdefault(fld, {})[subfld] = val
|
|
636
|
+
else:
|
|
637
|
+
val = existing_user.pop(field, None)
|
|
638
|
+
if val is not None:
|
|
639
|
+
protected_fields[field] = val
|
|
640
|
+
return protected_fields
|
|
641
|
+
|
|
642
|
+
async def process_existing_user(
|
|
643
|
+
self, user_obj
|
|
644
|
+
) -> Tuple[dict, dict, dict, dict, dict, dict, dict]:
|
|
645
|
+
"""
|
|
646
|
+
Process an existing user.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
user_obj (dict): The user object to process.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
tuple: A tuple containing the request preference object (rp_obj),
|
|
653
|
+
the service points user object (spu_obj),
|
|
654
|
+
the existing user object, the protected fields,
|
|
655
|
+
the existing request preference object (existing_rp),
|
|
656
|
+
the existing PU object (existing_pu),
|
|
657
|
+
and the existing SPU object (existing_spu).
|
|
658
|
+
"""
|
|
659
|
+
rp_obj = user_obj.pop("requestPreference", {})
|
|
660
|
+
spu_obj = user_obj.pop("servicePointsUser", {})
|
|
661
|
+
existing_user = await self.get_existing_user(user_obj)
|
|
662
|
+
if existing_user:
|
|
663
|
+
existing_rp = await self.get_existing_rp(user_obj, existing_user)
|
|
664
|
+
existing_pu = await self.get_existing_pu(user_obj, existing_user)
|
|
665
|
+
existing_spu = await self.get_existing_spu(existing_user)
|
|
666
|
+
protected_fields = await self.get_protected_fields(existing_user)
|
|
667
|
+
else:
|
|
668
|
+
existing_rp = {}
|
|
669
|
+
existing_pu = {}
|
|
670
|
+
existing_spu = {}
|
|
671
|
+
protected_fields = {}
|
|
672
|
+
return (
|
|
673
|
+
rp_obj,
|
|
674
|
+
spu_obj,
|
|
675
|
+
existing_user,
|
|
676
|
+
protected_fields,
|
|
677
|
+
existing_rp,
|
|
678
|
+
existing_pu,
|
|
679
|
+
existing_spu,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
async def create_or_update_rp(self, rp_obj, existing_rp, new_user_obj):
|
|
683
|
+
"""
|
|
684
|
+
Creates or updates a requet preference object based on the given parameters.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
rp_obj (object): A new requet preference object.
|
|
688
|
+
existing_rp (object): The existing resource provider object, if it exists.
|
|
689
|
+
new_user_obj (object): The new user object.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
None
|
|
693
|
+
"""
|
|
694
|
+
if existing_rp:
|
|
695
|
+
await self.update_existing_rp(rp_obj, existing_rp)
|
|
696
|
+
else:
|
|
697
|
+
await self.create_new_rp(new_user_obj)
|
|
698
|
+
|
|
699
|
+
async def create_new_rp(self, new_user_obj):
|
|
700
|
+
"""
|
|
701
|
+
Creates a new request preference for a user.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
new_user_obj (dict): The user object containing the user's ID.
|
|
705
|
+
|
|
706
|
+
Raises:
|
|
707
|
+
HTTPError: If there is an error in the HTTP request.
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
None
|
|
711
|
+
"""
|
|
712
|
+
rp_obj = {"holdShelf": True, "delivery": False}
|
|
713
|
+
rp_obj["userId"] = new_user_obj["id"]
|
|
714
|
+
response = await self.http_client.post(
|
|
715
|
+
self.folio_client.gateway_url + "/request-preference-storage/request-preference",
|
|
716
|
+
headers=self.folio_client.okapi_headers,
|
|
717
|
+
json=rp_obj,
|
|
718
|
+
)
|
|
719
|
+
response.raise_for_status()
|
|
720
|
+
|
|
721
|
+
async def update_existing_rp(self, rp_obj, existing_rp) -> None:
|
|
722
|
+
"""
|
|
723
|
+
Updates an existing request preference with the provided request preference object.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
rp_obj (dict): The request preference object containing the updated values.
|
|
727
|
+
existing_rp (dict): The existing request preference object to be updated.
|
|
728
|
+
|
|
729
|
+
Raises:
|
|
730
|
+
HTTPError: If the PUT request to update the request preference fails.
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
None
|
|
734
|
+
"""
|
|
735
|
+
existing_rp.update(rp_obj)
|
|
736
|
+
response = await self.http_client.put(
|
|
737
|
+
self.folio_client.gateway_url
|
|
738
|
+
+ f"/request-preference-storage/request-preference/{existing_rp['id']}",
|
|
739
|
+
headers=self.folio_client.okapi_headers,
|
|
740
|
+
json=existing_rp,
|
|
741
|
+
)
|
|
742
|
+
response.raise_for_status()
|
|
743
|
+
|
|
744
|
+
async def create_perms_user(self, new_user_obj) -> None:
|
|
745
|
+
"""
|
|
746
|
+
Creates a permissions user object for the given new user.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
new_user_obj (dict): A dictionary containing the details of the new user.
|
|
750
|
+
|
|
751
|
+
Raises:
|
|
752
|
+
HTTPError: If there is an error while making the HTTP request.
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
None
|
|
756
|
+
"""
|
|
757
|
+
perms_user_obj = {"userId": new_user_obj["id"], "permissions": []}
|
|
758
|
+
response = await self.http_client.post(
|
|
759
|
+
self.folio_client.gateway_url + "/perms/users",
|
|
760
|
+
headers=self.folio_client.okapi_headers,
|
|
761
|
+
json=perms_user_obj,
|
|
762
|
+
)
|
|
763
|
+
response.raise_for_status()
|
|
764
|
+
|
|
765
|
+
async def process_line(
|
|
766
|
+
self,
|
|
767
|
+
user: str,
|
|
768
|
+
line_number: int,
|
|
769
|
+
) -> None:
|
|
770
|
+
"""
|
|
771
|
+
Process a single line of user data.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
user (str): The user data to be processed.
|
|
775
|
+
logs (dict): A dictionary to store logs.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
None
|
|
779
|
+
|
|
780
|
+
Raises:
|
|
781
|
+
Any exceptions that occur during the processing.
|
|
782
|
+
|
|
783
|
+
"""
|
|
784
|
+
async with self.limit_simultaneous_requests:
|
|
785
|
+
user_obj = await self.process_user_obj(user)
|
|
786
|
+
(
|
|
787
|
+
rp_obj,
|
|
788
|
+
spu_obj,
|
|
789
|
+
existing_user,
|
|
790
|
+
protected_fields,
|
|
791
|
+
existing_rp,
|
|
792
|
+
existing_pu,
|
|
793
|
+
existing_spu,
|
|
794
|
+
) = await self.process_existing_user(user_obj)
|
|
795
|
+
await self.map_address_types(user_obj, line_number)
|
|
796
|
+
await self.map_patron_groups(user_obj, line_number)
|
|
797
|
+
await self.map_departments(user_obj, line_number)
|
|
798
|
+
new_user_obj = await self.create_or_update_user(
|
|
799
|
+
user_obj, existing_user, protected_fields, line_number
|
|
800
|
+
)
|
|
801
|
+
if new_user_obj:
|
|
802
|
+
try:
|
|
803
|
+
if existing_rp or rp_obj:
|
|
804
|
+
await self.create_or_update_rp(rp_obj, existing_rp, new_user_obj)
|
|
805
|
+
else:
|
|
806
|
+
logger.debug(
|
|
807
|
+
f"Row {line_number}: Creating default request preference object"
|
|
808
|
+
f" for {new_user_obj['id']}\n"
|
|
809
|
+
)
|
|
810
|
+
await self.create_new_rp(new_user_obj)
|
|
811
|
+
except Exception as ee: # noqa: W0718
|
|
812
|
+
rp_error_message = (
|
|
813
|
+
f"Row {line_number}: Error creating or updating request preferences for "
|
|
814
|
+
f"{new_user_obj['id']}: "
|
|
815
|
+
f"{str(getattr(getattr(ee, 'response', ee), 'text', str(ee)))}"
|
|
816
|
+
)
|
|
817
|
+
logger.error(rp_error_message)
|
|
818
|
+
if not existing_pu:
|
|
819
|
+
try:
|
|
820
|
+
await self.create_perms_user(new_user_obj)
|
|
821
|
+
except Exception as ee: # noqa: W0718
|
|
822
|
+
pu_error_message = (
|
|
823
|
+
f"Row {line_number}: Error creating permissionUser object for user: "
|
|
824
|
+
f"{new_user_obj['id']}: "
|
|
825
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
|
|
826
|
+
)
|
|
827
|
+
logger.error(pu_error_message)
|
|
828
|
+
await self.handle_service_points_user(spu_obj, existing_spu, new_user_obj)
|
|
829
|
+
|
|
830
|
+
async def map_service_points(self, spu_obj, existing_user):
|
|
831
|
+
"""
|
|
832
|
+
Maps the service points of a user object using the provided service point map.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
spu_obj (dict): The service-points-user object to update.
|
|
836
|
+
existing_user (dict): The existing user object associated with the spu_obj.
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
None
|
|
840
|
+
"""
|
|
841
|
+
if "servicePointsIds" in spu_obj:
|
|
842
|
+
mapped_service_points = []
|
|
843
|
+
for sp in spu_obj.pop("servicePointsIds", []):
|
|
844
|
+
try:
|
|
845
|
+
if self.validate_uuid(sp) and sp in self.service_point_map.values():
|
|
846
|
+
logger.debug(f"Service point {sp} is a UUID, skipping mapping\n")
|
|
847
|
+
mapped_service_points.append(sp)
|
|
848
|
+
else:
|
|
849
|
+
mapped_service_points.append(self.service_point_map[sp])
|
|
850
|
+
except KeyError:
|
|
851
|
+
logger.error(
|
|
852
|
+
f'Service point "{sp}" not found, excluding service point from user: '
|
|
853
|
+
f"{self.service_point_map}"
|
|
854
|
+
)
|
|
855
|
+
if mapped_service_points:
|
|
856
|
+
spu_obj["servicePointsIds"] = mapped_service_points
|
|
857
|
+
if "defaultServicePointId" in spu_obj:
|
|
858
|
+
sp_code = spu_obj.pop("defaultServicePointId", "")
|
|
859
|
+
try:
|
|
860
|
+
if self.validate_uuid(sp_code) and sp_code in self.service_point_map.values():
|
|
861
|
+
logger.debug(f"Default service point {sp_code} is a UUID, skipping mapping\n")
|
|
862
|
+
mapped_sp_id = sp_code
|
|
863
|
+
else:
|
|
864
|
+
mapped_sp_id = self.service_point_map[sp_code]
|
|
865
|
+
if mapped_sp_id not in spu_obj.get("servicePointsIds", []):
|
|
866
|
+
logger.warning(
|
|
867
|
+
f'Default service point "{sp_code}" not found in assigned service points, '
|
|
868
|
+
"excluding default service point from user"
|
|
869
|
+
)
|
|
870
|
+
else:
|
|
871
|
+
spu_obj["defaultServicePointId"] = mapped_sp_id
|
|
872
|
+
except KeyError:
|
|
873
|
+
logger.error(
|
|
874
|
+
f'Default service point "{sp_code}" not found, excluding default service '
|
|
875
|
+
f"point from user: {existing_user['id']}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
async def handle_service_points_user(self, spu_obj, existing_spu, existing_user):
|
|
879
|
+
"""
|
|
880
|
+
Handles processing a service-points-user object for a user.
|
|
881
|
+
|
|
882
|
+
Args:
|
|
883
|
+
spu_obj (dict): The service-points-user object to process.
|
|
884
|
+
existing_spu (dict): The existing service-points-user object, if it exists.
|
|
885
|
+
existing_user (dict): The existing user object associated with the spu_obj.
|
|
886
|
+
"""
|
|
887
|
+
if spu_obj:
|
|
888
|
+
await self.map_service_points(spu_obj, existing_user)
|
|
889
|
+
if existing_spu:
|
|
890
|
+
await self.update_existing_spu(spu_obj, existing_spu)
|
|
891
|
+
else:
|
|
892
|
+
await self.create_new_spu(spu_obj, existing_user)
|
|
893
|
+
|
|
894
|
+
async def get_existing_spu(self, existing_user):
|
|
895
|
+
"""
|
|
896
|
+
Retrieves the existing service-points-user object for a given user.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
existing_user (dict): The existing user object.
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
dict: The existing service-points-user object.
|
|
903
|
+
"""
|
|
904
|
+
try:
|
|
905
|
+
existing_spu = await self.http_client.get(
|
|
906
|
+
self.folio_client.gateway_url + "/service-points-users",
|
|
907
|
+
headers=self.folio_client.okapi_headers,
|
|
908
|
+
params={"query": f"userId=={existing_user['id']}"},
|
|
909
|
+
)
|
|
910
|
+
existing_spu.raise_for_status()
|
|
911
|
+
existing_spu = existing_spu.json().get("servicePointsUsers", [])
|
|
912
|
+
existing_spu = existing_spu[0] if existing_spu else {}
|
|
913
|
+
except httpx.HTTPError:
|
|
914
|
+
existing_spu = {}
|
|
915
|
+
return existing_spu
|
|
916
|
+
|
|
917
|
+
async def create_new_spu(self, spu_obj, existing_user):
|
|
918
|
+
"""
|
|
919
|
+
Creates a new service-points-user object for a given user.
|
|
920
|
+
|
|
921
|
+
Args:
|
|
922
|
+
spu_obj (dict): The service-points-user object to create.
|
|
923
|
+
existing_user (dict): The existing user object.
|
|
924
|
+
|
|
925
|
+
Returns:
|
|
926
|
+
None
|
|
927
|
+
"""
|
|
928
|
+
spu_obj["userId"] = existing_user["id"]
|
|
929
|
+
response = await self.http_client.post(
|
|
930
|
+
self.folio_client.gateway_url + "/service-points-users",
|
|
931
|
+
headers=self.folio_client.okapi_headers,
|
|
932
|
+
json=spu_obj,
|
|
933
|
+
)
|
|
934
|
+
response.raise_for_status()
|
|
935
|
+
|
|
936
|
+
async def update_existing_spu(self, spu_obj, existing_spu):
|
|
937
|
+
"""
|
|
938
|
+
Updates an existing service-points-user object with the provided service-points-user object.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
spu_obj (dict): The service-points-user object containing the updated values.
|
|
942
|
+
existing_spu (dict): The existing service-points-user object to be updated.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
None
|
|
946
|
+
""" # noqa: E501
|
|
947
|
+
existing_spu.update(spu_obj)
|
|
948
|
+
response = await self.http_client.put(
|
|
949
|
+
self.folio_client.gateway_url + f"/service-points-users/{existing_spu['id']}",
|
|
950
|
+
headers=self.folio_client.okapi_headers,
|
|
951
|
+
json=existing_spu,
|
|
952
|
+
)
|
|
953
|
+
response.raise_for_status()
|
|
954
|
+
|
|
955
|
+
async def process_file(self, openfile: TextIOWrapper) -> None:
|
|
956
|
+
"""
|
|
957
|
+
Process the user object file.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
openfile: The file or file-like object to process.
|
|
961
|
+
"""
|
|
962
|
+
with open(openfile.name, "rb") as f:
|
|
963
|
+
total_lines = sum(buf.count(b"\n") for buf in iter(lambda: f.read(1024 * 1024), b""))
|
|
964
|
+
|
|
965
|
+
with self.reporter:
|
|
966
|
+
task_id = self.reporter.start_task(
|
|
967
|
+
"users",
|
|
968
|
+
total=total_lines,
|
|
969
|
+
description="Importing users",
|
|
970
|
+
)
|
|
971
|
+
openfile.seek(0)
|
|
972
|
+
tasks = []
|
|
973
|
+
for line_number, user in enumerate(openfile):
|
|
974
|
+
tasks.append(self.process_line(user, line_number))
|
|
975
|
+
if len(tasks) == self.config.batch_size:
|
|
976
|
+
start = time.time()
|
|
977
|
+
await asyncio.gather(*tasks)
|
|
978
|
+
duration = time.time() - start
|
|
979
|
+
async with self.lock:
|
|
980
|
+
self.reporter.update_task(
|
|
981
|
+
task_id,
|
|
982
|
+
advance=len(tasks),
|
|
983
|
+
created=self.stats.created,
|
|
984
|
+
updated=self.stats.updated,
|
|
985
|
+
failed=self.stats.failed,
|
|
986
|
+
)
|
|
987
|
+
message = (
|
|
988
|
+
f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
|
|
989
|
+
f"Batch of {self.config.batch_size} users processed in {duration:.2f} "
|
|
990
|
+
f"seconds. - Users created: {self.stats.created} - Users updated: "
|
|
991
|
+
f"{self.stats.updated} - Users failed: {self.stats.failed}"
|
|
992
|
+
)
|
|
993
|
+
logger.info(message)
|
|
994
|
+
tasks = []
|
|
995
|
+
if tasks:
|
|
996
|
+
start = time.time()
|
|
997
|
+
await asyncio.gather(*tasks)
|
|
998
|
+
duration = time.time() - start
|
|
999
|
+
async with self.lock:
|
|
1000
|
+
self.reporter.update_task(
|
|
1001
|
+
task_id,
|
|
1002
|
+
advance=len(tasks),
|
|
1003
|
+
created=self.stats.created,
|
|
1004
|
+
updated=self.stats.updated,
|
|
1005
|
+
failed=self.stats.failed,
|
|
1006
|
+
)
|
|
1007
|
+
message = (
|
|
1008
|
+
f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
|
|
1009
|
+
f"Batch of {len(tasks)} users processed in {duration:.2f} seconds. - "
|
|
1010
|
+
f"Users created: {self.stats.created} - Users updated: "
|
|
1011
|
+
f"{self.stats.updated} - Users failed: {self.stats.failed}"
|
|
1012
|
+
)
|
|
1013
|
+
logger.info(message)
|
|
1014
|
+
|
|
1015
|
+
def get_stats(self) -> UserImporterStats:
|
|
1016
|
+
"""
|
|
1017
|
+
Get current import statistics.
|
|
1018
|
+
|
|
1019
|
+
Returns:
|
|
1020
|
+
Current statistics
|
|
1021
|
+
"""
|
|
1022
|
+
return self.stats
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def set_up_cli_logging() -> None:
|
|
1026
|
+
"""
|
|
1027
|
+
This function sets up logging for the CLI.
|
|
1028
|
+
"""
|
|
1029
|
+
logger.setLevel(logging.INFO)
|
|
1030
|
+
logger.propagate = False
|
|
1031
|
+
|
|
1032
|
+
# Set up file and stream handlers
|
|
1033
|
+
file_handler = logging.FileHandler(
|
|
1034
|
+
"folio_user_import_{}.log".format(dt.now().strftime("%Y%m%d%H%M%S"))
|
|
1035
|
+
)
|
|
1036
|
+
file_handler.setLevel(logging.INFO)
|
|
1037
|
+
file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
|
1038
|
+
file_handler.setFormatter(file_formatter)
|
|
1039
|
+
logger.addHandler(file_handler)
|
|
1040
|
+
|
|
1041
|
+
if not any(
|
|
1042
|
+
isinstance(h, logging.StreamHandler) and h.stream == sys.stderr for h in logger.handlers
|
|
1043
|
+
):
|
|
1044
|
+
stream_handler = RichHandler(
|
|
1045
|
+
show_level=False,
|
|
1046
|
+
show_time=False,
|
|
1047
|
+
omit_repeated_times=False,
|
|
1048
|
+
show_path=False,
|
|
1049
|
+
)
|
|
1050
|
+
stream_handler.setLevel(logging.WARNING)
|
|
1051
|
+
stream_formatter = logging.Formatter("%(message)s")
|
|
1052
|
+
stream_handler.setFormatter(stream_formatter)
|
|
1053
|
+
logger.addHandler(stream_handler)
|
|
1054
|
+
|
|
1055
|
+
# Stop httpx from logging info messages to the console
|
|
1056
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
app = cyclopts.App()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
@app.default
|
|
1063
|
+
def main(
|
|
1064
|
+
config_file: Annotated[
|
|
1065
|
+
Path | None, cyclopts.Parameter(group="Job Configuration Parameters")
|
|
1066
|
+
] = None,
|
|
1067
|
+
*,
|
|
1068
|
+
gateway_url: Annotated[
|
|
1069
|
+
str | None,
|
|
1070
|
+
cyclopts.Parameter(
|
|
1071
|
+
env_var="FOLIO_GATEWAY_URL", show_env_var=True, group="FOLIO Connection Parameters"
|
|
1072
|
+
),
|
|
1073
|
+
] = None,
|
|
1074
|
+
tenant_id: Annotated[
|
|
1075
|
+
str | None,
|
|
1076
|
+
cyclopts.Parameter(
|
|
1077
|
+
env_var="FOLIO_TENANT_ID", show_env_var=True, group="FOLIO Connection Parameters"
|
|
1078
|
+
),
|
|
1079
|
+
] = None,
|
|
1080
|
+
username: Annotated[
|
|
1081
|
+
str | None,
|
|
1082
|
+
cyclopts.Parameter(
|
|
1083
|
+
env_var="FOLIO_USERNAME", show_env_var=True, group="FOLIO Connection Parameters"
|
|
1084
|
+
),
|
|
1085
|
+
] = None,
|
|
1086
|
+
password: Annotated[
|
|
1087
|
+
str | None,
|
|
1088
|
+
cyclopts.Parameter(
|
|
1089
|
+
env_var="FOLIO_PASSWORD", show_env_var=True, group="FOLIO Connection Parameters"
|
|
1090
|
+
),
|
|
1091
|
+
] = None,
|
|
1092
|
+
library_name: Annotated[
|
|
1093
|
+
str | None,
|
|
1094
|
+
cyclopts.Parameter(
|
|
1095
|
+
env_var="FOLIO_LIBRARY_NAME", show_env_var=True, group="Job Configuration Parameters"
|
|
1096
|
+
),
|
|
1097
|
+
] = None,
|
|
1098
|
+
user_file_paths: Annotated[
|
|
1099
|
+
Tuple[Path, ...] | None,
|
|
1100
|
+
cyclopts.Parameter(
|
|
1101
|
+
name=["--user-file-paths", "--user-file-path"],
|
|
1102
|
+
help=(
|
|
1103
|
+
"Path(s) to user data file(s). "
|
|
1104
|
+
"Accepts multiple values. Can be used as --user-file-paths or --user-file-path."
|
|
1105
|
+
),
|
|
1106
|
+
group="Job Configuration Parameters",
|
|
1107
|
+
),
|
|
1108
|
+
] = None,
|
|
1109
|
+
member_tenant_id: Annotated[
|
|
1110
|
+
str | None,
|
|
1111
|
+
cyclopts.Parameter(
|
|
1112
|
+
env_var="FOLIO_MEMBER_TENANT_ID",
|
|
1113
|
+
show_env_var=True,
|
|
1114
|
+
group="FOLIO Connection Parameters",
|
|
1115
|
+
),
|
|
1116
|
+
] = None,
|
|
1117
|
+
fields_to_protect: Annotated[
|
|
1118
|
+
str | None,
|
|
1119
|
+
cyclopts.Parameter(
|
|
1120
|
+
env_var="FOLIO_FIELDS_TO_PROTECT",
|
|
1121
|
+
show_env_var=True,
|
|
1122
|
+
group="Job Configuration Parameters",
|
|
1123
|
+
),
|
|
1124
|
+
] = None,
|
|
1125
|
+
update_only_present_fields: Annotated[
|
|
1126
|
+
bool, cyclopts.Parameter(group="Job Configuration Parameters")
|
|
1127
|
+
] = False,
|
|
1128
|
+
limit_async_requests: Annotated[
|
|
1129
|
+
int,
|
|
1130
|
+
cyclopts.Parameter(
|
|
1131
|
+
env_var="FOLIO_LIMIT_ASYNC_REQUESTS",
|
|
1132
|
+
show_env_var=True,
|
|
1133
|
+
group="Job Configuration Parameters",
|
|
1134
|
+
),
|
|
1135
|
+
] = 10,
|
|
1136
|
+
batch_size: Annotated[
|
|
1137
|
+
int,
|
|
1138
|
+
cyclopts.Parameter(
|
|
1139
|
+
env_var="FOLIO_USER_IMPORT_BATCH_SIZE",
|
|
1140
|
+
show_env_var=True,
|
|
1141
|
+
group="Job Configuration Parameters",
|
|
1142
|
+
),
|
|
1143
|
+
] = 250,
|
|
1144
|
+
report_file_base_path: Annotated[
|
|
1145
|
+
Path | None, cyclopts.Parameter(group="Job Configuration Parameters")
|
|
1146
|
+
] = None,
|
|
1147
|
+
user_match_key: Annotated[
|
|
1148
|
+
Literal["externalSystemId", "username", "barcode"],
|
|
1149
|
+
cyclopts.Parameter(group="Job Configuration Parameters"),
|
|
1150
|
+
] = "externalSystemId",
|
|
1151
|
+
default_preferred_contact_type: Annotated[
|
|
1152
|
+
Literal["001", "002", "003", "004", "005", "mail", "email", "text", "phone", "mobile"],
|
|
1153
|
+
cyclopts.Parameter(group="Job Configuration Parameters"),
|
|
1154
|
+
] = "email",
|
|
1155
|
+
no_progress: Annotated[bool, cyclopts.Parameter(group="Job Configuration Parameters")] = False,
|
|
1156
|
+
) -> None:
|
|
1157
|
+
"""
|
|
1158
|
+
Command-line interface to batch import users into FOLIO
|
|
1159
|
+
|
|
1160
|
+
Parameters:
|
|
1161
|
+
config_file (Path | None): Path to a JSON configuration file. Overrides job configuration parameters if provided.
|
|
1162
|
+
gateway_url (str): The FOLIO gateway URL.
|
|
1163
|
+
tenant_id (str): The FOLIO tenant ID.
|
|
1164
|
+
username (str): The FOLIO username.
|
|
1165
|
+
password (str): The FOLIO password.
|
|
1166
|
+
library_name (str): The library name associated with the job.
|
|
1167
|
+
user_file_paths (Tuple[Path, ...]): Path(s) to the user data file(s). Use
|
|
1168
|
+
--user-file-paths or --user-file-path (deprecated, will be removed in future versions).
|
|
1169
|
+
member_tenant_id (str): The FOLIO ECS member tenant id (if applicable).
|
|
1170
|
+
fields_to_protect (str): Comma-separated list of fields to protect during update.
|
|
1171
|
+
update_only_present_fields (bool): Whether to update only fields present in the input.
|
|
1172
|
+
limit_async_requests (int): The maximum number of concurrent async HTTP requests.
|
|
1173
|
+
batch_size (int): The number of users to process in each batch.
|
|
1174
|
+
report_file_base_path (Path): The base path for report files.
|
|
1175
|
+
user_match_key (str): The key to match users (externalSystemId, username, barcode).
|
|
1176
|
+
default_preferred_contact_type (str): The default preferred contact type for users
|
|
1177
|
+
no_progress (bool): Whether to disable the progress bar.
|
|
1178
|
+
""" # noqa: E501
|
|
1179
|
+
set_up_cli_logging()
|
|
1180
|
+
fields_to_protect = fields_to_protect or ""
|
|
1181
|
+
protect_fields = [f.strip() for f in fields_to_protect.split(",") if f.strip()]
|
|
1182
|
+
|
|
1183
|
+
gateway_url, tenant_id, username, password = get_folio_connection_parameters(
|
|
1184
|
+
gateway_url, tenant_id, username, password
|
|
1185
|
+
)
|
|
1186
|
+
folio_client = folioclient.FolioClient(gateway_url, tenant_id, username, password)
|
|
1187
|
+
|
|
1188
|
+
# Set the member tenant id if provided to support FOLIO ECS multi-tenant environments
|
|
1189
|
+
if member_tenant_id:
|
|
1190
|
+
folio_client.tenant_id = member_tenant_id
|
|
1191
|
+
|
|
1192
|
+
if not library_name:
|
|
1193
|
+
raise ValueError("library_name is required")
|
|
1194
|
+
|
|
1195
|
+
if not user_file_paths:
|
|
1196
|
+
raise ValueError(
|
|
1197
|
+
"You must provide at least one user file path using --user-file-paths or "
|
|
1198
|
+
"--user-file-path."
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
# Expand any glob patterns in file paths
|
|
1202
|
+
expanded_paths = []
|
|
1203
|
+
for path_arg in user_file_paths:
|
|
1204
|
+
path_str = str(path_arg)
|
|
1205
|
+
# Check if it contains glob wildcards
|
|
1206
|
+
if any(char in path_str for char in ["*", "?", "["]):
|
|
1207
|
+
# Expand the glob pattern
|
|
1208
|
+
matches = glob.glob(path_str)
|
|
1209
|
+
if matches:
|
|
1210
|
+
expanded_paths.extend([Path(p) for p in sorted(matches)])
|
|
1211
|
+
else:
|
|
1212
|
+
# No matches - treat as literal path (will error later if file doesn't exist)
|
|
1213
|
+
expanded_paths.append(path_arg)
|
|
1214
|
+
else:
|
|
1215
|
+
expanded_paths.append(path_arg)
|
|
1216
|
+
|
|
1217
|
+
# Convert to single Path or List[Path] for Config
|
|
1218
|
+
file_paths_list = expanded_paths if len(expanded_paths) > 1 else expanded_paths[0]
|
|
1219
|
+
|
|
1220
|
+
report_file_base_path = report_file_base_path or Path.cwd()
|
|
1221
|
+
error_file_path = (
|
|
1222
|
+
report_file_base_path / f"failed_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.txt"
|
|
1223
|
+
)
|
|
1224
|
+
try:
|
|
1225
|
+
# Create UserImporter.Config object
|
|
1226
|
+
if config_file:
|
|
1227
|
+
with open(config_file, "r") as f:
|
|
1228
|
+
config_data = json.load(f)
|
|
1229
|
+
config = UserImporter.Config(**config_data)
|
|
1230
|
+
else:
|
|
1231
|
+
config = UserImporter.Config(
|
|
1232
|
+
library_name=library_name,
|
|
1233
|
+
batch_size=batch_size,
|
|
1234
|
+
user_match_key=user_match_key,
|
|
1235
|
+
only_update_present_fields=update_only_present_fields,
|
|
1236
|
+
default_preferred_contact_type=default_preferred_contact_type,
|
|
1237
|
+
fields_to_protect=protect_fields,
|
|
1238
|
+
limit_simultaneous_requests=limit_async_requests,
|
|
1239
|
+
user_file_paths=file_paths_list,
|
|
1240
|
+
no_progress=no_progress,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Create progress reporter
|
|
1244
|
+
reporter = (
|
|
1245
|
+
NoOpProgressReporter()
|
|
1246
|
+
if no_progress
|
|
1247
|
+
else RichProgressReporter(show_speed=True, show_time=True)
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
importer = UserImporter(folio_client, config, reporter)
|
|
1251
|
+
asyncio.run(run_user_importer(importer, error_file_path))
|
|
1252
|
+
except Exception as ee:
|
|
1253
|
+
logger.critical(f"An unknown error occurred: {ee}")
|
|
1254
|
+
sys.exit(1)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
async def run_user_importer(importer: UserImporter, error_file_path: Path):
|
|
1258
|
+
try:
|
|
1259
|
+
await importer.setup(error_file_path)
|
|
1260
|
+
await importer.do_import()
|
|
1261
|
+
except Exception as ee:
|
|
1262
|
+
logger.critical(f"An unknown error occurred: {ee}")
|
|
1263
|
+
sys.exit(1)
|
|
1264
|
+
finally:
|
|
1265
|
+
await importer.close()
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
# Run the main function
|
|
1269
|
+
if __name__ == "__main__":
|
|
1270
|
+
app()
|