-
Notifications
You must be signed in to change notification settings - Fork 0
/
patron_update.py
executable file
·298 lines (253 loc) · 9.69 KB
/
patron_update.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
#!/usr/bin/env python
import csv
from datetime import date
import json
from pathlib import Path
import click
from requests import Response
from requests.exceptions import HTTPError
from termcolor import colored
from koha_patron.config import config
from koha_patron.patron import PATRON_READ_ONLY_FIELDS
from koha_patron.request_wrapper import request_wrapper
from workday.models import Employee, Student, Person
from workday.utils import get_entries
# Universal IDs of patrons who should not have their prox numbers updated
# e.g. because the prox report seems to have the wrong number for them
PROX_EXCEPTIONS: list[str] = ["1458769"]
NAME_EXCEPTIONS: list[str] = [] # not needed yet
def create_prox_map(proxfile: Path) -> dict[str, str]:
"""Create a dict of { CCA ID : prox number } so we can look up patrons'
card numbers by their ID. Prox report does not have other identifiers like
username or email so we use CCA (universal, not student) ID.
Args:
proxfile (str|Path): path to the prox report CSV
Raises:
RuntimeError: if the CSV is not in the expected format
Returns:
dict: map of CCA IDs to prox numbers
"""
with open(proxfile, mode="r") as file:
# check the first line, which we'll always skip, to ensure CSV format
first_line = file.readline()
if "Active Accounts with Prox IDs" in first_line:
# skip the first 3 lines ("List of", empty line, then header row)
file.readline()
file.readline()
elif (
'"Universal ID","Student ID","Prox ID","Last Name","First Name","End Date","IsInactive"'
in first_line
):
# we already skipped the header row
pass
else:
raise RuntimeError(
f'The CSV of prox numbers "{proxfile}" was in an unexpected format. It should be a CSV export from OneCard either unmodified or with the two preamble rows removed but the header row present. Double-check the format of the file.'
)
# read rows from the rest of the CSV
reader = csv.reader(file)
# Universal ID => prox number mapping
# Prox report Univ IDs have varying number of leading zeroes e.g.
# "001000001", "010000001", so we strip them
map: dict[str, str] = {}
for row in reader:
# normalize IDs to be last 5 digits
prox = row[2].rstrip()[4:]
if prox != "" and int(prox) != 0:
map[row[0].lstrip("0")] = prox
return map
def handle_http_error(response: Response, workday: Person, prox: str | None) -> None:
try:
response.raise_for_status()
except HTTPError:
"""log info about HTTP error"""
results["totals"]["error"] += 1
print(colored("Error", "red"), response)
print("HTTP Response Headers", response.headers)
print(response.text)
print(
colored(
f"""Error for patron {workday.username} """
f"""({workday.first_name} {workday.last_name}) with prox """
f"""number {prox}""",
"red",
)
)
def missing_patron(workday: dict) -> None:
print(f'Could not find a patron with a userid of {workday["username"]} in Koha.')
results["totals"]["missing"] += 1
results["missing"].append(workday)
def has_changed(koha: dict, workday: Person, prox: str | None) -> bool:
return (
(prox and koha["cardnumber"] != prox)
or koha["firstname"] != workday.first_name
or koha["surname"] != workday.last_name
)
def skipped_employee(wd: Employee) -> bool:
return (
wd.etype == "Contingent Employees/Contractors"
or wd.job_profile == "Temporary System/Campus Access"
or wd.job_profile == "Temporary: Hourly"
or False
)
def check_patron(workday: Person, prox: str | None, dryrun: bool):
"""Try to find Koha account given WD profile.
If cardnumber or name have changed,
pass the new prox num to update_patron(koha, wd, prox).
Args:
workday (dict): Workday object of personal info
prox (int): card number
"""
response: Response = http.get(
"{}/patrons?userid={}&_match=exact".format(
config["api_root"],
workday.username,
)
)
handle_http_error(response, workday, prox)
patrons: list | dict = response.json()
# patrons is a dict if we had an error above, list otherwise
if isinstance(patrons, list):
if len(patrons) == 0:
missing_patron(workday.model_dump(mode="json"))
elif len(patrons) == 1:
if has_changed(patrons[0], workday, prox):
update_patron(patrons[0], workday, prox, dryrun)
else:
results["totals"]["unchanged"] += 1
else:
# theoretically impossible with _match=exact
raise RuntimeError(
f"Multiple patrons found for username {workday.username}: {patrons}"
)
def update_patron(koha: dict, workday: Person, prox: str | None, dryrun: bool) -> None:
print(f"Updating patron {koha['userid']}", end=" ")
# name change
if (
koha["firstname"] != workday.first_name
or koha["surname"] != workday.last_name
and workday.universal_id not in NAME_EXCEPTIONS
):
print(
f"{koha['firstname']} {koha['surname']} => {workday.first_name} {workday.last_name}",
end=" ",
)
koha["firstname"] = workday.first_name
koha["surname"] = workday.last_name
results["totals"]["name change"] += 1
else:
print(f"{koha['firstname']} {koha['surname']}", end=" ")
# new prox number
if (
prox
and koha["cardnumber"] != prox
and workday.universal_id not in PROX_EXCEPTIONS
):
print(f"Cardnumber {koha['cardnumber']} => {prox}")
# backup old cardnumber in "sort2" field
koha["statistics_2"] = koha["cardnumber"]
koha["cardnumber"] = prox
results["totals"]["prox change"] += 1
else:
print("Cardnumber", koha["cardnumber"])
# must do this or PUT request fails b/c we can't edit these fields
for field in PATRON_READ_ONLY_FIELDS:
koha.pop(field)
if not dryrun:
response: Response = http.put(
"{}/patrons/{}".format(
config["api_root"],
koha["patron_id"],
),
json=koha,
)
handle_http_error(response, workday, prox)
results["totals"]["updated"] += 1
def mk_missing_file(missing: list[Person], ptype: str) -> None:
"""write missing patrons to JSON file so we can add them later
Args:
missing (list): list of workday people objects
"""
filename = f"{date.today().isoformat()}-missing-{ptype.lower()}s.json"
with open(filename, "w") as file:
json.dump(missing, file, indent=2)
print(f"\nWrote {len(missing)} missing patrons to {filename}")
def load_data(filename: Path) -> list[Person]:
people_dicts: list[dict] = []
with open(filename, "r") as file:
people_dicts = get_entries(json.load(file))
if people_dicts[0].get("employee_id"):
return [Employee(**p) for p in people_dicts]
elif people_dicts[0].get("student_id"):
return [Student(**p) for p in people_dicts]
else:
raise RuntimeError(
f"Could not determine the type of person from the first entry in the JSON file {filename}."
)
def summary(totals: dict[str, int]) -> None:
# Print summary of changes
print(
f"""
=== Summary ===
- Total patrons: {totals['unchanged'] + totals['updated'] + totals['missing']}
- Errors: {totals['error']}
- Missing from Koha: {totals['missing']}
- Unchanged: {totals['unchanged']}
- Updated: {totals['updated']}
- Name changes: {totals['name change']}
- Cardnumber changes: {totals['prox change']}"""
)
@click.command()
@click.help_option("--help", "-h")
@click.option(
"-w", "--workday", help="Workday JSON file", required=True, type=click.Path()
)
@click.option("-p", "--prox", help="Prox CSV file", type=click.Path())
@click.option(
"-d",
"--dry-run",
help="Do not update patrons, only check for changes",
is_flag=True,
)
@click.option("-l", "--limit", help="Limit the number of patrons to check", type=int)
def main(workday: Path, prox: Path, dry_run: bool, limit: None | int):
# global vars that other functions need to access
global results
results = {
"missing": [],
"totals": {
"missing": 0,
"error": 0,
"updated": 0,
"unchanged": 0,
"name change": 0,
"prox change": 0,
},
}
global http
http = request_wrapper()
if prox:
prox_map: dict[str, str] = create_prox_map(prox)
else:
print(
colored("No prox file provided, cardnumbers will not be updated.", "yellow")
)
prox_map = {}
if dry_run:
print(colored("Dry run: no changes will be made.", "yellow"))
data: list[Person] = load_data(workday)
for i, person in enumerate(data):
if limit and i >= limit:
break
# skip temp/contractor positions
if isinstance(person, Employee) and skipped_employee(person):
continue
# skip incomplete students (username = id when they haven't chosen one yet)
if isinstance(person, Student) and not person.inst_email:
continue
check_patron(person, prox_map.get(person.universal_id), dryrun=dry_run)
if len(results["missing"]) > 0:
mk_missing_file(results["missing"], type(data[0]).__name__)
summary(results["totals"])
if __name__ == "__main__":
main()