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)