Spaces:
Sleeping
Sleeping
initial commit
Browse files- app.py +652 -0
- requirements.txt +2 -0
app.py
ADDED
@@ -0,0 +1,652 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import datetime
|
3 |
+
import gradio as gr
|
4 |
+
import itertools
|
5 |
+
import numpy as np
|
6 |
+
import os
|
7 |
+
import pandas as pd
|
8 |
+
import re
|
9 |
+
|
10 |
+
from collections import defaultdict
|
11 |
+
|
12 |
+
|
13 |
+
def convert_date(d):
|
14 |
+
return datetime.datetime.strptime(str(d), "%B %d, %Y").date()
|
15 |
+
|
16 |
+
|
17 |
+
def main(files):
|
18 |
+
available_files = []
|
19 |
+
fname_dict = {os.path.basename(f).split(".")[0]: f for f in files}
|
20 |
+
for fname, fpath in fname_dict.items():
|
21 |
+
available_files.append(fname)
|
22 |
+
if fname == "qgenda_schedule":
|
23 |
+
schedule = pd.read_excel(fpath, header=None)
|
24 |
+
elif fname == "rotations":
|
25 |
+
core_rotations = pd.read_excel(fpath, sheet_name="core", header=None)
|
26 |
+
call_rotations = pd.read_excel(fpath, sheet_name="call", header=None)
|
27 |
+
elif fname == "holidays":
|
28 |
+
holidays = pd.read_excel(fpath, header=None)
|
29 |
+
elif fname == "pto":
|
30 |
+
pto = pd.read_excel(fpath, header=None)
|
31 |
+
elif fname == "academic_years":
|
32 |
+
academic_years = pd.read_excel(fpath)
|
33 |
+
elif fname == "call_shifts":
|
34 |
+
call_shifts = pd.read_excel(fpath, header=None)
|
35 |
+
elif fname == "resident_roster":
|
36 |
+
roster = pd.read_excel(fpath)
|
37 |
+
assert all(x in ["qgenda_schedule", "rotations", "holidays", "pto", "academic_years", "call_shifts", "resident_roster"] for x in available_files)
|
38 |
+
|
39 |
+
# -- for debugging, load files from disk --
|
40 |
+
# Load in all files
|
41 |
+
# schedule = pd.read_excel("key_files/qgenda_schedule.xlsx", header=None) # remove first 3 rows
|
42 |
+
# core_rotations = pd.read_excel("key_files/rotations.xlsx", sheet_name="core", header=None)
|
43 |
+
# call_rotations = pd.read_excel("key_files/rotations.xlsx", sheet_name="call", header=None)
|
44 |
+
# holidays = pd.read_excel("key_files/holidays.xlsx", header=None)
|
45 |
+
# pto = pd.read_excel("key_files/pto.xlsx", header=None)
|
46 |
+
# academic_years = pd.read_excel("key_files/academic_years.xlsx")
|
47 |
+
# call_shifts = pd.read_excel("key_files/call_shifts.xlsx", header=None)
|
48 |
+
# roster = pd.read_excel("key_files/resident_roster.xlsx")
|
49 |
+
# --
|
50 |
+
|
51 |
+
schedule = schedule.iloc[3:].reset_index(drop=True) # remove first 3 rows
|
52 |
+
core_rotations = core_rotations.iloc[:, 0].tolist()
|
53 |
+
call_rotations = call_rotations.iloc[:, 0].tolist()
|
54 |
+
holidays = [_.date() for _ in holidays.iloc[:, 0].tolist()]
|
55 |
+
pto = pto.iloc[:, 0].tolist()
|
56 |
+
call_shifts = call_shifts.iloc[:, 0].tolist()
|
57 |
+
|
58 |
+
# remove stuff at the bottom
|
59 |
+
try:
|
60 |
+
last_row = schedule.iloc[:, 0].tolist().index("Schedule Notes:")
|
61 |
+
except ValueError:
|
62 |
+
last_row = schedule.iloc[:, 0].tolist().index("Phone Numbers")
|
63 |
+
|
64 |
+
|
65 |
+
schedule = schedule.iloc[:last_row].reset_index(drop=True)
|
66 |
+
|
67 |
+
# remove days of week rows
|
68 |
+
remove_rows = []
|
69 |
+
for idx, i in enumerate(schedule.iloc[:, 0].tolist()):
|
70 |
+
if i == "Sunday":
|
71 |
+
remove_rows.append(idx)
|
72 |
+
|
73 |
+
schedule = schedule.drop(remove_rows).reset_index(drop=True)
|
74 |
+
|
75 |
+
# split the schedule by weeks
|
76 |
+
sundays, week_rows = [], []
|
77 |
+
for idx, i in enumerate(schedule.iloc[:, 0].tolist()):
|
78 |
+
try:
|
79 |
+
tmp_date = convert_date(i)
|
80 |
+
sundays.append(tmp_date)
|
81 |
+
week_rows.append(idx)
|
82 |
+
except ValueError:
|
83 |
+
continue
|
84 |
+
|
85 |
+
|
86 |
+
weekly_schedule = {}
|
87 |
+
for idx, each_sunday in enumerate(sundays):
|
88 |
+
if idx == len(sundays) - 1:
|
89 |
+
weekly_schedule[each_sunday] = schedule.iloc[week_rows[idx]:].reset_index(drop=True)
|
90 |
+
else:
|
91 |
+
weekly_schedule[each_sunday] = schedule.iloc[week_rows[idx]:week_rows[idx+1]].reset_index(drop=True)
|
92 |
+
|
93 |
+
|
94 |
+
daily_schedule = {}
|
95 |
+
for each_week, each_schedule in weekly_schedule.items():
|
96 |
+
tmp_daily_schedule = {}
|
97 |
+
list_by_day = [each_schedule.iloc[:, idx:idx+2] for idx in range(0, each_schedule.shape[1], 2)]
|
98 |
+
for each_day in list_by_day:
|
99 |
+
tmp_date = convert_date(each_day.iloc[0, 0])
|
100 |
+
tmp_daily_schedule[tmp_date] = defaultdict(list)
|
101 |
+
tmp_residents, tmp_tasks = each_day.iloc[1:, 0].tolist(), each_day.iloc[1:, 1].tolist()
|
102 |
+
tmp_tasks = [re.sub(r" \([A-Z]+\)", "", str(_)) for _ in tmp_tasks]
|
103 |
+
# PTO supersedes everything else, meaning if you have a PTO task then all other tasks are void
|
104 |
+
pto_residents = []
|
105 |
+
for resident, task in zip(tmp_residents, tmp_tasks):
|
106 |
+
if task in pto:
|
107 |
+
pto_residents.append(resident)
|
108 |
+
for resident, task in zip(tmp_residents, tmp_tasks):
|
109 |
+
if str(resident) == "nan" or resident in pto_residents:
|
110 |
+
if task not in pto:
|
111 |
+
continue
|
112 |
+
if tmp_date.weekday() in [5, 6] and task in core_rotations + holidays:
|
113 |
+
continue
|
114 |
+
tmp_daily_schedule[tmp_date][resident].append(task)
|
115 |
+
daily_schedule.update(tmp_daily_schedule)
|
116 |
+
|
117 |
+
|
118 |
+
weekly_schedule_with_tasks = {}
|
119 |
+
for each_week, each_schedule in weekly_schedule.items():
|
120 |
+
tmp_weekly_schedule = {}
|
121 |
+
tmp_weekly_schedule[each_week] = defaultdict(list)
|
122 |
+
list_by_day = [each_schedule.iloc[:, idx:idx+2] for idx in range(0, each_schedule.shape[1], 2)]
|
123 |
+
for each_day in list_by_day:
|
124 |
+
tmp_date = convert_date(each_day.iloc[0, 0])
|
125 |
+
tmp_residents, tmp_tasks = each_day.iloc[1:, 0].tolist(), each_day.iloc[1:, 1].tolist()
|
126 |
+
tmp_tasks = [re.sub(r" \([A-Z]+\)", "", str(_)) for _ in tmp_tasks]
|
127 |
+
pto_residents = []
|
128 |
+
for resident, task in zip(tmp_residents, tmp_tasks):
|
129 |
+
if task in pto:
|
130 |
+
pto_residents.append(resident)
|
131 |
+
for resident, task in zip(tmp_residents, tmp_tasks):
|
132 |
+
if str(resident) == "nan" or resident in pto_residents:
|
133 |
+
continue
|
134 |
+
task = re.sub(r" \([A-Z]+\)", "", task)
|
135 |
+
if tmp_date.weekday() in [5, 6] and task in core_rotations:
|
136 |
+
continue
|
137 |
+
tmp_weekly_schedule[each_week][resident].append(task)
|
138 |
+
weekly_schedule_with_tasks.update(tmp_weekly_schedule)
|
139 |
+
|
140 |
+
|
141 |
+
# create a calendar for each resident
|
142 |
+
resident_calendar = defaultdict(dict)
|
143 |
+
for tmp_date, resident_dict in daily_schedule.items():
|
144 |
+
for resident_name, tasks in resident_dict.items():
|
145 |
+
resident_calendar[resident_name][tmp_date] = tasks
|
146 |
+
|
147 |
+
resident_weekly_calendar = defaultdict(dict)
|
148 |
+
for tmp_week, resident_dict in weekly_schedule_with_tasks.items():
|
149 |
+
for resident_name, tasks in resident_dict.items():
|
150 |
+
resident_weekly_calendar[resident_name][tmp_week] = tasks
|
151 |
+
|
152 |
+
# count number of weeks for each core rotation
|
153 |
+
resident_core_rotation_tallies = {}
|
154 |
+
for row_idx, row in academic_years.iterrows():
|
155 |
+
# need to subtract 1 day from start since it is a Monday and ED/NF/Consult start on Sunday so will affect
|
156 |
+
# those who start at the beginning of the year
|
157 |
+
start, stop = row["Start"].date() - datetime.timedelta(days=1), row["Stop"].date()
|
158 |
+
ay = row["Year"]
|
159 |
+
resident_core_rotation_tallies[ay] = defaultdict(dict)
|
160 |
+
for resident_name, calendar in resident_weekly_calendar.items():
|
161 |
+
tmp_rotation_counts = defaultdict(list)
|
162 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
163 |
+
for each_week, weekly_tasks in tmp_calendar.items():
|
164 |
+
tmp_tasks, tmp_counts = np.unique(weekly_tasks, return_counts=True)
|
165 |
+
tmp_task_counts = {k: v for k, v in zip(tmp_tasks, tmp_counts)}
|
166 |
+
for k, v in tmp_task_counts.items():
|
167 |
+
if k in core_rotations and v >= 3:
|
168 |
+
tmp_rotation_counts[k].append(1)
|
169 |
+
tmp_rotation_counts = {k: np.sum(v) for k, v in tmp_rotation_counts.items()}
|
170 |
+
for each_core_rotation in core_rotations:
|
171 |
+
if each_core_rotation not in tmp_rotation_counts:
|
172 |
+
tmp_rotation_counts[each_core_rotation] = 0
|
173 |
+
resident_core_rotation_tallies[ay][resident_name] = tmp_rotation_counts
|
174 |
+
|
175 |
+
for each_ay, each_resident_dict in resident_core_rotation_tallies.items():
|
176 |
+
for each_resident, resident_rotation_counts in each_resident_dict.items():
|
177 |
+
resident_rotation_counts["Total Nucs"] = resident_rotation_counts["Nucs"] + resident_rotation_counts["CV Nucs"]
|
178 |
+
|
179 |
+
# it's easier to keep track of NF, consult by days rather than weeks due to potential random swaps, sick call, and split weeks
|
180 |
+
# also keep track of nucs days since for nucs time requirements days may be more important than weeks
|
181 |
+
resident_rotations_by_days = {}
|
182 |
+
for row_idx, row in academic_years.iterrows():
|
183 |
+
# need to subtract 1 day from start since it is a Monday and ED/NF/Consult start on Sunday so will affect
|
184 |
+
# those who start at the beginning of the year
|
185 |
+
start, stop = row["Start"].date() - datetime.timedelta(days=1), row["Stop"].date()
|
186 |
+
ay = row["Year"]
|
187 |
+
resident_rotations_by_days[ay] = defaultdict(dict)
|
188 |
+
for resident_name, calendar in resident_weekly_calendar.items():
|
189 |
+
tmp_rotation_counts = defaultdict(list)
|
190 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
191 |
+
for each_week, weekly_tasks in tmp_calendar.items():
|
192 |
+
tmp_tasks, tmp_counts = np.unique(weekly_tasks, return_counts=True)
|
193 |
+
tmp_task_counts = {k: v for k, v in zip(tmp_tasks, tmp_counts)}
|
194 |
+
for k, v in tmp_task_counts.items():
|
195 |
+
if k in core_rotations + call_rotations:
|
196 |
+
tmp_rotation_counts[k].append(v)
|
197 |
+
tmp_rotation_counts = {k: np.sum(v) for k, v in tmp_rotation_counts.items()}
|
198 |
+
for each_core_rotation in core_rotations + call_rotations:
|
199 |
+
if each_core_rotation not in tmp_rotation_counts:
|
200 |
+
tmp_rotation_counts[each_core_rotation] = 0
|
201 |
+
resident_rotations_by_days[ay][resident_name] = tmp_rotation_counts
|
202 |
+
|
203 |
+
for each_ay, each_resident_dict in resident_rotations_by_days.items():
|
204 |
+
for each_resident, resident_rotation_counts in each_resident_dict.items():
|
205 |
+
resident_rotation_counts["Total Nucs"] = resident_rotation_counts["Nucs"] + resident_rotation_counts["CV Nucs"]
|
206 |
+
|
207 |
+
for each_ay, each_resident_dict in resident_rotations_by_days.items():
|
208 |
+
for each_resident, resident_rotation_counts in each_resident_dict.items():
|
209 |
+
resident_core_rotation_tallies[each_ay][each_resident]["ED"] = int(np.round(resident_rotation_counts["ED"] / 6))
|
210 |
+
resident_core_rotation_tallies[each_ay][each_resident]["Consult"] = int(np.round(resident_rotation_counts["Consult"] / 6))
|
211 |
+
resident_core_rotation_tallies[each_ay][each_resident]["NF"] = int(np.round(resident_rotation_counts["NF"] / 6))
|
212 |
+
|
213 |
+
# count weekend call shifts (Sat Consult, Sat NF, In-House, Sat EDD)
|
214 |
+
# does not include Sundays from NF, consult, ED rotation or Angio/IR call)
|
215 |
+
call_shifts_worked = {}
|
216 |
+
for row_idx, row in academic_years.iterrows():
|
217 |
+
# note here we do NOT subtract 1 day from start because if you are assigned to Sunday in-house shift
|
218 |
+
# prior to AY start it counts for the that AY
|
219 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
220 |
+
ay = row["Year"]
|
221 |
+
call_shifts_worked[ay] = defaultdict(dict)
|
222 |
+
for resident_name, calendar in resident_calendar.items(): # use daily calendar
|
223 |
+
tmp_call_shifts = defaultdict(list)
|
224 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
225 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
226 |
+
for each_task in daily_tasks:
|
227 |
+
if each_task in call_shifts:
|
228 |
+
tmp_call_shifts[each_task].append(1)
|
229 |
+
tmp_call_shifts = {k: np.sum(v) for k, v in tmp_call_shifts.items()}
|
230 |
+
for each_call_shift in call_shifts:
|
231 |
+
if each_call_shift not in tmp_call_shifts:
|
232 |
+
tmp_call_shifts[each_call_shift] = 0
|
233 |
+
call_shifts_worked[ay][resident_name] = tmp_call_shifts
|
234 |
+
|
235 |
+
|
236 |
+
# count holidays worked (call shifts only)
|
237 |
+
# differentiates between working on the actual holiday and holiday weekend
|
238 |
+
def count_holidays_worked(which_holidays):
|
239 |
+
holidays_worked = {}
|
240 |
+
for row_idx, row in academic_years.iterrows():
|
241 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
242 |
+
ay = row["Year"]
|
243 |
+
holidays_worked[ay] = defaultdict(dict)
|
244 |
+
for resident_name, calendar in resident_calendar.items():
|
245 |
+
tmp_holidays = []
|
246 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop and k in which_holidays}
|
247 |
+
for each_holiday, daily_tasks in tmp_calendar.items():
|
248 |
+
for each_task in daily_tasks:
|
249 |
+
if each_task in call_rotations + call_shifts:
|
250 |
+
tmp_holidays.append(1)
|
251 |
+
break # if stack shifts, break so it doesn't double count
|
252 |
+
holidays_worked[ay][resident_name] = int(np.sum(tmp_holidays))
|
253 |
+
return holidays_worked
|
254 |
+
|
255 |
+
|
256 |
+
holidays_worked = count_holidays_worked(holidays)
|
257 |
+
|
258 |
+
# identify which holidays are Christmas, New Years, and Thanksgiving
|
259 |
+
# Thanksgiving is the only holiday in November
|
260 |
+
thanksgiving = [_ for _ in holidays if _.month == 11]
|
261 |
+
# consider observed dates as well
|
262 |
+
christmas = [_ for _ in holidays if _.month == 12 and abs(int((datetime.date(_.year, 12, 25) - _).days)) < 2]
|
263 |
+
new_years_candidates = [datetime.date(2000, 1, 1), datetime.date(2000, 12, 31), datetime.date(2000, 1, 2)]
|
264 |
+
new_years = []
|
265 |
+
for _ in holidays:
|
266 |
+
if _.month == 1 and _.day in [1, 2] or _.month == 12 and _.day == 31:
|
267 |
+
new_years.append(_)
|
268 |
+
thanksgiving_worked = count_holidays_worked(thanksgiving)
|
269 |
+
christmas_worked = count_holidays_worked(christmas)
|
270 |
+
new_years_worked = count_holidays_worked(new_years)
|
271 |
+
|
272 |
+
# also count weekends for Thanksgiving, Christmas, New Years
|
273 |
+
# if you work more than one day for a weekend, it will count for more than 1
|
274 |
+
# since thanksgiving is always Thurs, it's easy...
|
275 |
+
thanksgiving_weekend = []
|
276 |
+
|
277 |
+
for each_thanksgiving in thanksgiving:
|
278 |
+
tmp_weekend = []
|
279 |
+
for each_day in [*daily_schedule]:
|
280 |
+
if int((each_day - each_thanksgiving).days) in [2, 3]:
|
281 |
+
assert each_day.weekday() in [5, 6]
|
282 |
+
tmp_weekend.append(each_day)
|
283 |
+
thanksgiving_weekend.append(tmp_weekend)
|
284 |
+
|
285 |
+
# Christmas/NY a bit trickier
|
286 |
+
# first identify the Christmas and New Year's holiday weeks (AKA which weeks contain the observed holiday)
|
287 |
+
# remember- the week start always precedes the holiday
|
288 |
+
# make dict where key is week and value is all days in that week
|
289 |
+
weeks_dict = {}
|
290 |
+
for each_week in [*weekly_schedule]:
|
291 |
+
# week starts on Sunday
|
292 |
+
assert each_week.weekday() == 6
|
293 |
+
weeks_dict[each_week] = [each_week + datetime.timedelta(days=_) for _ in range(7)]
|
294 |
+
assert len(weeks_dict[each_week]) == 7
|
295 |
+
|
296 |
+
# there are 3 weekends here- weekend before Xmas holiday week, weekend in between, weekend after New Year's holiday week
|
297 |
+
# technically the middle one is the worst
|
298 |
+
# also for some people they may have Xmas holiday week off but then work weekend after New Year's holiday week
|
299 |
+
# which is really not that bad, but this is annoying to take into account so will just treat them all the same
|
300 |
+
christmas_weeks, new_years_weeks = [], []
|
301 |
+
for each_week, days_in_week in weeks_dict.items():
|
302 |
+
if len(list(set(days_in_week) & set(christmas))) > 0:
|
303 |
+
christmas_weeks.append(each_week)
|
304 |
+
if len(list(set(days_in_week) & set(new_years))) > 0:
|
305 |
+
new_years_weeks.append(each_week)
|
306 |
+
|
307 |
+
christmas_new_years_weekend = []
|
308 |
+
for each_week in christmas_weeks:
|
309 |
+
christmas_new_years_weekend.append([each_week, each_week - datetime.timedelta(days=1)]) # Sunday
|
310 |
+
for each_week in new_years_weeks:
|
311 |
+
christmas_new_years_weekend.append([each_week, each_week - datetime.timedelta(days=1)])
|
312 |
+
christmas_new_years_weekend.append([each_week + datetime.timedelta(days=7), each_week + datetime.timedelta(days=6)])
|
313 |
+
|
314 |
+
|
315 |
+
def count_holiday_weekends_worked(which_holidays):
|
316 |
+
holidays_worked = {}
|
317 |
+
for row_idx, row in academic_years.iterrows():
|
318 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
319 |
+
ay = row["Year"]
|
320 |
+
holidays_worked[ay] = defaultdict(dict)
|
321 |
+
for resident_name, calendar in resident_calendar.items():
|
322 |
+
tmp_holidays = []
|
323 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop and k in list(itertools.chain(*which_holidays))}
|
324 |
+
tmp_weekend_task_dict = defaultdict(list)
|
325 |
+
for idx, each_weekend in enumerate(which_holidays):
|
326 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
327 |
+
if each_day in each_weekend:
|
328 |
+
tmp_weekend_task_dict[idx].extend(daily_tasks)
|
329 |
+
for each_weekend, weekend_tasks in tmp_weekend_task_dict.items():
|
330 |
+
for each_task in weekend_tasks:
|
331 |
+
if each_task in call_rotations + call_shifts:
|
332 |
+
tmp_holidays.append(1)
|
333 |
+
break
|
334 |
+
holidays_worked[ay][resident_name] = int(np.sum(tmp_holidays))
|
335 |
+
return holidays_worked
|
336 |
+
|
337 |
+
|
338 |
+
thanksgiving_weekends_worked = count_holiday_weekends_worked(thanksgiving_weekend)
|
339 |
+
christmas_new_years_weekends_worked = count_holiday_weekends_worked(christmas_new_years_weekend)
|
340 |
+
|
341 |
+
# Count PTO days
|
342 |
+
pto_days = {}
|
343 |
+
for row_idx, row in academic_years.iterrows():
|
344 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
345 |
+
ay = row["Year"]
|
346 |
+
pto_days[ay] = defaultdict(dict)
|
347 |
+
for resident_name, calendar in resident_calendar.items(): # use daily calendar
|
348 |
+
tmp_pto_days = defaultdict(list)
|
349 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
350 |
+
in_house_days = 0
|
351 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
352 |
+
# doesn't count if weekend or holiday
|
353 |
+
for each_task in daily_tasks:
|
354 |
+
if each_task in pto and each_day.weekday() not in [5, 6]:
|
355 |
+
if each_task == "LOA":
|
356 |
+
# Easier to see how many LOA weeks if we just include holidays
|
357 |
+
tmp_pto_days[each_task].append(1)
|
358 |
+
elif each_task != "LOA" and each_day not in holidays:
|
359 |
+
tmp_pto_days[each_task].append(1)
|
360 |
+
if each_task == "In-House":
|
361 |
+
in_house_days += 1
|
362 |
+
tmp_pto_days = {k: np.sum(v) for k, v in tmp_pto_days.items()}
|
363 |
+
# treat VACATION and HOLIDAY the same then subtract in house days
|
364 |
+
for pto_day_type in pto:
|
365 |
+
if pto_day_type not in tmp_pto_days:
|
366 |
+
tmp_pto_days[pto_day_type] = 0
|
367 |
+
tmp_pto_days["VACATION"] = tmp_pto_days["VACATION"] + tmp_pto_days["HOLIDAY"] - in_house_days
|
368 |
+
_ = tmp_pto_days.pop("HOLIDAY")
|
369 |
+
pto_days[ay][resident_name] = tmp_pto_days
|
370 |
+
|
371 |
+
|
372 |
+
# Count sick call
|
373 |
+
sick_call_weekdays = {}
|
374 |
+
for row_idx, row in academic_years.iterrows():
|
375 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
376 |
+
ay = row["Year"]
|
377 |
+
sick_call_weekdays[ay] = defaultdict(dict)
|
378 |
+
for resident_name, calendar in resident_calendar.items():
|
379 |
+
tmp_sick_call_weekdays = []
|
380 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
381 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
382 |
+
if each_day.weekday() not in [0, 1, 2, 3]: # Mon-Thurs only
|
383 |
+
continue
|
384 |
+
if "Sick Call" in daily_tasks:
|
385 |
+
tmp_sick_call_weekdays.append(1)
|
386 |
+
sick_call_weekdays[ay][resident_name] = int(np.round(np.sum(tmp_sick_call_weekdays) / 4))
|
387 |
+
|
388 |
+
sick_call_weekends = {}
|
389 |
+
for row_idx, row in academic_years.iterrows():
|
390 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
391 |
+
ay = row["Year"]
|
392 |
+
sick_call_weekends[ay] = defaultdict(dict)
|
393 |
+
for resident_name, calendar in resident_calendar.items():
|
394 |
+
tmp_sick_call_weekends = []
|
395 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
396 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
397 |
+
if each_day.weekday() not in [4, 5, 6]: # Fri-Sun only
|
398 |
+
continue
|
399 |
+
if "Sick Call" in daily_tasks:
|
400 |
+
tmp_sick_call_weekends.append(1)
|
401 |
+
sick_call_weekends[ay][resident_name] = int(np.round(np.sum(tmp_sick_call_weekends) / 3))
|
402 |
+
|
403 |
+
sick_call_holidays = {}
|
404 |
+
for row_idx, row in academic_years.iterrows():
|
405 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
406 |
+
ay = row["Year"]
|
407 |
+
sick_call_holidays[ay] = defaultdict(dict)
|
408 |
+
for resident_name, calendar in resident_calendar.items():
|
409 |
+
tmp_sick_call_holidays = []
|
410 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
411 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
412 |
+
if each_day not in holidays:
|
413 |
+
continue
|
414 |
+
if "Sick Call" in daily_tasks:
|
415 |
+
tmp_sick_call_holidays.append(1)
|
416 |
+
sick_call_holidays[ay][resident_name] = int(np.sum(tmp_sick_call_holidays))
|
417 |
+
|
418 |
+
sick_call_major_holidays = {}
|
419 |
+
for row_idx, row in academic_years.iterrows():
|
420 |
+
start, stop = row["Start"].date(), row["Stop"].date()
|
421 |
+
ay = row["Year"]
|
422 |
+
sick_call_major_holidays[ay] = defaultdict(dict)
|
423 |
+
for resident_name, calendar in resident_calendar.items():
|
424 |
+
tmp_sick_call_major_holidays = defaultdict(list)
|
425 |
+
tmp_calendar = {k: v for k, v in calendar.items() if k >= start and k < stop}
|
426 |
+
for each_day, daily_tasks in tmp_calendar.items():
|
427 |
+
if each_day not in thanksgiving + christmas + new_years:
|
428 |
+
continue
|
429 |
+
if "Sick Call" in daily_tasks:
|
430 |
+
if each_day in thanksgiving:
|
431 |
+
tmp_sick_call_major_holidays["Thanksgiving"].append(1)
|
432 |
+
elif each_day in christmas:
|
433 |
+
tmp_sick_call_major_holidays["Xmas"].append(1)
|
434 |
+
elif each_day in new_years:
|
435 |
+
tmp_sick_call_major_holidays["NY"].append(1)
|
436 |
+
for each_major_holiday in ["Thanksgiving", "Xmas", "NY"]:
|
437 |
+
if each_major_holiday not in tmp_sick_call_major_holidays:
|
438 |
+
tmp_sick_call_major_holidays[each_major_holiday] = 0
|
439 |
+
for k, v in tmp_sick_call_major_holidays.items():
|
440 |
+
tmp_sick_call_major_holidays[k] = int(np.sum(v))
|
441 |
+
sick_call_major_holidays[ay][resident_name] = tmp_sick_call_major_holidays
|
442 |
+
|
443 |
+
sick_call_combined = sick_call_major_holidays.copy()
|
444 |
+
for ay, residents in sick_call_combined.items():
|
445 |
+
for resident_name, resident_dict in residents.items():
|
446 |
+
sick_call_combined[ay][resident_name]["Weekdays"] = sick_call_weekdays[ay][resident_name]
|
447 |
+
sick_call_combined[ay][resident_name]["Weekends"] = sick_call_weekends[ay][resident_name]
|
448 |
+
sick_call_combined[ay][resident_name]["Holidays"] = sick_call_holidays[ay][resident_name]
|
449 |
+
|
450 |
+
# Eliminate residents from years they were not a resident yet
|
451 |
+
for each_ay, each_resident_dict in resident_core_rotation_tallies.copy().items():
|
452 |
+
for each_resident, resident_rotation_dict in each_resident_dict.copy().items():
|
453 |
+
if np.sum(list(resident_rotation_dict.values())) == 0:
|
454 |
+
_ = resident_core_rotation_tallies[each_ay].pop(each_resident)
|
455 |
+
|
456 |
+
classes = defaultdict(list)
|
457 |
+
for row_idx, row in roster.iterrows():
|
458 |
+
classes[row.Year].append(row.Resident)
|
459 |
+
|
460 |
+
######################################################
|
461 |
+
# CREATE EXCEL SPREADSHEET FOR CORE ROTATION TALLIES #
|
462 |
+
######################################################
|
463 |
+
core_rotation_tallies_by_class = {}
|
464 |
+
for each_class, residents in classes.items():
|
465 |
+
core_rotation_tallies_by_class[each_class] = {}
|
466 |
+
for each_resident in residents:
|
467 |
+
tmp_df_list = []
|
468 |
+
for each_ay, ay_tallies in resident_core_rotation_tallies.items():
|
469 |
+
if each_resident not in ay_tallies:
|
470 |
+
continue
|
471 |
+
tmp_df = pd.DataFrame(ay_tallies[each_resident], index=[0])
|
472 |
+
tmp_df["Year"] = each_ay
|
473 |
+
tmp_df_list.append(tmp_df)
|
474 |
+
tmp_df = pd.concat(tmp_df_list)
|
475 |
+
tmp_total = dict(tmp_df.sum())
|
476 |
+
tmp_total["Year"] = "Total"
|
477 |
+
filler = {k: "" for k in [*tmp_total]}
|
478 |
+
filler = pd.DataFrame(filler, index=[0])
|
479 |
+
tmp_df = pd.concat([tmp_df, pd.DataFrame(tmp_total, index=[0]), filler])
|
480 |
+
tmp_df["Resident"] = [each_resident] + [""] * (len(tmp_df) - 1)
|
481 |
+
tmp_df = tmp_df[["Resident", "Year"] + list(tmp_df.columns[:-2])]
|
482 |
+
core_rotation_tallies_by_class[each_class][each_resident] = tmp_df
|
483 |
+
|
484 |
+
core_rotation_tallies_df_by_class = {}
|
485 |
+
for each_class in [*core_rotation_tallies_by_class]:
|
486 |
+
core_rotation_tallies_df_by_class[each_class] = pd.concat(list(core_rotation_tallies_by_class[each_class].values()))
|
487 |
+
|
488 |
+
|
489 |
+
####################################################
|
490 |
+
# CREATE EXCEL SPREADSHEET FOR WEEKEND CALL SHIFTS #
|
491 |
+
####################################################
|
492 |
+
weekend_call_shifts_by_class = {}
|
493 |
+
for each_class, residents in classes.items():
|
494 |
+
weekend_call_shifts_by_class[each_class] = {}
|
495 |
+
for each_resident in residents:
|
496 |
+
tmp_df_list = []
|
497 |
+
for each_ay, ay_tallies in call_shifts_worked.items():
|
498 |
+
if each_resident not in ay_tallies:
|
499 |
+
continue
|
500 |
+
tmp_df = pd.DataFrame(ay_tallies[each_resident], index=[0])
|
501 |
+
tmp_df["Year"] = each_ay
|
502 |
+
tmp_df_list.append(tmp_df)
|
503 |
+
tmp_df = pd.concat(tmp_df_list)
|
504 |
+
tmp_total = dict(tmp_df.sum())
|
505 |
+
tmp_total["Year"] = "Total"
|
506 |
+
filler = {k: "" for k in [*tmp_total]}
|
507 |
+
filler = pd.DataFrame(filler, index=[0])
|
508 |
+
tmp_df = pd.concat([tmp_df, pd.DataFrame(tmp_total, index=[0]), filler])
|
509 |
+
tmp_df["Resident"] = [each_resident] + [""] * (len(tmp_df) - 1)
|
510 |
+
tmp_df = tmp_df[["Resident", "Year"] + list(tmp_df.columns[:-2])]
|
511 |
+
weekend_call_shifts_by_class[each_class][each_resident] = tmp_df
|
512 |
+
|
513 |
+
weekend_call_shifts_df_by_class = {}
|
514 |
+
for each_class in [*weekend_call_shifts_by_class]:
|
515 |
+
weekend_call_shifts_df_by_class[each_class] = pd.concat(list(weekend_call_shifts_by_class[each_class].values()))
|
516 |
+
|
517 |
+
weekend_call_shifts_df = pd.concat([v for k, v in weekend_call_shifts_df_by_class.items()])
|
518 |
+
|
519 |
+
|
520 |
+
#################################################
|
521 |
+
# CREATE EXCEL SPREADSHEET FOR SICK CALL SHIFTS #
|
522 |
+
#################################################
|
523 |
+
sick_call_by_class = {}
|
524 |
+
for each_class, residents in classes.items():
|
525 |
+
sick_call_by_class[each_class] = {}
|
526 |
+
for each_resident in residents:
|
527 |
+
tmp_df_list = []
|
528 |
+
for each_ay, ay_tallies in sick_call_combined.items():
|
529 |
+
if each_resident not in ay_tallies:
|
530 |
+
continue
|
531 |
+
tmp_df = pd.DataFrame(ay_tallies[each_resident], index=[0])
|
532 |
+
tmp_df = tmp_df[["Weekdays", "Weekends", "Holidays", "Thanksgiving", "Xmas", "NY"]]
|
533 |
+
tmp_df["Year"] = each_ay
|
534 |
+
tmp_df_list.append(tmp_df)
|
535 |
+
tmp_df = pd.concat(tmp_df_list)
|
536 |
+
tmp_total = dict(tmp_df.sum())
|
537 |
+
tmp_total["Year"] = "Total"
|
538 |
+
filler = {k: "" for k in [*tmp_total]}
|
539 |
+
filler = pd.DataFrame(filler, index=[0])
|
540 |
+
tmp_df = pd.concat([tmp_df, pd.DataFrame(tmp_total, index=[0]), filler])
|
541 |
+
tmp_df["Resident"] = [each_resident] + [""] * (len(tmp_df) - 1)
|
542 |
+
tmp_df = tmp_df[["Resident", "Year"] + list(tmp_df.columns[:-2])]
|
543 |
+
sick_call_by_class[each_class][each_resident] = tmp_df
|
544 |
+
|
545 |
+
sick_call_df_by_class = {}
|
546 |
+
for each_class in [*sick_call_by_class]:
|
547 |
+
sick_call_df_by_class[each_class] = pd.concat(list(sick_call_by_class[each_class].values()))
|
548 |
+
|
549 |
+
sick_call_df = pd.concat([v for k, v in sick_call_df_by_class.items()])
|
550 |
+
|
551 |
+
|
552 |
+
#########################################
|
553 |
+
# CREATE EXCEL SPREADSHEET FOR PTO DAYS #
|
554 |
+
#########################################
|
555 |
+
pto_by_class = {}
|
556 |
+
for each_class, residents in classes.items():
|
557 |
+
pto_by_class[each_class] = {}
|
558 |
+
for each_resident in residents:
|
559 |
+
tmp_df_list = []
|
560 |
+
for each_ay, ay_pto in pto_days.items():
|
561 |
+
if each_resident not in ay_tallies:
|
562 |
+
continue
|
563 |
+
tmp_df = pd.DataFrame(ay_pto[each_resident], index=[0])
|
564 |
+
tmp_df["Total (Days)"] = tmp_df.sum(1)
|
565 |
+
tmp_df["Total (Weeks)"] = np.round(tmp_df["Total (Days)"] / 5).astype("int")
|
566 |
+
tmp_df["Year"] = each_ay
|
567 |
+
tmp_df_list.append(tmp_df)
|
568 |
+
tmp_df = pd.concat(tmp_df_list)
|
569 |
+
tmp_total = dict(tmp_df.sum())
|
570 |
+
tmp_total["Year"] = "Total"
|
571 |
+
filler = {k: "" for k in [*tmp_total]}
|
572 |
+
filler = pd.DataFrame(filler, index=[0])
|
573 |
+
tmp_df = pd.concat([tmp_df, pd.DataFrame(tmp_total, index=[0]), filler])
|
574 |
+
tmp_df["Resident"] = [each_resident] + [""] * (len(tmp_df) - 1)
|
575 |
+
tmp_df = tmp_df[["Resident", "Year"] + list(tmp_df.columns[:-2])]
|
576 |
+
pto_by_class[each_class][each_resident] = tmp_df
|
577 |
+
|
578 |
+
pto_df_by_class = {}
|
579 |
+
for each_class in [*pto_by_class]:
|
580 |
+
pto_df_by_class[each_class] = pd.concat(list(pto_by_class[each_class].values()))
|
581 |
+
|
582 |
+
pto_df = pd.concat([v for k, v in pto_df_by_class.items()])
|
583 |
+
|
584 |
+
|
585 |
+
#########################################
|
586 |
+
# CREATE EXCEL SPREADSHEET FOR HOLIDAYS #
|
587 |
+
#########################################
|
588 |
+
# first, combine the separate holiday dicts into 1...
|
589 |
+
combined_holidays = {}
|
590 |
+
for each_ay, residents in holidays_worked.items():
|
591 |
+
combined_holidays[each_ay] = defaultdict(dict)
|
592 |
+
for resident_name, resident_dict in residents.items():
|
593 |
+
combined_holidays[each_ay][resident_name] = {
|
594 |
+
"All Holidays": holidays_worked[each_ay][resident_name],
|
595 |
+
"Thanksgiving": thanksgiving_worked[each_ay][resident_name],
|
596 |
+
"Xmas": christmas_worked[each_ay][resident_name],
|
597 |
+
"NY": new_years_worked[each_ay][resident_name],
|
598 |
+
"Thanksgiving Weekend": thanksgiving_weekends_worked[each_ay][resident_name],
|
599 |
+
"Xmas/NY Weekend": christmas_new_years_weekends_worked[each_ay][resident_name]
|
600 |
+
}
|
601 |
+
|
602 |
+
holidays_by_class = {}
|
603 |
+
for each_class, residents in classes.items():
|
604 |
+
holidays_by_class[each_class] = {}
|
605 |
+
for each_resident in residents:
|
606 |
+
tmp_df_list = []
|
607 |
+
for each_ay, ay_holidays in combined_holidays.items():
|
608 |
+
if each_resident not in ay_holidays:
|
609 |
+
continue
|
610 |
+
tmp_df = pd.DataFrame(ay_holidays[each_resident], index=[0])
|
611 |
+
tmp_df["Year"] = each_ay
|
612 |
+
tmp_df_list.append(tmp_df)
|
613 |
+
tmp_df = pd.concat(tmp_df_list)
|
614 |
+
tmp_total = dict(tmp_df.sum())
|
615 |
+
tmp_total["Year"] = "Total"
|
616 |
+
filler = {k: "" for k in [*tmp_total]}
|
617 |
+
filler = pd.DataFrame(filler, index=[0])
|
618 |
+
tmp_df = pd.concat([tmp_df, pd.DataFrame(tmp_total, index=[0]), filler])
|
619 |
+
tmp_df["Resident"] = [each_resident] + [""] * (len(tmp_df) - 1)
|
620 |
+
tmp_df = tmp_df[["Resident", "Year"] + list(tmp_df.columns[:-2])]
|
621 |
+
holidays_by_class[each_class][each_resident] = tmp_df
|
622 |
+
|
623 |
+
holidays_df_by_class = {}
|
624 |
+
for each_class in [*holidays_by_class]:
|
625 |
+
holidays_df_by_class[each_class] = pd.concat(list(holidays_by_class[each_class].values()))
|
626 |
+
|
627 |
+
holidays_df = pd.concat([v for k, v in holidays_df_by_class.items()])
|
628 |
+
|
629 |
+
with pd.ExcelWriter("all_tallies.xlsx") as writer:
|
630 |
+
for each_class, each_df in core_rotation_tallies_df_by_class.items():
|
631 |
+
each_df.to_excel(writer, sheet_name=f"R{each_class}", index=False)
|
632 |
+
pto_df.to_excel(writer, sheet_name="PTO", index=False)
|
633 |
+
weekend_call_shifts_df.to_excel(writer, sheet_name=f"Call Shifts", index=False)
|
634 |
+
sick_call_df.to_excel(writer, sheet_name=f"Sick Call", index=False)
|
635 |
+
holidays_df.to_excel(writer, sheet_name="Holidays", index=False)
|
636 |
+
|
637 |
+
return "all_tallies.xlsx"
|
638 |
+
|
639 |
+
|
640 |
+
demo = gr.Interface(
|
641 |
+
fn=main,
|
642 |
+
inputs=gr.File(file_count="multiple"),
|
643 |
+
outputs="file")
|
644 |
+
|
645 |
+
|
646 |
+
if __name__ == "__main__":
|
647 |
+
demo.launch()
|
648 |
+
|
649 |
+
|
650 |
+
|
651 |
+
|
652 |
+
|
requirements.txt
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
pandas
|
2 |
+
openpyxl
|