Spaces:
Sleeping
Sleeping
File size: 16,181 Bytes
210d88d | 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 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | import uuid
from datetime import datetime, timedelta
import json
from typing import List, Optional, Dict, Any
from models import (
LeaveRequest, LeaveStatus, SubstitutionRequest, SubstitutionStatus,
AffectedSlot, ProposedSwap
)
from storage import (
load_schedule, load_data, save_schedule, add_history_entry,
load_substitution_requests, save_substitution_requests,
load_leave_requests, save_leave_requests
)
import constants as const
def _get_faculty_id_by_name(name: str, faculties: list) -> Optional[str]:
"""Resolve a faculty display name (e.g. 'Prof. Anu') to its internal ID ('anu')."""
name_clean = name.replace("Prof. ", "").replace("Dr. ", "").replace("Mr. ", "").replace("Ms. ", "").strip().lower()
# Exact ID match first
for f in faculties:
if name_clean == f["id"].lower():
return f["id"]
# Substring / partial match
for f in faculties:
if name_clean in f["id"].lower() or name_clean in f["name"].lower():
return f["id"]
# Also check the other direction
if f["id"].lower() in name_clean or f["name"].replace("Prof. ", "").replace("Dr. ", "").replace("Mr. ", "").replace("Ms. ", "").strip().lower() in name_clean:
return f["id"]
# Word-level match: check if any word in the input matches a faculty ID
for word in name_clean.split():
for f in faculties:
if word == f["id"].lower():
return f["id"]
return None
def _get_faculty_name_by_id(fac_id: str, faculties: list) -> str:
for f in faculties:
if f["id"] == fac_id:
return f["name"]
return fac_id
def _resolve_faculty_id(raw_id: str, faculties: list) -> Optional[str]:
"""
Given a raw faculty identifier (could be an internal ID like 'anu',
a display name like 'Prof. Anu', or a user full name like 'Anu Sharma'),
resolve it to the canonical internal faculty ID.
"""
# 1. Direct match on ID
for f in faculties:
if f["id"].lower() == raw_id.lower():
return f["id"]
# 2. Direct match on name
for f in faculties:
if f["name"].lower() == raw_id.lower():
return f["id"]
# 3. Try _get_faculty_id_by_name (handles prefix stripping)
result = _get_faculty_id_by_name(raw_id, faculties)
if result:
return result
# 4. Word-level: check each word of raw_id against faculty IDs
words = raw_id.replace("Prof. ", "").replace("Dr. ", "").replace("Mr. ", "").replace("Ms. ", "").strip().lower().split()
for word in words:
if len(word) < 2: # skip very short words
continue
for f in faculties:
if word == f["id"].lower():
return f["id"]
fname_clean = f["name"].replace("Prof. ", "").replace("Dr. ", "").replace("Mr. ", "").replace("Ms. ", "").strip().lower()
if word == fname_clean or word in fname_clean.split():
return f["id"]
return None
def _parent_section(section_id: str) -> str:
"""Merge sub-sections like '6b-E1', '6b-E2' into parent '6b'."""
return section_id.split("-")[0] if "-" in section_id else section_id
def process_leave_approval(leave: LeaveRequest):
"""Entry point when a leave is approved. Finds affected slots and starts substitution process."""
schedule_data = load_schedule()
if not schedule_data or not schedule_data.get("schedule"):
return
data = load_data()
faculties = data.get("faculties", [])
allocations = data.get("allocations", [])
# Resolve the leave's faculty_id to the canonical internal ID.
# The frontend may send a display name like "Anu Sharma" instead of "anu".
resolved_fac_id = _resolve_faculty_id(leave.faculty_id, faculties)
if not resolved_fac_id:
# Fallback: try using the raw value
resolved_fac_id = leave.faculty_id
leave_fac_name = _get_faculty_name_by_id(resolved_fac_id, faculties)
affected_tasks = []
current_solution = schedule_data["schedule"]
# Track which (parent_section, subject, day, period) combos we've already added
# to avoid duplicate requests for E1/E2 batches of the same class
seen_slots = set()
# Identify affected classes for the given days
for task_id, info in current_solution.items():
faculty_name = info.get("faculty_name", "")
# Resolve this task's faculty to an internal ID for reliable comparison
task_fac_id = _get_faculty_id_by_name(faculty_name, faculties)
day_index = info.get("day_index", 0)
day_name = const.DAYS[day_index] if day_index < len(const.DAYS) else f"Day {day_index}"
# Check if the task belongs to the leaving faculty and on one of their leave days
if task_fac_id and task_fac_id == resolved_fac_id and day_name in leave.days:
# Handle duration (labs are 2 hours, theory 1 hour)
duration = info.get("duration", 1)
period = info.get("period_index", 0)
subject = info.get("subject_code", "")
parent_sec = _parent_section(info.get("section_id", ""))
for i in range(duration):
slot_key = (subject, parent_sec, day_name, period + i)
if slot_key in seen_slots:
continue # Skip E2 if E1 already added for same class
seen_slots.add(slot_key)
affected_tasks.append(AffectedSlot(
subject_code=subject,
section_id=parent_sec, # Use parent section (e.g., '6b' not '6b-E1')
day=day_name,
period=period + i,
room_id=info.get("room_id", "")
))
# For each affected slot, find substitutes
for slot in affected_tasks:
find_substitutes_for_slot(slot, resolved_fac_id, leave.leave_id, current_solution, data)
def find_substitutes_for_slot(slot: AffectedSlot, leave_faculty_id: str, leave_id: str, current_solution: dict, data: dict):
"""Applies the Priority Ladder to find substitutes."""
faculties = data.get("faculties", [])
# Find all faculties teaching the same subject anywhere
teach_same_subject = set()
for task_id, info in current_solution.items():
if info.get("subject_code") == slot.subject_code:
fac_id = _get_faculty_id_by_name(info.get("faculty_name", ""), faculties)
if fac_id and fac_id != leave_faculty_id:
teach_same_subject.add(fac_id)
# Build a map of faculty busyness
busyness = {f["id"]: [] for f in faculties}
day_idx = const.DAYS.index(slot.day) if slot.day in const.DAYS else -1
for task_id, info in current_solution.items():
if info.get("day_index") == day_idx:
fac_id = _get_faculty_id_by_name(info.get("faculty_name", ""), faculties)
if fac_id:
duration = info.get("duration", 1)
start_p = info.get("period_index", 0)
for i in range(duration):
busyness[fac_id].append(start_p + i)
# Also exclude anyone who is currently on an approved leave for this day
leave_requests = load_leave_requests()
on_leave_today = set()
# Always include the leaving faculty
on_leave_today.add(leave_faculty_id)
for lr in leave_requests:
if lr.get("status") == "APPROVED" and slot.day in lr.get("days", []):
# Resolve the raw faculty_id to internal ID so it matches
resolved = _resolve_faculty_id(lr.get("faculty_id", ""), faculties)
if resolved:
on_leave_today.add(resolved)
on_leave_today.add(lr.get("faculty_id")) # keep raw value too
candidates_p1 = [] # Same subject, free
candidates_p2 = [] # Same subject, busy
candidates_p3 = [] # Free, any subject
# Priority 1 & 2
for fac_id in teach_same_subject:
if fac_id in on_leave_today: continue
if slot.period not in busyness.get(fac_id, []):
candidates_p1.append(fac_id)
else:
candidates_p2.append(fac_id)
# Priority 3
for fac in faculties:
fac_id = fac["id"]
if fac_id == leave_faculty_id or fac_id in on_leave_today: continue
if slot.period not in busyness.get(fac_id, []) and fac_id not in candidates_p1:
candidates_p3.append(fac_id)
# Dispatch logic
if candidates_p1:
_dispatch_requests(candidates_p1, slot, leave_faculty_id, leave_id, 1)
elif candidates_p2:
# P2 needs a swap. For simplicity, just pick a random free slot of the candidate
# that the leaving teacher is not using. This requires a bit more logic.
# For MVP, we'll try to find any free slot for candidate `f` and propose a swap.
for fac_id in candidates_p2:
free_slots = [p for p in range(const.NUM_TEACHING_SLOTS_PER_DAY) if p not in busyness[fac_id] and p not in busyness.get(leave_faculty_id, [])]
if free_slots:
swap = ProposedSwap(
subject_code=slot.subject_code,
day=slot.day,
original_period=slot.period,
new_period=free_slots[0]
)
_dispatch_requests([fac_id], slot, leave_faculty_id, leave_id, 2, swap)
return # Just propose one for now
# Fallback to P3 if no swaps found
if candidates_p3:
_dispatch_requests(candidates_p3, slot, leave_faculty_id, leave_id, 3)
elif candidates_p3:
_dispatch_requests(candidates_p3, slot, leave_faculty_id, leave_id, 3)
else:
# Priority 4: Nobody available, escalate to Admin.
pass
def _dispatch_requests(candidates: list, slot: AffectedSlot, original_fac_id: str, leave_id: str, priority: int, swap: Optional[ProposedSwap] = None):
requests = load_substitution_requests()
now = datetime.now()
expires = now + timedelta(hours=24)
for fac_id in candidates:
# NEVER send a substitute request to the original faculty
if fac_id == original_fac_id:
continue
# don't send duplicates
existing = [r for r in requests if r["leave_id"] == leave_id and r["affected_slot"]["period"] == slot.period and r["affected_slot"]["section_id"] == slot.section_id and r["candidate_faculty_id"] == fac_id and r["status"] == "PENDING"]
if existing: continue
req = SubstitutionRequest(
request_id=str(uuid.uuid4()),
leave_id=leave_id,
affected_slot=slot,
original_faculty_id=original_fac_id,
candidate_faculty_id=fac_id,
priority_level=priority,
status=SubstitutionStatus.PENDING,
sent_at=now.isoformat(),
expires_at=expires.isoformat(),
proposed_swap=swap
)
import dataclasses
requests.append(dataclasses.asdict(req))
save_substitution_requests(requests)
def handle_acceptance(request_id: str):
requests = load_substitution_requests()
target_req = next((r for r in requests if r["request_id"] == request_id), None)
if not target_req or target_req["status"] != "PENDING":
return False, "Request not found or not pending."
target_req["status"] = "ACCEPTED"
target_req["responded_at"] = datetime.now().isoformat()
# Withdraw others for the same slot+day combination
for r in requests:
if (r["leave_id"] == target_req["leave_id"]
and r["affected_slot"]["period"] == target_req["affected_slot"]["period"]
and r["affected_slot"]["day"] == target_req["affected_slot"]["day"]
and r["request_id"] != request_id):
if r["status"] == "PENDING":
r["status"] = "WITHDRAWN"
save_substitution_requests(requests)
# Update Timetable
schedule_data = load_schedule()
if not schedule_data or not schedule_data.get("schedule"):
return True, "Substitution accepted but no schedule to update."
current_solution = schedule_data.get("schedule", {})
data = load_data()
faculties = data.get("faculties", [])
# Resolve faculty IDs reliably
original_fac_id = _resolve_faculty_id(target_req["original_faculty_id"], faculties) or target_req["original_faculty_id"]
sub_fac_id = _resolve_faculty_id(target_req["candidate_faculty_id"], faculties) or target_req["candidate_faculty_id"]
leave_fac_name = _get_faculty_name_by_id(original_fac_id, faculties)
sub_fac_name = _get_faculty_name_by_id(sub_fac_id, faculties)
day_idx = const.DAYS.index(target_req["affected_slot"]["day"]) if target_req["affected_slot"]["day"] in const.DAYS else -1
# The affected_slot section_id is a parent section (e.g., '6b').
# We must update ALL sub-sections (6b-E1, 6b-E2, etc.) that match.
parent_sec = target_req["affected_slot"]["section_id"]
updated = False
for task_id, info in current_solution.items():
faculty_name = info.get("faculty_name", "")
# Use ID-based matching instead of fragile name stripping
task_fac_id = _get_faculty_id_by_name(faculty_name, faculties)
# Match by parent section (e.g., task '6b-E1' matches parent '6b')
task_parent_sec = info.get("section_id", "").split("-")[0] if "-" in info.get("section_id", "") else info.get("section_id", "")
if (task_fac_id and task_fac_id == original_fac_id
and info.get("day_index") == day_idx
and task_parent_sec == parent_sec):
# Match period
duration = info.get("duration", 1)
start_p = info.get("period_index", 0)
if start_p <= target_req["affected_slot"]["period"] < start_p + duration:
# Update to the substitute teacher
info["faculty_name"] = sub_fac_name
info["is_substituted"] = True
info["original_faculty_name"] = leave_fac_name
updated = True
# DON'T break — update ALL sub-sections (E1, E2, etc.)
# If swap involved, swap the slots
if target_req.get("proposed_swap"):
swap = target_req["proposed_swap"]
# Not fully implemented atomic swap for brevity, but this is where it'd go
pass
# Save the updated schedule using the storage module (supports CommitScheduler)
save_schedule(current_solution)
add_history_entry(
operation_type="SUBSTITUTION",
description=f"Substituted {leave_fac_name} with {sub_fac_name} for {target_req['affected_slot']['subject_code']} on {target_req['affected_slot']['day']} P{target_req['affected_slot']['period']+1}",
affected_sections=[target_req['affected_slot'].get('section_id', '')],
changes=[],
status="SUCCESS",
)
return True, "Substitution accepted and timetable updated."
def handle_decline(request_id: str):
requests = load_substitution_requests()
target_req = next((r for r in requests if r["request_id"] == request_id), None)
if not target_req or target_req["status"] != "PENDING":
return False, "Request not found or not pending."
target_req["status"] = "DECLINED"
target_req["responded_at"] = datetime.now().isoformat()
save_substitution_requests(requests)
# Ideally, trigger find_substitutes_for_slot again here or move to next priority level.
# We would need to keep track of which priority level we are currently exploring for this slot.
return True, "Substitution declined."
def check_timeouts():
requests = load_substitution_requests()
changed = False
now = datetime.now()
for r in requests:
if r["status"] == "PENDING" and datetime.fromisoformat(r["expires_at"]) < now:
r["status"] = "TIMEOUT"
changed = True
if changed:
save_substitution_requests(requests)
|