nyimbi commited on
Commit
7f0238b
·
verified ·
1 Parent(s): f3a8d03

Updated the strings

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +23 -185
src/streamlit_app.py CHANGED
@@ -1,7 +1,7 @@
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
- Enterprise Roster Generator — Final Adaptive Version
5
  All constraints adapt dynamically based on team size to ensure feasibility.
6
  """
7
 
@@ -324,9 +324,9 @@ def get_weekly_requirements(n_staff: int) -> tuple:
324
  # cumulative_shifts: dict[str, int],
325
  # all_staff_global: list[str],
326
  # ) -> tuple[dict, dict]:
327
- N_AVAIL = len(available_staff)
328
- if N_AVAIL < 5:
329
- raise ValueError("Minimum 5 staff per week.")
330
 
331
  # Get coverage rules
332
  wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
@@ -445,168 +445,6 @@ def get_weekly_requirements(n_staff: int) -> tuple:
445
  return schedule_week, weekly_counts
446
 
447
 
448
- # def solve_week(
449
- # week_idx: int,
450
- # start_date: datetime.date,
451
- # available_staff: list[str],
452
- # cumulative_shifts: dict[str, int],
453
- # all_staff_global: list[str],
454
- # ) -> tuple[dict, dict]:
455
- # """Solve one week with constraints that adapt to team size."""
456
- # N_AVAIL = len(available_staff)
457
- # if N_AVAIL < 5:
458
- # raise ValueError("Minimum 5 staff per week required.")
459
-
460
- # # Determine constraint strictness based on staff count
461
- # IS_LARGE_TEAM = N_AVAIL >= 8 # 8-9 staff get strictest constraints
462
- # IS_MEDIUM_TEAM = N_AVAIL == 7 # 7 staff get moderate constraints
463
-
464
- # # Get coverage rules
465
- # wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
466
-
467
- # full_names = available_staff + [f"Vacant_{i}" for i in range(9 - N_AVAIL)]
468
- # SHIFT = {"day": 0, "night": 1}
469
- # DAYS = 7
470
- # WEEKDAY_REL = {0, 1, 2, 3, 4}
471
-
472
- # model = cp_model.CpModel()
473
- # x = {}
474
- # for p, d, s in itertools.product(range(9), range(DAYS), range(2)):
475
- # x[p, d, s] = model.NewBoolVar(f"x_{p}_{d}_{s}")
476
-
477
- # # === Dynamic Coverage ===
478
- # for d in range(DAYS):
479
- # if d in WEEKDAY_REL:
480
- # day_req = wd_day
481
- # if d in reduced_days:
482
- # day_req = 2
483
- # night_req = wd_night
484
- # else:
485
- # day_req = we_day
486
- # night_req = we_night
487
- # model.Add(sum(x[p, d, SHIFT["day"]] for p in range(9)) == day_req)
488
- # model.Add(sum(x[p, d, SHIFT["night"]] for p in range(9)) == night_req)
489
-
490
- # # === No same-day double shift (always enforced) ===
491
- # for p, d in itertools.product(range(9), range(DAYS)):
492
- # model.Add(x[p, d, SHIFT["day"]] + x[p, d, SHIFT["night"]] <= 1)
493
-
494
- # # === No consecutive days (adaptive) ===
495
- # for p in range(9):
496
- # for d in range(DAYS - 1):
497
- # if IS_LARGE_TEAM:
498
- # # Full restriction for large teams
499
- # model.Add(
500
- # x[p, d, SHIFT["day"]]
501
- # + x[p, d, SHIFT["night"]]
502
- # + x[p, d + 1, SHIFT["day"]]
503
- # + x[p, d + 1, SHIFT["night"]]
504
- # <= 1
505
- # )
506
- # else:
507
- # # Only prevent consecutive NIGHT shifts for smaller teams
508
- # model.Add(x[p, d, SHIFT["night"]] + x[p, d + 1, SHIFT["night"]] <= 1)
509
-
510
- # # === Weekend cap (always enforced) ===
511
- # for p in range(9):
512
- # model.Add(sum(x[p, d, s] for d in (5, 6) for s in range(2)) <= 1)
513
-
514
- # # === 48h rest after night shift (adaptive) ===
515
- # for p in range(9):
516
- # for d in range(DAYS):
517
- # night_d = x[p, d, SHIFT["night"]]
518
- # # Always enforce 24h rest (no shift next day after night)
519
- # if d + 1 < DAYS:
520
- # any_d1 = x[p, d + 1, SHIFT["day"]] + x[p, d + 1, SHIFT["night"]]
521
- # model.Add(any_d1 <= 1 - night_d)
522
-
523
- # # Only enforce second day off for large teams
524
- # if IS_LARGE_TEAM and d + 2 < DAYS:
525
- # any_d2 = x[p, d + 2, SHIFT["day"]] + x[p, d + 2, SHIFT["night"]]
526
- # model.Add(any_d2 <= 1 - night_d)
527
-
528
- # # === Vacants forced to 0 ===
529
- # for p in range(N_AVAIL, 9):
530
- # for d, s in itertools.product(range(DAYS), range(2)):
531
- # model.Add(x[p, d, s] == 0)
532
-
533
- # # === Weekly bounds (adaptive) ===
534
- # if N_AVAIL <= 6:
535
- # MIN_WEEKLY, MAX_WEEKLY = 3, 4 # Small teams work more shifts
536
- # elif N_AVAIL == 7:
537
- # MIN_WEEKLY, MAX_WEEKLY = 2, 4 # Medium teams need flexibility
538
- # else: # 8-9 staff
539
- # MIN_WEEKLY, MAX_WEEKLY = 2, 3
540
-
541
- # week_shifts = {}
542
- # for i, name in enumerate(available_staff):
543
- # var = model.NewIntVar(MIN_WEEKLY, MAX_WEEKLY, f"wshift_{i}")
544
- # model.Add(var == sum(x[i, d, s] for d in range(DAYS) for s in range(2)))
545
- # week_shifts[name] = var
546
-
547
- # # === Soft fairness objective ===
548
- # objective_terms = []
549
- # for i, name in enumerate(available_staff):
550
- # cum = cumulative_shifts.get(name, 0)
551
- # objective_terms.append(cum * week_shifts[name])
552
- # model.Minimize(sum(objective_terms))
553
-
554
- # solver = cp_model.CpSolver()
555
- # # Longer timeout for smaller teams with tighter constraints
556
- # solver.parameters.max_time_in_seconds = 45.0 if N_AVAIL <= 7 else 30.0
557
- # solver.parameters.num_search_workers = 6
558
-
559
- # status = solver.Solve(model)
560
- # if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
561
- # status_map = {
562
- # cp_model.UNKNOWN: "UNKNOWN",
563
- # cp_model.MODEL_INVALID: "MODEL_INVALID",
564
- # cp_model.FEASIBLE: "FEASIBLE",
565
- # cp_model.INFEASIBLE: "INFEASIBLE",
566
- # cp_model.OPTIMAL: "OPTIMAL",
567
- # }
568
- # status_name = status_map.get(status, f"Status {status}")
569
-
570
- # error_msg = (
571
- # f"Week {week_idx + 1} solver returned: {status_name} with {N_AVAIL} staff\n"
572
- # )
573
- # error_msg += "Active constraints:\n"
574
- # error_msg += (
575
- # f"- No consecutive days: {'FULL' if IS_LARGE_TEAM else 'NIGHT ONLY'}\n"
576
- # )
577
- # error_msg += f"- Night rest: {'48h' if IS_LARGE_TEAM else '24h'}\n"
578
- # error_msg += f"- Weekly bounds: {MIN_WEEKLY}-{MAX_WEEKLY} shifts\n"
579
- # error_msg += (
580
- # f"- Coverage: {wd_day}/{wd_night} weekdays, {we_day}/{we_night} weekends"
581
- # )
582
- # if reduced_days:
583
- # error_msg += (
584
- # f"\n- Reduced day(s): {', '.join(str(d) for d in reduced_days)}"
585
- # )
586
- # raise RuntimeError(error_msg)
587
-
588
- # # === Extract solution ===
589
- # schedule_week = {}
590
- # weekly_counts = {name: 0 for name in available_staff}
591
- # for d in range(7):
592
- # day_staff = [
593
- # full_names[p]
594
- # for p in range(9)
595
- # if solver.Value(x[p, d, SHIFT["day"]])
596
- # and not full_names[p].startswith("Vacant_")
597
- # ]
598
- # night_staff = [
599
- # full_names[p]
600
- # for p in range(9)
601
- # if solver.Value(x[p, d, SHIFT["night"]])
602
- # and not full_names[p].startswith("Vacant_")
603
- # ]
604
- # schedule_week[d] = {"day": day_staff, "night": night_staff}
605
- # for name in day_staff + night_staff:
606
- # weekly_counts[name] += 1
607
-
608
- # return schedule_week, weekly_counts
609
-
610
 
611
  def solve_week(
612
  week_idx: int,
@@ -1037,10 +875,10 @@ def export_ics(schedule, start_date, output_path):
1037
 
1038
 
1039
  # ------------------------------
1040
- # Streamlit App — CORRECTED SESSION STATE HANDLING
1041
  # ------------------------------
1042
  st.set_page_config(page_title="Enterprise Roster (Final)", layout="wide")
1043
- st.title("🪖 Enterprise Roster Generator — Final")
1044
 
1045
  # === Initialize session state (FIRST RUN ONLY) ===
1046
  if "initialized" not in st.session_state:
@@ -1058,7 +896,7 @@ if "initialized" not in st.session_state:
1058
  st.session_state.roster_ready = False
1059
 
1060
  # === Auth UI ===
1061
- st.sidebar.header("üîê Access")
1062
  role = st.sidebar.radio(
1063
  "Role",
1064
  ["Manager", "Staff"],
@@ -1105,7 +943,7 @@ if st.session_state.user_role == "manager":
1105
 
1106
  st.header("3. Weekly Availability (Holiday/Mission)")
1107
  st.markdown(
1108
- "Uncheck staff who are unavailable (max 4 absent/week ‚Üí min 5 available)."
1109
  )
1110
  avail_matrix = {}
1111
  cols_w = st.columns(6)
@@ -1122,7 +960,7 @@ if st.session_state.user_role == "manager":
1122
  if len(available) < 5:
1123
  st.error("‚ö†Ô∏è ‚â•5 must be available")
1124
 
1125
- if st.button("üöÄ Generate Rolling Roster", type="primary", key="generate_btn"):
1126
  try:
1127
  names_all = [n for n in st.session_state.names if n]
1128
  if not names_all:
@@ -1155,7 +993,7 @@ if st.session_state.user_role == "manager":
1155
  # Check if we need reduced coverage
1156
  if len(avail) == 5:
1157
  st.warning(
1158
- f"⚠️ Week {w + 1}: Reduced coverage mode (one weekday day shift = 2 staff)"
1159
  )
1160
 
1161
  for w in range(6):
@@ -1195,7 +1033,7 @@ if st.session_state.user_role == "manager":
1195
  f"roster_{st.session_state.start_date:%Y%m%d}.pkl", data
1196
  )
1197
  if fid:
1198
- st.info(f"💾 Saved to Drive (ID: {fid[:8]}…)")
1199
  except Exception as e:
1200
  st.warning(f"Drive save failed: {e}")
1201
 
@@ -1207,11 +1045,11 @@ if st.session_state.user_role == "manager":
1207
  schedule_weekly_emails(
1208
  full_sched, emails_all, st.session_state.start_date
1209
  )
1210
- st.info("üìß Weekly email reminders scheduled.")
1211
  except Exception as e:
1212
  st.warning(f"Email setup failed: {e}")
1213
 
1214
- st.success("‚úÖ Rolling roster generated!")
1215
 
1216
  except Exception as e:
1217
  st.error(f"Generation failed: {e}")
@@ -1231,11 +1069,11 @@ if st.session_state.roster_ready:
1231
 
1232
  if reduced_weeks:
1233
  st.warning(
1234
- f"⚠️ Reduced coverage in Week(s): {', '.join(map(str, reduced_weeks))} "
1235
  "(one weekday day shift = 2 staff instead of 3)."
1236
  )
1237
 
1238
- st.header("üìã Full 6‚ÄëWeek Roster")
1239
  rows = []
1240
  for d in range(42):
1241
  dt = st.session_state.start_date + timedelta(days=d)
@@ -1255,7 +1093,7 @@ if st.session_state.roster_ready:
1255
  df = pd.DataFrame(rows)
1256
  st.dataframe(df, use_container_width=True, hide_index=True)
1257
 
1258
- st.subheader("üìä Cumulative Shifts")
1259
  summ = []
1260
  for name in [n for n in st.session_state.names if n]:
1261
  summ.append(
@@ -1270,7 +1108,7 @@ if st.session_state.roster_ready:
1270
  c1, c2, c3, c4 = st.columns(4)
1271
  with c1:
1272
  st.download_button(
1273
- "üì• CSV",
1274
  df.to_csv(index=False).encode(),
1275
  "roster.csv",
1276
  "text/csv",
@@ -1283,7 +1121,7 @@ if st.session_state.roster_ready:
1283
  ):
1284
  with open(f.name, "rb") as pf:
1285
  st.download_button(
1286
- "📄 PDF",
1287
  pf.read(),
1288
  "roster.pdf",
1289
  "application/pdf",
@@ -1295,7 +1133,7 @@ if st.session_state.roster_ready:
1295
  if export_ics(full_sched, st.session_state.start_date, Path(f.name)):
1296
  with open(f.name, "rb") as pf:
1297
  st.download_button(
1298
- "üìÖ ICS",
1299
  pf.read(),
1300
  "roster.ics",
1301
  "text/calendar",
@@ -1303,19 +1141,19 @@ if st.session_state.roster_ready:
1303
  )
1304
  os.unlink(f.name)
1305
  with c4:
1306
- if st.button("🗑️ Clear", key="clear_btn"):
1307
  st.session_state.roster_ready = False
1308
  st.session_state.roster_weekly = {}
1309
  st.session_state.cumulative_shifts = {}
1310
  st.rerun()
1311
 
1312
  # Drive Load
1313
- st.subheader("☁️ Load from Drive")
1314
  files = list_drive_files()
1315
  if files:
1316
  opts = {f["title"]: f["id"] for f in files}
1317
  sel = st.selectbox("Select roster", list(opts.keys()), key="drive_select")
1318
- if st.button("üì• Load Selected", key="load_btn"):
1319
  try:
1320
  data = pickle.loads(load_from_drive(opts[sel]))
1321
  st.session_state.roster_weekly = data["weekly"]
@@ -1343,7 +1181,7 @@ if st.session_state.roster_ready:
1343
  if not staff_name:
1344
  st.warning("Enter an email matching a staff member.")
1345
  else:
1346
- st.header(f"üëã Your Shifts, {staff_name}")
1347
  my_shifts = []
1348
  for d in range(42):
1349
  dt = st.session_state.start_date + timedelta(days=d)
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
+ Enterprise Roster Generator Final Adaptive Version
5
  All constraints adapt dynamically based on team size to ensure feasibility.
6
  """
