folio-data-import 0.1.0__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.
- folio_data_import-0.1.0.dist-info/LICENSE +21 -0
- folio_data_import-0.1.0.dist-info/METADATA +63 -0
- folio_data_import-0.1.0.dist-info/RECORD +9 -0
- folio_data_import-0.1.0.dist-info/WHEEL +4 -0
- folio_data_import-0.1.0.dist-info/entry_points.txt +5 -0
- src/folio_data_import/MARCDataImport.py +528 -0
- src/folio_data_import/UserImport.py +724 -0
- src/folio_data_import/__init__.py +0 -0
- src/folio_data_import/__main__.py +109 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import datetime
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime as dt
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Tuple
|
|
11
|
+
|
|
12
|
+
import aiofiles
|
|
13
|
+
import folioclient
|
|
14
|
+
import httpx
|
|
15
|
+
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
utc = datetime.UTC
|
|
19
|
+
except AttributeError:
|
|
20
|
+
import zoneinfo
|
|
21
|
+
|
|
22
|
+
utc = zoneinfo.ZoneInfo("UTC")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UserImporter: # noqa: R0902
|
|
26
|
+
"""
|
|
27
|
+
Class to import mod-user-import compatible user objects
|
|
28
|
+
(eg. from folio_migration_tools UserTransformer task)
|
|
29
|
+
from a JSON-lines file into FOLIO
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
folio_client: folioclient.FolioClient,
|
|
35
|
+
library_name: str,
|
|
36
|
+
user_file_path: Path,
|
|
37
|
+
batch_size: int,
|
|
38
|
+
limit_simultaneous_requests: asyncio.Semaphore,
|
|
39
|
+
logfile: AsyncTextIOWrapper,
|
|
40
|
+
errorfile: AsyncTextIOWrapper,
|
|
41
|
+
http_client: httpx.AsyncClient,
|
|
42
|
+
user_match_key: str = "externalSystemId",
|
|
43
|
+
only_update_present_fields: bool = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.limit_simultaneous_requests = limit_simultaneous_requests
|
|
46
|
+
self.batch_size = batch_size
|
|
47
|
+
self.folio_client: folioclient.FolioClient = folio_client
|
|
48
|
+
self.library_name: str = library_name
|
|
49
|
+
self.user_file_path: Path = user_file_path
|
|
50
|
+
self.patron_group_map: dict = self.build_ref_data_id_map(
|
|
51
|
+
self.folio_client, "/groups", "usergroups", "group"
|
|
52
|
+
)
|
|
53
|
+
self.address_type_map: dict = self.build_ref_data_id_map(
|
|
54
|
+
self.folio_client, "/addresstypes", "addressTypes", "addressType"
|
|
55
|
+
)
|
|
56
|
+
self.department_map: dict = self.build_ref_data_id_map(
|
|
57
|
+
self.folio_client, "/departments", "departments", "name"
|
|
58
|
+
)
|
|
59
|
+
self.logfile: AsyncTextIOWrapper = logfile
|
|
60
|
+
self.errorfile: AsyncTextIOWrapper = errorfile
|
|
61
|
+
self.http_client: httpx.AsyncClient = http_client
|
|
62
|
+
self.only_update_present_fields: bool = only_update_present_fields
|
|
63
|
+
self.match_key = user_match_key
|
|
64
|
+
self.lock: asyncio.Lock = asyncio.Lock()
|
|
65
|
+
self.logs: dict = {"created": 0, "updated": 0, "failed": 0}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def build_ref_data_id_map(
|
|
69
|
+
folio_client: folioclient.FolioClient, endpoint: str, key: str, name: str
|
|
70
|
+
) -> dict:
|
|
71
|
+
"""
|
|
72
|
+
Builds a map of reference data IDs.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
folio_client (folioclient.FolioClient): A FolioClient object.
|
|
76
|
+
endpoint (str): The endpoint to retrieve the reference data from.
|
|
77
|
+
key (str): The key to use as the map key.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
dict: A dictionary mapping reference data keys to their corresponding IDs.
|
|
81
|
+
"""
|
|
82
|
+
return {x[name]: x["id"] for x in folio_client.folio_get_all(endpoint, key)}
|
|
83
|
+
|
|
84
|
+
async def do_import(self) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Main method to import users.
|
|
87
|
+
|
|
88
|
+
This method triggers the process of importing users by calling the `process_file` method.
|
|
89
|
+
"""
|
|
90
|
+
await self.process_file()
|
|
91
|
+
|
|
92
|
+
async def get_existing_user(self, user_obj) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Retrieves an existing user from FOLIO based on the provided user object.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
user_obj: The user object containing the information to match against existing users.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The existing user object if found, otherwise an empty dictionary.
|
|
101
|
+
"""
|
|
102
|
+
match_key = "id" if ("id" in user_obj) else self.match_key
|
|
103
|
+
try:
|
|
104
|
+
existing_user = await self.http_client.get(
|
|
105
|
+
self.folio_client.okapi_url + "/users",
|
|
106
|
+
headers=self.folio_client.okapi_headers,
|
|
107
|
+
params={"query": f"{match_key}=={user_obj[match_key]}"},
|
|
108
|
+
)
|
|
109
|
+
existing_user.raise_for_status()
|
|
110
|
+
existing_user = existing_user.json().get("users", [])
|
|
111
|
+
existing_user = existing_user[0] if existing_user else {}
|
|
112
|
+
except httpx.HTTPError:
|
|
113
|
+
existing_user = {}
|
|
114
|
+
return existing_user
|
|
115
|
+
|
|
116
|
+
async def get_existing_rp(self, user_obj, existing_user) -> dict:
|
|
117
|
+
"""
|
|
118
|
+
Retrieves the existing request preferences for a given user.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
user_obj (dict): The user object.
|
|
122
|
+
existing_user (dict): The existing user object.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
dict: The existing request preferences for the user.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
existing_rp = await self.http_client.get(
|
|
129
|
+
self.folio_client.okapi_url
|
|
130
|
+
+ "/request-preference-storage/request-preference",
|
|
131
|
+
headers=self.folio_client.okapi_headers,
|
|
132
|
+
params={
|
|
133
|
+
"query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
existing_rp.raise_for_status()
|
|
137
|
+
existing_rp = existing_rp.json().get("requestPreferences", [])
|
|
138
|
+
existing_rp = existing_rp[0] if existing_rp else {}
|
|
139
|
+
except httpx.HTTPError:
|
|
140
|
+
existing_rp = {}
|
|
141
|
+
return existing_rp
|
|
142
|
+
|
|
143
|
+
async def get_existing_pu(self, user_obj, existing_user) -> dict:
|
|
144
|
+
"""
|
|
145
|
+
Retrieves the existing permission user for a given user.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
user_obj (dict): The user object.
|
|
149
|
+
existing_user (dict): The existing user object.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
dict: The existing permission user object.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
existing_pu = await self.http_client.get(
|
|
156
|
+
self.folio_client.okapi_url + "/perms/users",
|
|
157
|
+
headers=self.folio_client.okapi_headers,
|
|
158
|
+
params={
|
|
159
|
+
"query": f"userId=={existing_user.get('id', user_obj.get('id', ''))}"
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
existing_pu.raise_for_status()
|
|
163
|
+
existing_pu = existing_pu.json().get("permissionUsers", [])
|
|
164
|
+
existing_pu = existing_pu[0] if existing_pu else {}
|
|
165
|
+
except httpx.HTTPError:
|
|
166
|
+
existing_pu = {}
|
|
167
|
+
return existing_pu
|
|
168
|
+
|
|
169
|
+
async def map_address_types(self, user_obj, line_number) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Maps address type names in the user object to the corresponding ID in the address_type_map.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
user_obj (dict): The user object containing personal information.
|
|
175
|
+
address_type_map (dict): A dictionary mapping address type names to their ID values.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
None
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
KeyError: If an address type name in the user object is not found in address_type_map.
|
|
182
|
+
|
|
183
|
+
"""
|
|
184
|
+
if "personal" in user_obj and "addresses" in user_obj["personal"]:
|
|
185
|
+
for address in user_obj["personal"]["addresses"]:
|
|
186
|
+
try:
|
|
187
|
+
address["addressTypeId"] = self.address_type_map[
|
|
188
|
+
address["addressTypeId"]
|
|
189
|
+
]
|
|
190
|
+
except KeyError:
|
|
191
|
+
if address["addressTypeId"] not in self.address_type_map.values():
|
|
192
|
+
print(
|
|
193
|
+
f"Row {line_number}: Address type {address['addressTypeId']} not found"
|
|
194
|
+
f", removing address"
|
|
195
|
+
)
|
|
196
|
+
await self.logfile.write(
|
|
197
|
+
f"Row {line_number}: Address type {address['addressTypeId']} not found"
|
|
198
|
+
f", removing address\n"
|
|
199
|
+
)
|
|
200
|
+
del address
|
|
201
|
+
if len(user_obj["personal"]["addresses"]) == 0:
|
|
202
|
+
del user_obj["personal"]["addresses"]
|
|
203
|
+
|
|
204
|
+
async def map_patron_groups(self, user_obj, line_number) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Maps the patron group of a user object using the provided patron group map.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
user_obj (dict): The user object to update.
|
|
210
|
+
patron_group_map (dict): A dictionary mapping patron group names.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
None
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
user_obj["patronGroup"] = self.patron_group_map[user_obj["patronGroup"]]
|
|
217
|
+
except KeyError:
|
|
218
|
+
if user_obj["patronGroup"] not in self.patron_group_map.values():
|
|
219
|
+
print(
|
|
220
|
+
f"Row {line_number}: Patron group {user_obj['patronGroup']} not found, "
|
|
221
|
+
f"removing patron group"
|
|
222
|
+
)
|
|
223
|
+
await self.logfile.write(
|
|
224
|
+
f"Row {line_number}: Patron group {user_obj['patronGroup']} not found in, "
|
|
225
|
+
f"removing patron group\n"
|
|
226
|
+
)
|
|
227
|
+
del user_obj["patronGroup"]
|
|
228
|
+
|
|
229
|
+
async def map_departments(self, user_obj, line_number) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Maps the departments of a user object using the provided department map.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
user_obj (dict): The user object to update.
|
|
235
|
+
department_map (dict): A dictionary mapping department names.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
None
|
|
239
|
+
"""
|
|
240
|
+
mapped_departments = []
|
|
241
|
+
for department in user_obj["departments"]:
|
|
242
|
+
try:
|
|
243
|
+
mapped_departments.append(self.department_map[department])
|
|
244
|
+
except KeyError:
|
|
245
|
+
print(
|
|
246
|
+
f'Row {line_number}: Department "{department}" not found, '
|
|
247
|
+
f"excluding department from user"
|
|
248
|
+
)
|
|
249
|
+
await self.logfile.write(
|
|
250
|
+
f'Row {line_number}: Department "{department}" not found, '
|
|
251
|
+
f"excluding department from user\n"
|
|
252
|
+
)
|
|
253
|
+
user_obj["departments"] = mapped_departments
|
|
254
|
+
|
|
255
|
+
async def update_existing_user(self, user_obj, existing_user) -> Tuple[dict, dict]:
|
|
256
|
+
"""
|
|
257
|
+
Updates an existing user with the provided user object.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
user_obj (dict): The user object containing the updated user information.
|
|
261
|
+
existing_user (dict): The existing user object to be updated.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
tuple: A tuple containing the updated existing user object and the API response.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
None
|
|
268
|
+
|
|
269
|
+
"""
|
|
270
|
+
if self.only_update_present_fields:
|
|
271
|
+
new_personal = user_obj.pop("personal", {})
|
|
272
|
+
existing_personal = existing_user.pop("personal", {})
|
|
273
|
+
existing_preferred_first_name = existing_personal.pop(
|
|
274
|
+
"preferredFirstName", ""
|
|
275
|
+
)
|
|
276
|
+
existing_addresses = existing_personal.get("addresses", [])
|
|
277
|
+
existing_user.update(user_obj)
|
|
278
|
+
existing_personal.update(new_personal)
|
|
279
|
+
if (
|
|
280
|
+
not existing_personal.get("preferredFirstName", "")
|
|
281
|
+
and existing_preferred_first_name
|
|
282
|
+
):
|
|
283
|
+
existing_personal["preferredFirstName"] = existing_preferred_first_name
|
|
284
|
+
if not existing_personal.get("addresses", []):
|
|
285
|
+
existing_personal["addresses"] = existing_addresses
|
|
286
|
+
if existing_personal:
|
|
287
|
+
existing_user["personal"] = existing_personal
|
|
288
|
+
else:
|
|
289
|
+
existing_user.update(user_obj)
|
|
290
|
+
create_update_user = await self.http_client.put(
|
|
291
|
+
self.folio_client.okapi_url + f"/users/{existing_user['id']}",
|
|
292
|
+
headers=self.folio_client.okapi_headers,
|
|
293
|
+
json=existing_user,
|
|
294
|
+
)
|
|
295
|
+
return existing_user, create_update_user
|
|
296
|
+
|
|
297
|
+
async def create_new_user(self, user_obj) -> dict:
|
|
298
|
+
"""
|
|
299
|
+
Creates a new user in the system.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
user_obj (dict): A dictionary containing the user information.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
dict: A dictionary representing the response from the server.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
HTTPError: If the HTTP request to create the user fails.
|
|
309
|
+
"""
|
|
310
|
+
response = await self.http_client.post(
|
|
311
|
+
self.folio_client.okapi_url + "/users",
|
|
312
|
+
headers=self.folio_client.okapi_headers,
|
|
313
|
+
json=user_obj,
|
|
314
|
+
)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
async with self.lock:
|
|
317
|
+
self.logs["created"] += 1
|
|
318
|
+
return response.json()
|
|
319
|
+
|
|
320
|
+
async def create_or_update_user(self, user_obj, existing_user, line_number) -> dict:
|
|
321
|
+
"""
|
|
322
|
+
Creates or updates a user based on the given user object and existing user.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
user_obj (dict): The user object containing the user details.
|
|
326
|
+
existing_user (dict): The existing user object to be updated, if available.
|
|
327
|
+
logs (dict): A dictionary to keep track of the number of updates and failures.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
dict: The updated or created user object, or an empty dictionary an error occurs.
|
|
331
|
+
"""
|
|
332
|
+
if existing_user:
|
|
333
|
+
existing_user, update_user = await self.update_existing_user(
|
|
334
|
+
user_obj, existing_user
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
update_user.raise_for_status()
|
|
338
|
+
self.logs["updated"] += 1
|
|
339
|
+
return existing_user
|
|
340
|
+
except Exception as ee:
|
|
341
|
+
print(
|
|
342
|
+
f"Row {line_number}: User update failed: "
|
|
343
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
|
|
344
|
+
)
|
|
345
|
+
await self.logfile.write(
|
|
346
|
+
f"Row {line_number}: User update failed: "
|
|
347
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
|
|
348
|
+
)
|
|
349
|
+
await self.errorfile.write(
|
|
350
|
+
json.dumps(existing_user, ensure_ascii=False) + "\n"
|
|
351
|
+
)
|
|
352
|
+
self.logs["failed"] += 1
|
|
353
|
+
return {}
|
|
354
|
+
else:
|
|
355
|
+
try:
|
|
356
|
+
new_user = await self.create_new_user(user_obj)
|
|
357
|
+
return new_user
|
|
358
|
+
except Exception as ee:
|
|
359
|
+
print(
|
|
360
|
+
f"Row {line_number}: User creation failed: "
|
|
361
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
|
|
362
|
+
)
|
|
363
|
+
await self.logfile.write(
|
|
364
|
+
f"Row {line_number}: User creation failed: "
|
|
365
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}\n"
|
|
366
|
+
)
|
|
367
|
+
await self.errorfile.write(
|
|
368
|
+
json.dumps(user_obj, ensure_ascii=False) + "\n"
|
|
369
|
+
)
|
|
370
|
+
self.logs["failed"] += 1
|
|
371
|
+
return {}
|
|
372
|
+
|
|
373
|
+
async def process_user_obj(self, user: str) -> dict:
|
|
374
|
+
"""
|
|
375
|
+
Process a user object.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
user (str): The user data to be processed, as a json string.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
dict: The processed user object.
|
|
382
|
+
|
|
383
|
+
"""
|
|
384
|
+
user_obj = json.loads(user)
|
|
385
|
+
user_obj["type"] = user_obj.get("type", "patron")
|
|
386
|
+
if "personal" in user_obj:
|
|
387
|
+
current_pref_contact = user_obj["personal"].get(
|
|
388
|
+
"preferredContactTypeId", ""
|
|
389
|
+
)
|
|
390
|
+
user_obj["personal"]["preferredContactTypeId"] = (
|
|
391
|
+
current_pref_contact
|
|
392
|
+
if current_pref_contact in ["001", "002", "003"]
|
|
393
|
+
else "002"
|
|
394
|
+
)
|
|
395
|
+
return user_obj
|
|
396
|
+
|
|
397
|
+
async def process_existing_user(self, user_obj) -> Tuple[dict, dict, dict, dict]:
|
|
398
|
+
"""
|
|
399
|
+
Process an existing user.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
user_obj (dict): The user object to process.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
tuple: A tuple containing the request preference object (rp_obj),
|
|
406
|
+
the existing user object, the existing request preference object (existing_rp),
|
|
407
|
+
and the existing PU object (existing_pu).
|
|
408
|
+
"""
|
|
409
|
+
rp_obj = user_obj.pop("requestPreference", {})
|
|
410
|
+
existing_user = await self.get_existing_user(user_obj)
|
|
411
|
+
if existing_user:
|
|
412
|
+
existing_rp = await self.get_existing_rp(user_obj, existing_user)
|
|
413
|
+
existing_pu = await self.get_existing_pu(user_obj, existing_user)
|
|
414
|
+
else:
|
|
415
|
+
existing_rp = {}
|
|
416
|
+
existing_pu = {}
|
|
417
|
+
return rp_obj, existing_user, existing_rp, existing_pu
|
|
418
|
+
|
|
419
|
+
async def create_or_update_rp(self, rp_obj, existing_rp, new_user_obj):
|
|
420
|
+
"""
|
|
421
|
+
Creates or updates a requet preference object based on the given parameters.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
rp_obj (object): A new requet preference object.
|
|
425
|
+
existing_rp (object): The existing resource provider object, if it exists.
|
|
426
|
+
new_user_obj (object): The new user object.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
None
|
|
430
|
+
"""
|
|
431
|
+
if existing_rp:
|
|
432
|
+
# print(existing_rp)
|
|
433
|
+
await self.update_existing_rp(rp_obj, existing_rp)
|
|
434
|
+
else:
|
|
435
|
+
# print(new_user_obj)
|
|
436
|
+
await self.create_new_rp(new_user_obj)
|
|
437
|
+
|
|
438
|
+
async def create_new_rp(self, new_user_obj):
|
|
439
|
+
"""
|
|
440
|
+
Creates a new request preference for a user.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
new_user_obj (dict): The user object containing the user's ID.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
HTTPError: If there is an error in the HTTP request.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
None
|
|
450
|
+
"""
|
|
451
|
+
rp_obj = {"holdShelf": True, "delivery": False}
|
|
452
|
+
rp_obj["userId"] = new_user_obj["id"]
|
|
453
|
+
# print(rp_obj)
|
|
454
|
+
response = await self.http_client.post(
|
|
455
|
+
self.folio_client.okapi_url
|
|
456
|
+
+ "/request-preference-storage/request-preference",
|
|
457
|
+
headers=self.folio_client.okapi_headers,
|
|
458
|
+
json=rp_obj,
|
|
459
|
+
)
|
|
460
|
+
response.raise_for_status()
|
|
461
|
+
|
|
462
|
+
async def update_existing_rp(self, rp_obj, existing_rp) -> None:
|
|
463
|
+
"""
|
|
464
|
+
Updates an existing request preference with the provided request preference object.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
rp_obj (dict): The request preference object containing the updated values.
|
|
468
|
+
existing_rp (dict): The existing request preference object to be updated.
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
HTTPError: If the PUT request to update the request preference fails.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
None
|
|
475
|
+
"""
|
|
476
|
+
existing_rp.update(rp_obj)
|
|
477
|
+
# print(existing_rp)
|
|
478
|
+
response = await self.http_client.put(
|
|
479
|
+
self.folio_client.okapi_url
|
|
480
|
+
+ f"/request-preference-storage/request-preference/{existing_rp['id']}",
|
|
481
|
+
headers=self.folio_client.okapi_headers,
|
|
482
|
+
json=existing_rp,
|
|
483
|
+
)
|
|
484
|
+
response.raise_for_status()
|
|
485
|
+
|
|
486
|
+
async def create_perms_user(self, new_user_obj) -> None:
|
|
487
|
+
"""
|
|
488
|
+
Creates a permissions user object for the given new user.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
new_user_obj (dict): A dictionary containing the details of the new user.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
HTTPError: If there is an error while making the HTTP request.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
None
|
|
498
|
+
"""
|
|
499
|
+
perms_user_obj = {"userId": new_user_obj["id"], "permissions": []}
|
|
500
|
+
response = await self.http_client.post(
|
|
501
|
+
self.folio_client.okapi_url + "/perms/users",
|
|
502
|
+
headers=self.folio_client.okapi_headers,
|
|
503
|
+
json=perms_user_obj,
|
|
504
|
+
)
|
|
505
|
+
response.raise_for_status()
|
|
506
|
+
|
|
507
|
+
async def process_line(
|
|
508
|
+
self,
|
|
509
|
+
user: str,
|
|
510
|
+
line_number: int,
|
|
511
|
+
) -> None:
|
|
512
|
+
"""
|
|
513
|
+
Process a single line of user data.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
user (str): The user data to be processed.
|
|
517
|
+
logs (dict): A dictionary to store logs.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
None
|
|
521
|
+
|
|
522
|
+
Raises:
|
|
523
|
+
Any exceptions that occur during the processing.
|
|
524
|
+
|
|
525
|
+
"""
|
|
526
|
+
async with self.limit_simultaneous_requests:
|
|
527
|
+
user_obj = await self.process_user_obj(user)
|
|
528
|
+
rp_obj, existing_user, existing_rp, existing_pu = (
|
|
529
|
+
await self.process_existing_user(user_obj)
|
|
530
|
+
)
|
|
531
|
+
await self.map_address_types(user_obj, line_number)
|
|
532
|
+
await self.map_patron_groups(user_obj, line_number)
|
|
533
|
+
await self.map_departments(user_obj, line_number)
|
|
534
|
+
new_user_obj = await self.create_or_update_user(
|
|
535
|
+
user_obj, existing_user, line_number
|
|
536
|
+
)
|
|
537
|
+
if new_user_obj:
|
|
538
|
+
try:
|
|
539
|
+
if existing_rp or rp_obj:
|
|
540
|
+
await self.create_or_update_rp(
|
|
541
|
+
rp_obj, existing_rp, new_user_obj
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
print(
|
|
545
|
+
f"Row {line_number}: Creating default request preference object"
|
|
546
|
+
f" for {new_user_obj['id']}"
|
|
547
|
+
)
|
|
548
|
+
await self.logfile.write(
|
|
549
|
+
f"Row {line_number}: Creating default request preference object"
|
|
550
|
+
f" for {new_user_obj['id']}\n"
|
|
551
|
+
)
|
|
552
|
+
await self.create_new_rp(new_user_obj)
|
|
553
|
+
except Exception as ee: # noqa: W0718
|
|
554
|
+
rp_error_message = (
|
|
555
|
+
f"Row {line_number}: Error creating or updating request preferences for "
|
|
556
|
+
f"{new_user_obj['id']}: "
|
|
557
|
+
f"{str(getattr(getattr(ee, 'response', ee), 'text', str(ee)))}"
|
|
558
|
+
)
|
|
559
|
+
print(rp_error_message)
|
|
560
|
+
await self.logfile.write(rp_error_message + "\n")
|
|
561
|
+
if not existing_pu:
|
|
562
|
+
try:
|
|
563
|
+
await self.create_perms_user(new_user_obj)
|
|
564
|
+
except Exception as ee: # noqa: W0718
|
|
565
|
+
pu_error_message = (
|
|
566
|
+
f"Row {line_number}: Error creating permissionUser object for user: "
|
|
567
|
+
f"{new_user_obj['id']}: "
|
|
568
|
+
f"{str(getattr(getattr(ee, 'response', str(ee)), 'text', str(ee)))}"
|
|
569
|
+
)
|
|
570
|
+
print(pu_error_message)
|
|
571
|
+
await self.logfile.write(pu_error_message + "\n")
|
|
572
|
+
|
|
573
|
+
async def process_file(self) -> None:
|
|
574
|
+
"""
|
|
575
|
+
Process the user object file.
|
|
576
|
+
"""
|
|
577
|
+
with open(self.user_file_path, "r", encoding="utf-8") as openfile:
|
|
578
|
+
tasks = []
|
|
579
|
+
for line_number, user in enumerate(openfile):
|
|
580
|
+
tasks.append(self.process_line(user, line_number))
|
|
581
|
+
if len(tasks) == self.batch_size:
|
|
582
|
+
start = time.time()
|
|
583
|
+
await asyncio.gather(*tasks)
|
|
584
|
+
duration = time.time() - start
|
|
585
|
+
async with self.lock:
|
|
586
|
+
message = (
|
|
587
|
+
f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
|
|
588
|
+
f"Batch of {self.batch_size} users processed in {duration:.2f} "
|
|
589
|
+
f"seconds. - Users created: {self.logs['created']} - Users updated: "
|
|
590
|
+
f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
|
|
591
|
+
)
|
|
592
|
+
print(message)
|
|
593
|
+
await self.logfile.write(message + "\n")
|
|
594
|
+
tasks = []
|
|
595
|
+
if tasks:
|
|
596
|
+
await asyncio.gather(*tasks)
|
|
597
|
+
async with self.lock:
|
|
598
|
+
message = (
|
|
599
|
+
f"{dt.now().isoformat(sep=' ', timespec='milliseconds')}: "
|
|
600
|
+
f"Batch of {self.batch_size} users processed in {duration:.2f} seconds. - "
|
|
601
|
+
f"Users created: {self.logs['created']} - Users updated: "
|
|
602
|
+
f"{self.logs['updated']} - Users failed: {self.logs['failed']}"
|
|
603
|
+
)
|
|
604
|
+
print(message)
|
|
605
|
+
await self.logfile.write(message + "\n")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
async def main() -> None:
|
|
609
|
+
"""
|
|
610
|
+
Entry point of the user import script.
|
|
611
|
+
|
|
612
|
+
Parses command line arguments, initializes necessary objects, and starts the import process.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
--tenant_id (str): The tenant id.
|
|
616
|
+
--library_name (str): The name of the library.
|
|
617
|
+
--username (str): The FOLIO username.
|
|
618
|
+
--okapi_url (str): The Okapi URL.
|
|
619
|
+
--user_file_path (str): The path to the user file.
|
|
620
|
+
--limit_async_requests (int): Limit how many http requests can be made at once. Default 10.
|
|
621
|
+
--batch_size (int): How many records to process before logging statistics. Default 250.
|
|
622
|
+
--folio_password (str): The FOLIO password.
|
|
623
|
+
--user_match_key (str): The key to use to match users. Default "externalSystemId".
|
|
624
|
+
|
|
625
|
+
Raises:
|
|
626
|
+
Exception: If an unknown error occurs during the import process.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
None
|
|
630
|
+
"""
|
|
631
|
+
parser = argparse.ArgumentParser()
|
|
632
|
+
parser.add_argument("--tenant_id", help="The tenant id")
|
|
633
|
+
parser.add_argument("--library_name", help="The name of the library")
|
|
634
|
+
parser.add_argument("--username", help="The FOLIO username")
|
|
635
|
+
parser.add_argument("--okapi_url", help="The Okapi URL")
|
|
636
|
+
parser.add_argument("--user_file_path", help="The path to the user file")
|
|
637
|
+
parser.add_argument(
|
|
638
|
+
"--limit_async_requests",
|
|
639
|
+
help="Limit how many http requests can be made at once",
|
|
640
|
+
type=int,
|
|
641
|
+
default=10,
|
|
642
|
+
)
|
|
643
|
+
parser.add_argument(
|
|
644
|
+
"--batch_size",
|
|
645
|
+
help="How many user records to process before logging statistics",
|
|
646
|
+
type=int,
|
|
647
|
+
default=250,
|
|
648
|
+
)
|
|
649
|
+
parser.add_argument("--folio_password", help="The FOLIO password")
|
|
650
|
+
parser.add_argument(
|
|
651
|
+
"--user_match_key",
|
|
652
|
+
help="The key to use to match users",
|
|
653
|
+
choices=["externalSystemId", "barcode", "username"],
|
|
654
|
+
default="externalSystemId",
|
|
655
|
+
)
|
|
656
|
+
parser.add_argument(
|
|
657
|
+
"--update_only_present_fields",
|
|
658
|
+
help="Only update fields that are present in the user object",
|
|
659
|
+
action="store_true",
|
|
660
|
+
)
|
|
661
|
+
args = parser.parse_args()
|
|
662
|
+
|
|
663
|
+
library_name = args.library_name
|
|
664
|
+
|
|
665
|
+
# Semaphore to limit the number of async HTTP requests active at any given time
|
|
666
|
+
limit_async_requests = asyncio.Semaphore(args.limit_async_requests)
|
|
667
|
+
batch_size = args.batch_size
|
|
668
|
+
|
|
669
|
+
folio_client = folioclient.FolioClient(
|
|
670
|
+
args.okapi_url,
|
|
671
|
+
args.tenant_id,
|
|
672
|
+
args.username,
|
|
673
|
+
args.folio_password
|
|
674
|
+
or os.environ.get("FOLIO_PASS", "")
|
|
675
|
+
or getpass.getpass("Enter your FOLIO password: "),
|
|
676
|
+
)
|
|
677
|
+
user_file_path = Path(args.user_file_path)
|
|
678
|
+
log_file_path = (
|
|
679
|
+
user_file_path.parent.parent
|
|
680
|
+
/ "reports"
|
|
681
|
+
/ f"log_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.log"
|
|
682
|
+
)
|
|
683
|
+
error_file_path = (
|
|
684
|
+
user_file_path.parent
|
|
685
|
+
/ f"failed_user_import_{dt.now(utc).strftime('%Y%m%d_%H%M%S')}.txt"
|
|
686
|
+
)
|
|
687
|
+
async with aiofiles.open(
|
|
688
|
+
log_file_path,
|
|
689
|
+
"w",
|
|
690
|
+
) as logfile, aiofiles.open(
|
|
691
|
+
error_file_path, "w"
|
|
692
|
+
) as errorfile, httpx.AsyncClient(timeout=None) as http_client:
|
|
693
|
+
try:
|
|
694
|
+
importer = UserImporter(
|
|
695
|
+
folio_client,
|
|
696
|
+
library_name,
|
|
697
|
+
user_file_path,
|
|
698
|
+
batch_size,
|
|
699
|
+
limit_async_requests,
|
|
700
|
+
logfile,
|
|
701
|
+
errorfile,
|
|
702
|
+
http_client,
|
|
703
|
+
args.user_match_key,
|
|
704
|
+
args.update_only_present_fields,
|
|
705
|
+
)
|
|
706
|
+
await importer.do_import()
|
|
707
|
+
except Exception as ee:
|
|
708
|
+
print(f"An unknown error occurred: {ee}")
|
|
709
|
+
await logfile.write(f"An error occurred {ee}\n")
|
|
710
|
+
raise ee
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def sync_main() -> None:
|
|
714
|
+
"""
|
|
715
|
+
Synchronous version of the main function.
|
|
716
|
+
|
|
717
|
+
This function is used to run the main function in a synchronous context.
|
|
718
|
+
"""
|
|
719
|
+
asyncio.run(main())
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# Run the main function
|
|
723
|
+
if __name__ == "__main__":
|
|
724
|
+
asyncio.run(main())
|