7
 
 
324
  # cumulative_shifts: dict[str, int],
325
  # all_staff_global: list[str],
326
  # ) -> tuple[dict, dict]:
327
+ # N_AVAIL = len(available_staff)
328
+ # if N_AVAIL < 5:
329
+ # raise ValueError("Minimum 5 staff per week.")
330
 
331
  # Get coverage rules
332
  wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
 
445
  return schedule_week, weekly_counts
446
 
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
 
449
  def solve_week(
450
  week_idx: int,
 
875
 
876
 
877
  # ------------------------------
878
+ # Streamlit App CORRECTED SESSION STATE HANDLING
879
  # ------------------------------
880
  st.set_page_config(page_title="Enterprise Roster (Final)", layout="wide")
881
+ st.title("Enterprise Roster Generator Final")
882
 
883
  # === Initialize session state (FIRST RUN ONLY) ===
884
  if "initialized" not in st.session_state:
 
896
  st.session_state.roster_ready = False
897
 
898
  # === Auth UI ===
899
+ st.sidebar.header("Access")
900
  role = st.sidebar.radio(
901
  "Role",
902
  ["Manager", "Staff"],
 
943
 
944
  st.header("3. Weekly Availability (Holiday/Mission)")
945
  st.markdown(
946
+ "Uncheck staff who are unavailable (max 4 absent/week min 5 available)."
947
  )
948
  avail_matrix = {}
949
  cols_w = st.columns(6)
 
960
  if len(available) < 5:
961
  st.error("‚ö†Ô∏è ‚â•5 must be available")
962
 
963
+ if st.button("Generate Rolling Roster", type="primary", key="generate_btn"):
964
  try:
965
  names_all = [n for n in st.session_state.names if n]
966
  if not names_all:
 
993
  # Check if we need reduced coverage
994
  if len(avail) == 5:
995
  st.warning(
996
+ f"Week {w + 1}: Reduced coverage mode (one weekday day shift = 2 staff)"
997
  )
998
 
999
  for w in range(6):
 
1033
  f"roster_{st.session_state.start_date:%Y%m%d}.pkl", data
1034
  )
1035
  if fid:
1036
+ st.info(f"Saved to Drive (ID: {fid[:8]}…)")
1037
  except Exception as e:
1038
  st.warning(f"Drive save failed: {e}")
1039
 
 
1045
  schedule_weekly_emails(
1046
  full_sched, emails_all, st.session_state.start_date
1047
  )
1048
+ st.info("Weekly email reminders scheduled.")
1049
  except Exception as e:
1050
  st.warning(f"Email setup failed: {e}")
1051
 
1052
+ st.success("Rolling roster generated!")
1053
 
1054
  except Exception as e:
1055
  st.error(f"Generation failed: {e}")
 
1069
 
1070
  if reduced_weeks:
1071
  st.warning(
1072
+ f"Reduced coverage in Week(s): {', '.join(map(str, reduced_weeks))} "
1073
  "(one weekday day shift = 2 staff instead of 3)."
1074
  )
1075
 
1076
+ st.header("Full 6 Week Roster")
1077
  rows = []
1078
  for d in range(42):
1079
  dt = st.session_state.start_date + timedelta(days=d)
 
1093
  df = pd.DataFrame(rows)
1094
  st.dataframe(df, use_container_width=True, hide_index=True)
1095
 
1096
+ st.subheader("Cumulative Shifts")
1097
  summ = []
1098
  for name in [n for n in st.session_state.names if n]:
1099
  summ.append(
 
1108
  c1, c2, c3, c4 = st.columns(4)
1109
  with c1:
1110
  st.download_button(
1111
+ "CSV",
1112
  df.to_csv(index=False).encode(),
1113
  "roster.csv",
1114
  "text/csv",
 
1121
  ):
1122
  with open(f.name, "rb") as pf:
1123
  st.download_button(
1124
+ "PDF",
1125
  pf.read(),
1126
  "roster.pdf",
1127
  "application/pdf",
 
1133
  if export_ics(full_sched, st.session_state.start_date, Path(f.name)):
1134
  with open(f.name, "rb") as pf:
1135
  st.download_button(
1136
+ "ICS",
1137
  pf.read(),
1138
  "roster.ics",
1139
  "text/calendar",
 
1141
  )
1142
  os.unlink(f.name)
1143
  with c4:
1144
+ if st.button("Clear", key="clear_btn"):
1145
  st.session_state.roster_ready = False
1146
  st.session_state.roster_weekly = {}
1147
  st.session_state.cumulative_shifts = {}
1148
  st.rerun()
1149
 
1150
  # Drive Load
1151
+ st.subheader("Load from Drive")
1152
  files = list_drive_files()
1153
  if files:
1154
  opts = {f["title"]: f["id"] for f in files}
1155
  sel = st.selectbox("Select roster", list(opts.keys()), key="drive_select")
1156
+ if st.button("Load Selected", key="load_btn"):
1157
  try:
1158
  data = pickle.loads(load_from_drive(opts[sel]))
1159
  st.session_state.roster_weekly = data["weekly"]
 
1181
  if not staff_name:
1182
  st.warning("Enter an email matching a staff member.")
1183
  else:
1184
+ st.header(f"Your Shifts, {staff_name}")
1185
  my_shifts = []
1186
  for d in range(42):
1187
  dt = st.session_state.start_date + timedelta(days=d)