Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, HTTPException, File, UploadFile, Body | |
| from fastapi.responses import StreamingResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from typing import List, Optional | |
| from collections import defaultdict | |
| import traceback | |
| from datetime import datetime | |
| from storage import ( | |
| load_data, save_data, | |
| save_schedule, save_original_schedule, | |
| schedule_exists, load_schedule, | |
| original_schedule_exists, load_original_schedule, | |
| load_history, add_history_entry, save_history, | |
| clear_schedule, clear_history, clear_original_schedule, | |
| save_version, load_versions, restore_version, | |
| ) | |
| from models import Faculty, Subject, Section, Room, SubjectType | |
| from data_loader import Allocation, prepare_scheduling_tasks | |
| from solver import TimetableSolver | |
| from partial_optimizer import PartialOptimizer | |
| from slm_inference import get_constraints_batch, smart_parse, get_constraint, check_api_health | |
| from substitution_engine import ( | |
| process_leave_approval, handle_acceptance, handle_decline, check_timeouts | |
| ) | |
| from storage import ( | |
| load_leave_requests, save_leave_requests, load_substitution_requests, | |
| save_substitution_requests, load_cancellations, save_cancellations | |
| ) | |
| from models import LeaveRequest, LeaveStatus | |
| import uuid | |
| import constants as const | |
| app = FastAPI(title="VTU Timetable Generator API", version="2.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # INTERNAL HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _subject_type(type_str: str) -> SubjectType: | |
| return { | |
| "THEORY": SubjectType.THEORY, | |
| "LAB": SubjectType.LAB, | |
| "SOFTSKILL": SubjectType.SOFTSKILL, | |
| "FORUM": SubjectType.FORUM, | |
| }.get(type_str.upper(), SubjectType.THEORY) | |
| def _build_objects(data: dict): | |
| """Convert raw JSON data dicts into domain model objects.""" | |
| facs = [Faculty(f["id"], f["name"], f["designation"], f["max_hours"]) | |
| for f in data["faculties"]] | |
| subs = [Subject(s["code"], s["name"], s["credits"], | |
| _subject_type(s["type"]), | |
| s.get("is_core", True), s.get("is_heavy", False)) | |
| for s in data["subjects"]] | |
| secs = [Section(s["id"], s["semester"], s["strength"]) | |
| for s in data["sections"]] | |
| rooms = [Room(r["id"], r["capacity"], r["is_lab"], r["building"]) | |
| for r in data["rooms"]] | |
| allocs = [Allocation(a["faculty_id"], a["subject_code"], | |
| a["section_id"], a.get("elective_group")) | |
| for a in data["allocations"]] | |
| return facs, subs, secs, rooms, allocs | |
| def _clean(solution: dict) -> dict: | |
| """Remove non-serialisable task_obj from solution.""" | |
| return {k: {kk: vv for kk, vv in v.items() if kk != "task_obj"} | |
| for k, v in solution.items()} | |
| def diff_schedules(old_sched: dict, new_sched: dict): | |
| changes = [] | |
| affected_sections = set() | |
| all_keys = set(old_sched.keys()) | set(new_sched.keys()) | |
| for k in all_keys: | |
| old_val = old_sched.get(k) | |
| new_val = new_sched.get(k) | |
| if old_val != new_val: | |
| changes.append({ | |
| "task_id": k, | |
| "before": old_val, | |
| "after": new_val | |
| }) | |
| if old_val: affected_sections.add(old_val.get("section_id", "").split("-")[0].upper()) | |
| if new_val: affected_sections.add(new_val.get("section_id", "").split("-")[0].upper()) | |
| return changes, list(affected_sections) | |
| def _build_grid(solution: dict, allocations: list = None) -> dict: | |
| """ | |
| Merge sub-sections (6a-E1, 6a-E2) into their parent (6A) β | |
| exactly what render_timetable_html does in app.py. | |
| Now also enriches grid entries with: | |
| - duration: from the schedule entry | |
| - elective_group: from allocations data | |
| - is_open_elective: true if elective_group contains 'oe' | |
| """ | |
| if allocations is None: | |
| allocations = load_data().get("allocations", []) | |
| # Build allocation lookup: (section_id, subject_code, faculty_id) -> elective_group | |
| alloc_lookup = {} | |
| if allocations: | |
| for a in allocations: | |
| key = ( | |
| a.get("section_id", "").lower(), | |
| a.get("subject_code", "").lower(), | |
| a.get("faculty_id", "").lower(), | |
| ) | |
| alloc_lookup[key] = a.get("elective_group") | |
| # Build faculty_id reverse lookup from data | |
| # The schedule stores faculty_name (e.g. "Prof. Anu") but allocations use faculty_id (e.g. "anu") | |
| # We'll also try matching by section_id + subject_code only as fallback | |
| alloc_by_sec_sub = {} | |
| if allocations: | |
| for a in allocations: | |
| key2 = (a.get("section_id", "").lower(), a.get("subject_code", "").lower()) | |
| alloc_by_sec_sub[key2] = a.get("elective_group") | |
| parent_sections = sorted(set( | |
| info.get("section_id", "").split("-")[0].upper() | |
| for info in solution.values() | |
| )) | |
| merged = {ps: defaultdict(lambda: defaultdict(list)) for ps in parent_sections} | |
| days_seen = {ps: set() for ps in parent_sections} | |
| for task_id, info in solution.items(): | |
| sec_id = info.get("section_id") or "" | |
| ps = sec_id.split("-")[0].upper() if sec_id else "" | |
| day = info.get("day_index", 0) | |
| period = info.get("period_index", 0) | |
| dur = info.get("duration", 1) | |
| subject_raw = info.get("subject_code") or "?" | |
| subject = subject_raw.upper() | |
| faculty = info.get("faculty_name") or "" | |
| short_fac = (faculty.replace("Prof. ", "").replace("Dr. ", "") | |
| .replace("Mr. ", "").replace("Ms. ", "")) | |
| # Look up elective_group from allocations | |
| eg = alloc_by_sec_sub.get((sec_id.lower(), subject_raw.lower())) | |
| is_oe = bool(eg and "oe" in eg.lower()) | |
| days_seen[ps].add(day) | |
| for i in range(dur): | |
| entry = { | |
| "task_id": task_id, | |
| "subject": subject, | |
| "faculty": short_fac, | |
| "duration": dur, | |
| "elective_group": eg, | |
| "is_open_elective": is_oe, | |
| } | |
| # Propagate substitution metadata so frontend can highlight | |
| if info.get("is_substituted"): | |
| entry["is_substituted"] = True | |
| orig = info.get("original_faculty_name") or "" | |
| entry["original_faculty"] = (orig.replace("Prof. ", "").replace("Dr. ", "") | |
| .replace("Mr. ", "").replace("Ms. ", "")) | |
| slot = merged[ps][day][period + i] | |
| if not any(e["subject"] == entry["subject"] and e["faculty"] == entry["faculty"] for e in slot): | |
| slot.append(entry) | |
| grid = {} | |
| for ps in parent_sections: | |
| day_indices = list(range(len(const.DAYS))) # always MonβFri | |
| if 5 in days_seen[ps]: # Saturday only if needed | |
| day_indices.append(5) | |
| slots = {} | |
| for day in day_indices: | |
| slots[str(day)] = { | |
| str(p): merged[ps][day].get(p, []) | |
| for p in range(const.NUM_TEACHING_SLOTS_PER_DAY) | |
| } | |
| grid[ps] = {"days": day_indices, "slots": slots} | |
| return grid | |
| def _timetable_constants() -> dict: | |
| return { | |
| "days": const.DAYS, | |
| "headers": const.TIMETABLE_HEADERS, | |
| "num_periods": const.NUM_TEACHING_SLOTS_PER_DAY, | |
| "break_after_index": const.BREAK_AFTER_INDEX, | |
| "lunch_after_index": const.LUNCH_AFTER_INDEX, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PYDANTIC SCHEMAS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class FacultyIn(BaseModel): | |
| id: str | |
| name: str | |
| designation: str | |
| max_hours: int = 18 | |
| class SubjectIn(BaseModel): | |
| code: str | |
| name: str | |
| credits: int | |
| type: str = "THEORY" # THEORY | LAB | SOFTSKILL | FORUM | |
| is_core: bool = True | |
| is_heavy: bool = False | |
| class SectionIn(BaseModel): | |
| id: str | |
| semester: int | |
| strength: int | |
| class RoomIn(BaseModel): | |
| id: str | |
| capacity: int | |
| is_lab: bool = False | |
| building: str = "Main" | |
| class AllocationIn(BaseModel): | |
| faculty_id: str | |
| subject_code: str | |
| section_id: str | |
| elective_group: Optional[str] = None | |
| class GenerateRequest(BaseModel): | |
| time_limit_seconds: int = 30 | |
| version_label: Optional[str] = None | |
| semesters: Optional[List[int]] = None # e.g. [5, 7] for odd sems only | |
| locked_semesters: Optional[List[int]] = None | |
| class UpdateRequest(BaseModel): | |
| prompt: str | |
| preview_only: bool = False | |
| propose_only: bool = False | |
| class OverwriteRequest(BaseModel): | |
| schedule: dict | |
| class ProposeRequest(BaseModel): | |
| schedule: dict | |
| proposer: str | |
| proposer_name: str | |
| description: str = "Proposed timetable change" | |
| class InjectEntry(BaseModel): | |
| section_id: str # e.g. "6A" (parent section) | |
| day_index: int # 0-4 (Mon-Fri) | |
| period_index: int # 0-7 teaching period | |
| subject_code: str # e.g. "ml" | |
| faculty_name: str # e.g. "Dr. Kavitha" | |
| duration: int = 1 # 1 for theory, 2 for lab | |
| room_id: Optional[str] = None | |
| class InjectRequest(BaseModel): | |
| entries: list[InjectEntry] | |
| class RemoveRequest(BaseModel): | |
| task_id: str | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HEALTH & CONSTANTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health(): | |
| """API liveness check β also returns timetable constants for convenience.""" | |
| return { | |
| "status": "ok", | |
| "schedule_exists": schedule_exists(), | |
| **_timetable_constants(), | |
| } | |
| def slm_health(): | |
| """Check whether the external flan-t5 SLM API is reachable.""" | |
| ok = check_api_health() | |
| return {"slm_online": ok, "message": "SLM API is online" if ok else "SLM API is offline"} | |
| def get_constants(): | |
| """Timetable rendering constants (days, time headers, period indices).""" | |
| return _timetable_constants() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ALL DATA | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_all_data(): | |
| """Return all stored academic data in one call.""" | |
| return load_data() | |
| import pandas as pd | |
| import io | |
| def get_excel_template(): | |
| from openpyxl.worksheet.datavalidation import DataValidation | |
| df_faculties = pd.DataFrame(columns=["id", "name", "designation", "max_hours"]) | |
| df_subjects = pd.DataFrame(columns=["code", "name", "type", "credits", "is_core", "is_heavy"]) | |
| df_sections = pd.DataFrame(columns=["id", "semester", "strength"]) | |
| df_rooms = pd.DataFrame(columns=["id", "capacity", "is_lab", "building"]) | |
| df_allocations = pd.DataFrame(columns=["faculty_id", "subject_code", "section_id", "elective_group"]) | |
| df_scheduling_rules = pd.DataFrame(columns=["rule_type", "faculty_id", "subject_codes", "subject_types", "period", "max_period", "days"]) | |
| output = io.BytesIO() | |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: | |
| df_faculties.to_excel(writer, sheet_name='Faculties', index=False) | |
| df_subjects.to_excel(writer, sheet_name='Subjects', index=False) | |
| df_sections.to_excel(writer, sheet_name='Sections', index=False) | |
| df_rooms.to_excel(writer, sheet_name='Rooms', index=False) | |
| df_allocations.to_excel(writer, sheet_name='Allocations', index=False) | |
| df_scheduling_rules.to_excel(writer, sheet_name='Scheduling Rules', index=False) | |
| # Add dropdown validations to Scheduling Rules sheet | |
| ws = writer.sheets['Scheduling Rules'] | |
| # Rule Type dropdown (column A, rows 2-100) | |
| dv_rule_type = DataValidation(type="list", formula1='"FIXED_PERIOD,BEFORE_TIME,FIXED_DAYS,FACULTY_UNAVAILABLE"', allow_blank=True) | |
| dv_rule_type.prompt = "Select a rule type" | |
| dv_rule_type.promptTitle = "Rule Type" | |
| ws.add_data_validation(dv_rule_type) | |
| dv_rule_type.add(f'A2:A100') | |
| # Subject Types dropdown (column D, rows 2-100) | |
| dv_subject_types = DataValidation(type="list", formula1='"THEORY,LAB,SOFTSKILL,FORUM"', allow_blank=True) | |
| dv_subject_types.prompt = "Select subject type(s) - comma-separate for multiple" | |
| dv_subject_types.promptTitle = "Subject Types" | |
| ws.add_data_validation(dv_subject_types) | |
| dv_subject_types.add(f'D2:D100') | |
| # Period dropdown (column E, rows 2-100) | |
| dv_period = DataValidation(type="list", formula1='"Period 1,Period 2,Period 3,Period 4,Period 5,Period 6,Period 7,Period 8"', allow_blank=True) | |
| dv_period.prompt = "Select period (for FIXED_PERIOD rules)" | |
| dv_period.promptTitle = "Period" | |
| ws.add_data_validation(dv_period) | |
| dv_period.add(f'E2:E100') | |
| # Max Period dropdown (column F, rows 2-100) | |
| dv_max_period = DataValidation(type="list", formula1='"Period 1,Period 2,Period 3,Period 4,Period 5,Period 6,Period 7,Period 8"', allow_blank=True) | |
| dv_max_period.prompt = "Select max period (for BEFORE_TIME rules)" | |
| dv_max_period.promptTitle = "Max Period" | |
| ws.add_data_validation(dv_max_period) | |
| dv_max_period.add(f'F2:F100') | |
| # Days dropdown (column G, rows 2-100) | |
| dv_days = DataValidation(type="list", formula1='"MON,TUE,WED,THU,FRI,SAT"', allow_blank=True) | |
| dv_days.prompt = "Select day(s) - comma-separate for multiple (for FIXED_DAYS rules)" | |
| dv_days.promptTitle = "Days" | |
| ws.add_data_validation(dv_days) | |
| dv_days.add(f'G2:G100') | |
| # Designation dropdown for Faculties sheet (column C, rows 2-100) | |
| ws_fac = writer.sheets['Faculties'] | |
| dv_designation = DataValidation(type="list", formula1='"Professor,Assoc. Prof,Asst. Prof,Guest"', allow_blank=True) | |
| dv_designation.prompt = "Select designation" | |
| dv_designation.promptTitle = "Designation" | |
| ws_fac.add_data_validation(dv_designation) | |
| dv_designation.add(f'C2:C100') | |
| # Subject Type dropdown for Subjects sheet (column C, rows 2-100) | |
| ws_sub = writer.sheets['Subjects'] | |
| dv_sub_type = DataValidation(type="list", formula1='"THEORY,LAB,SOFTSKILL,FORUM"', allow_blank=True) | |
| dv_sub_type.prompt = "Select subject type" | |
| dv_sub_type.promptTitle = "Type" | |
| ws_sub.add_data_validation(dv_sub_type) | |
| dv_sub_type.add(f'C2:C100') | |
| # Boolean dropdowns for Subjects (is_core col E, is_heavy col F) | |
| dv_bool = DataValidation(type="list", formula1='"TRUE,FALSE"', allow_blank=True) | |
| ws_sub.add_data_validation(dv_bool) | |
| dv_bool.add(f'E2:F100') | |
| # Boolean dropdown for Rooms is_lab (column C, rows 2-100) | |
| ws_rooms = writer.sheets['Rooms'] | |
| dv_lab = DataValidation(type="list", formula1='"TRUE,FALSE"', allow_blank=True) | |
| ws_rooms.add_data_validation(dv_lab) | |
| dv_lab.add(f'C2:C100') | |
| output.seek(0) | |
| return StreamingResponse( | |
| output, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| headers={"Content-Disposition": "attachment; filename=timetable_template.xlsx"} | |
| ) | |
| def export_data_as_excel(): | |
| """Export all current data as an Excel file matching the template format.""" | |
| data = load_data() | |
| # Build DataFrames from existing data | |
| df_faculties = pd.DataFrame(data.get("faculties", [])) | |
| df_subjects = pd.DataFrame(data.get("subjects", [])) | |
| df_sections = pd.DataFrame(data.get("sections", [])) | |
| df_rooms = pd.DataFrame(data.get("rooms", [])) | |
| df_allocations = pd.DataFrame(data.get("allocations", [])) | |
| # Build scheduling rules DataFrame with human-readable columns | |
| rules = data.get("scheduling_rules", []) | |
| rules_rows = [] | |
| for rule in rules: | |
| row = { | |
| "rule_type": rule.get("rule_type", ""), | |
| "faculty_id": rule.get("faculty_id", ""), | |
| "subject_codes": ", ".join(rule.get("subject_codes", [])) if isinstance(rule.get("subject_codes"), list) else str(rule.get("subject_codes", "")), | |
| "subject_types": ", ".join(rule.get("subject_types", [])) if isinstance(rule.get("subject_types"), list) else str(rule.get("subject_types", "")), | |
| "period": f"Period {rule['period_index'] + 1}" if rule.get("period_index") is not None else "", | |
| "max_period": f"Period {rule['max_period_index'] + 1}" if rule.get("max_period_index") is not None else "", | |
| "days": ", ".join(rule.get("days", [])) if isinstance(rule.get("days"), list) else str(rule.get("days", "")), | |
| } | |
| rules_rows.append(row) | |
| df_scheduling_rules = pd.DataFrame(rules_rows) if rules_rows else pd.DataFrame(columns=["rule_type", "subject_codes", "subject_types", "period", "max_period", "days"]) | |
| # Ensure column order matches the template | |
| fac_cols = ["id", "name", "designation", "max_hours"] | |
| sub_cols = ["code", "name", "type", "credits", "is_core", "is_heavy"] | |
| sec_cols = ["id", "semester", "strength"] | |
| room_cols = ["id", "capacity", "is_lab", "building"] | |
| alloc_cols = ["faculty_id", "subject_code", "section_id", "elective_group"] | |
| rule_cols = ["rule_type", "faculty_id", "subject_codes", "subject_types", "period", "max_period", "days"] | |
| for col in fac_cols: | |
| if col not in df_faculties.columns: | |
| df_faculties[col] = "" | |
| for col in sub_cols: | |
| if col not in df_subjects.columns: | |
| df_subjects[col] = "" | |
| for col in sec_cols: | |
| if col not in df_sections.columns: | |
| df_sections[col] = "" | |
| for col in room_cols: | |
| if col not in df_rooms.columns: | |
| df_rooms[col] = "" | |
| for col in alloc_cols: | |
| if col not in df_allocations.columns: | |
| df_allocations[col] = "" | |
| output = io.BytesIO() | |
| with pd.ExcelWriter(output, engine='openpyxl') as writer: | |
| df_faculties[fac_cols].to_excel(writer, sheet_name='Faculties', index=False) | |
| df_subjects[sub_cols].to_excel(writer, sheet_name='Subjects', index=False) | |
| df_sections[sec_cols].to_excel(writer, sheet_name='Sections', index=False) | |
| df_rooms[room_cols].to_excel(writer, sheet_name='Rooms', index=False) | |
| df_allocations[alloc_cols].to_excel(writer, sheet_name='Allocations', index=False) | |
| df_scheduling_rules[rule_cols].to_excel(writer, sheet_name='Scheduling Rules', index=False) | |
| # Add dropdown validations (same as template) | |
| from openpyxl.worksheet.datavalidation import DataValidation | |
| # Faculties - designation dropdown | |
| ws_fac = writer.sheets['Faculties'] | |
| dv_desig = DataValidation(type="list", formula1='"Professor,Assoc. Prof,Asst. Prof,Guest"', allow_blank=True) | |
| ws_fac.add_data_validation(dv_desig) | |
| dv_desig.add('C2:C1000') | |
| # Subjects - type dropdown | |
| ws_sub = writer.sheets['Subjects'] | |
| dv_stype = DataValidation(type="list", formula1='"THEORY,LAB,SOFTSKILL,FORUM"', allow_blank=True) | |
| ws_sub.add_data_validation(dv_stype) | |
| dv_stype.add('C2:C1000') | |
| # Subjects - is_core & is_heavy boolean dropdowns | |
| dv_bool_sub = DataValidation(type="list", formula1='"TRUE,FALSE"', allow_blank=True) | |
| ws_sub.add_data_validation(dv_bool_sub) | |
| dv_bool_sub.add('E2:F1000') | |
| # Rooms - is_lab boolean dropdown | |
| ws_rooms = writer.sheets['Rooms'] | |
| dv_lab = DataValidation(type="list", formula1='"TRUE,FALSE"', allow_blank=True) | |
| ws_rooms.add_data_validation(dv_lab) | |
| dv_lab.add('C2:C1000') | |
| # Scheduling Rules - all dropdowns | |
| ws_rules = writer.sheets['Scheduling Rules'] | |
| dv_rt = DataValidation(type="list", formula1='"FIXED_PERIOD,BEFORE_TIME,FIXED_DAYS"', allow_blank=True) | |
| ws_rules.add_data_validation(dv_rt) | |
| dv_rt.add('A2:A1000') | |
| dv_st = DataValidation(type="list", formula1='"THEORY,LAB,SOFTSKILL,FORUM"', allow_blank=True) | |
| ws_rules.add_data_validation(dv_st) | |
| dv_st.add('C2:C1000') | |
| dv_p = DataValidation(type="list", formula1='"Period 1,Period 2,Period 3,Period 4,Period 5,Period 6,Period 7,Period 8"', allow_blank=True) | |
| ws_rules.add_data_validation(dv_p) | |
| dv_p.add('D2:D1000') | |
| dv_mp = DataValidation(type="list", formula1='"Period 1,Period 2,Period 3,Period 4,Period 5,Period 6,Period 7,Period 8"', allow_blank=True) | |
| ws_rules.add_data_validation(dv_mp) | |
| dv_mp.add('E2:E1000') | |
| dv_d = DataValidation(type="list", formula1='"MON,TUE,WED,THU,FRI,SAT"', allow_blank=True) | |
| ws_rules.add_data_validation(dv_d) | |
| dv_d.add('F2:F1000') | |
| output.seek(0) | |
| return StreamingResponse( | |
| output, | |
| media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| headers={"Content-Disposition": "attachment; filename=timetable_data_export.xlsx"} | |
| ) | |
| async def import_excel(file: UploadFile = File(...)): | |
| contents = await file.read() | |
| try: | |
| xls = pd.ExcelFile(io.BytesIO(contents)) | |
| data = { | |
| "faculties": [], | |
| "subjects": [], | |
| "sections": [], | |
| "rooms": [], | |
| "allocations": [], | |
| "scheduling_rules": [] | |
| } | |
| import re | |
| def parse_int(val, default): | |
| if pd.isna(val) or val == '': return default | |
| try: | |
| return int(val) | |
| except (ValueError, TypeError): | |
| m = re.search(r'\d+', str(val)) | |
| return int(m.group()) if m else default | |
| if 'Faculties' in xls.sheet_names: | |
| df = pd.read_excel(xls, 'Faculties').fillna('') | |
| for _, row in df.iterrows(): | |
| if row.get('id'): | |
| data["faculties"].append({ | |
| "id": str(row.get('id')), | |
| "name": str(row.get('name', '')), | |
| "designation": str(row.get('designation', 'Asst. Prof')), | |
| "max_hours": parse_int(row.get('max_hours', 18), 18) | |
| }) | |
| if 'Subjects' in xls.sheet_names: | |
| df = pd.read_excel(xls, 'Subjects').fillna('') | |
| for _, row in df.iterrows(): | |
| if row.get('code'): | |
| data["subjects"].append({ | |
| "code": str(row.get('code')), | |
| "name": str(row.get('name', '')), | |
| "type": str(row.get('type', 'THEORY')), | |
| "credits": parse_int(row.get('credits', 3), 3), | |
| "is_core": bool(row.get('is_core', True)), | |
| "is_heavy": bool(row.get('is_heavy', False)) | |
| }) | |
| if 'Sections' in xls.sheet_names: | |
| df = pd.read_excel(xls, 'Sections').fillna('') | |
| for _, row in df.iterrows(): | |
| if row.get('id'): | |
| data["sections"].append({ | |
| "id": str(row.get('id')), | |
| "semester": parse_int(row.get('semester', 1), 1), | |
| "strength": parse_int(row.get('strength', 60), 60) | |
| }) | |
| if 'Rooms' in xls.sheet_names: | |
| df = pd.read_excel(xls, 'Rooms').fillna('') | |
| for _, row in df.iterrows(): | |
| if row.get('id'): | |
| data["rooms"].append({ | |
| "id": str(row.get('id')), | |
| "capacity": parse_int(row.get('capacity', 60), 60), | |
| "is_lab": bool(row.get('is_lab', False)), | |
| "building": str(row.get('building', 'Main')) | |
| }) | |
| if 'Allocations' in xls.sheet_names: | |
| df = pd.read_excel(xls, 'Allocations').fillna('') | |
| for _, row in df.iterrows(): | |
| if row.get('faculty_id') and row.get('subject_code') and row.get('section_id'): | |
| eg = row.get('elective_group') | |
| data["allocations"].append({ | |
| "faculty_id": str(row.get('faculty_id')), | |
| "subject_code": str(row.get('subject_code')), | |
| "section_id": str(row.get('section_id')), | |
| "elective_group": str(eg) if eg else None | |
| }) | |
| # Parse Scheduling Rules sheet if present, otherwise preserve existing rules | |
| if 'Scheduling Rules' in xls.sheet_names: | |
| import uuid as _uuid | |
| df = pd.read_excel(xls, 'Scheduling Rules').fillna('') | |
| for _, row in df.iterrows(): | |
| rule_type = str(row.get('rule_type', '')).strip() | |
| if not rule_type: | |
| continue | |
| rule = {"id": str(_uuid.uuid4()), "rule_type": rule_type} | |
| # Parse faculty_id | |
| fid = str(row.get('faculty_id', '')).strip() | |
| if fid: | |
| rule["faculty_id"] = fid | |
| # Parse subject_codes (comma-separated) | |
| sc = str(row.get('subject_codes', '')).strip() | |
| rule["subject_codes"] = [s.strip() for s in sc.split(',') if s.strip()] if sc else [] | |
| # Parse subject_types (comma-separated) | |
| st = str(row.get('subject_types', '')).strip() | |
| rule["subject_types"] = [s.strip() for s in st.split(',') if s.strip()] if st else [] | |
| # Parse period (e.g. "Period 3" -> 2) | |
| period_str = str(row.get('period', '')).strip() | |
| if period_str and 'Period' in period_str: | |
| try: | |
| rule["period_index"] = int(period_str.replace('Period ', '')) - 1 | |
| except ValueError: | |
| pass | |
| # Parse max_period | |
| max_period_str = str(row.get('max_period', '')).strip() | |
| if max_period_str and 'Period' in max_period_str: | |
| try: | |
| rule["max_period_index"] = int(max_period_str.replace('Period ', '')) - 1 | |
| except ValueError: | |
| pass | |
| # Parse days (comma-separated) | |
| days_str = str(row.get('days', '')).strip() | |
| if days_str: | |
| rule["days"] = [d.strip() for d in days_str.split(',') if d.strip()] | |
| data["scheduling_rules"].append(rule) | |
| else: | |
| # Preserve existing scheduling_rules if no sheet in upload | |
| existing_data = load_data() | |
| data["scheduling_rules"] = existing_data.get("scheduling_rules", []) | |
| save_data(data) | |
| return {"message": "Data imported successfully", "imported": {k: len(v) for k, v in data.items()}} | |
| except Exception as e: | |
| raise HTTPException(400, f"Failed to parse Excel file: {str(e)}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MANAGE FACULTIES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_faculties(): | |
| return {"faculties": load_data()["faculties"]} | |
| def add_faculty(faculty: FacultyIn): | |
| data = load_data() | |
| if any(f["id"] == faculty.id for f in data["faculties"]): | |
| raise HTTPException(400, f"Faculty ID '{faculty.id}' already exists.") | |
| data["faculties"].append(faculty.model_dump()) | |
| save_data(data) | |
| return {"message": "Faculty added.", "faculty": faculty.model_dump()} | |
| def update_faculty(faculty_id: str, faculty: FacultyIn): | |
| data = load_data() | |
| for i, f in enumerate(data["faculties"]): | |
| if f["id"] == faculty_id: | |
| updated = faculty.model_dump() | |
| data["faculties"][i] = updated | |
| # Cascade to allocations and scheduling_rules if ID changed | |
| if faculty_id != updated["id"]: | |
| for a in data.get("allocations", []): | |
| if a.get("faculty_id") == faculty_id: | |
| a["faculty_id"] = updated["id"] | |
| for r in data.get("scheduling_rules", []): | |
| if r.get("faculty_id") == faculty_id: | |
| r["faculty_id"] = updated["id"] | |
| save_data(data) | |
| return {"message": "Faculty updated.", "faculty": updated} | |
| raise HTTPException(404, f"Faculty '{faculty_id}' not found.") | |
| def clear_faculties(): | |
| data = load_data() | |
| data["faculties"] = [] | |
| save_data(data) | |
| return {"message": "All faculties cleared."} | |
| def delete_faculty(faculty_id: str): | |
| data = load_data() | |
| before = len(data["faculties"]) | |
| data["faculties"] = [f for f in data["faculties"] if f["id"] != faculty_id] | |
| if len(data["faculties"]) == before: | |
| raise HTTPException(404, f"Faculty '{faculty_id}' not found.") | |
| save_data(data) | |
| return {"message": f"Faculty '{faculty_id}' deleted."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MANAGE SUBJECTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_subjects(): | |
| return {"subjects": load_data()["subjects"]} | |
| def add_subject(subject: SubjectIn): | |
| data = load_data() | |
| if any(s["code"] == subject.code for s in data["subjects"]): | |
| raise HTTPException(400, f"Subject code '{subject.code}' already exists.") | |
| data["subjects"].append(subject.model_dump()) | |
| save_data(data) | |
| return {"message": "Subject added.", "subject": subject.model_dump()} | |
| def update_subject(subject_code: str, subject: SubjectIn): | |
| data = load_data() | |
| for i, s in enumerate(data["subjects"]): | |
| if s["code"] == subject_code: | |
| updated = subject.model_dump() | |
| data["subjects"][i] = updated | |
| # Cascade to allocations and scheduling_rules if code changed | |
| if subject_code != updated["code"]: | |
| for a in data.get("allocations", []): | |
| if a.get("subject_code") == subject_code: | |
| a["subject_code"] = updated["code"] | |
| for r in data.get("scheduling_rules", []): | |
| if subject_code in r.get("subject_codes", []): | |
| r["subject_codes"] = [updated["code"] if c == subject_code else c for c in r["subject_codes"]] | |
| save_data(data) | |
| return {"message": "Subject updated.", "subject": updated} | |
| raise HTTPException(404, f"Subject '{subject_code}' not found.") | |
| def clear_subjects(): | |
| data = load_data() | |
| data["subjects"] = [] | |
| save_data(data) | |
| return {"message": "All subjects cleared."} | |
| def delete_subject(subject_code: str): | |
| data = load_data() | |
| before = len(data["subjects"]) | |
| data["subjects"] = [s for s in data["subjects"] if s["code"] != subject_code] | |
| if len(data["subjects"]) == before: | |
| raise HTTPException(404, f"Subject '{subject_code}' not found.") | |
| save_data(data) | |
| return {"message": f"Subject '{subject_code}' deleted."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MANAGE SECTIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_sections(): | |
| return {"sections": load_data()["sections"]} | |
| def add_section(section: SectionIn): | |
| data = load_data() | |
| if any(s["id"] == section.id for s in data["sections"]): | |
| raise HTTPException(400, f"Section ID '{section.id}' already exists.") | |
| data["sections"].append(section.model_dump()) | |
| save_data(data) | |
| return {"message": "Section added.", "section": section.model_dump()} | |
| def update_section(section_id: str, section: SectionIn): | |
| data = load_data() | |
| for i, s in enumerate(data["sections"]): | |
| if s["id"] == section_id: | |
| updated = section.model_dump() | |
| data["sections"][i] = updated | |
| # Cascade to allocations if ID changed | |
| if section_id != updated["id"]: | |
| for a in data.get("allocations", []): | |
| if a.get("section_id") == section_id: | |
| a["section_id"] = updated["id"] | |
| save_data(data) | |
| return {"message": "Section updated.", "section": updated} | |
| raise HTTPException(404, f"Section '{section_id}' not found.") | |
| def clear_sections(): | |
| data = load_data() | |
| data["sections"] = [] | |
| save_data(data) | |
| return {"message": "All sections cleared."} | |
| def list_semesters(): | |
| """Return all unique semester numbers from sections data.""" | |
| data = load_data() | |
| semesters = sorted(set(s["semester"] for s in data.get("sections", []))) | |
| return {"semesters": semesters} | |
| def delete_section(section_id: str): | |
| data = load_data() | |
| before = len(data["sections"]) | |
| data["sections"] = [s for s in data["sections"] if s["id"] != section_id] | |
| if len(data["sections"]) == before: | |
| raise HTTPException(404, f"Section '{section_id}' not found.") | |
| save_data(data) | |
| return {"message": f"Section '{section_id}' deleted."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MANAGE ROOMS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_rooms(): | |
| return {"rooms": load_data()["rooms"]} | |
| def add_room(room: RoomIn): | |
| data = load_data() | |
| if any(r["id"] == room.id for r in data["rooms"]): | |
| raise HTTPException(400, f"Room ID '{room.id}' already exists.") | |
| data["rooms"].append(room.model_dump()) | |
| save_data(data) | |
| return {"message": "Room added.", "room": room.model_dump()} | |
| def update_room(room_id: str, room: RoomIn): | |
| data = load_data() | |
| for i, r in enumerate(data["rooms"]): | |
| if r["id"] == room_id: | |
| updated = room.model_dump() | |
| data["rooms"][i] = updated | |
| save_data(data) | |
| return {"message": "Room updated.", "room": updated} | |
| raise HTTPException(404, f"Room '{room_id}' not found.") | |
| def clear_rooms(): | |
| data = load_data() | |
| data["rooms"] = [] | |
| save_data(data) | |
| return {"message": "All rooms cleared."} | |
| def delete_room(room_id: str): | |
| data = load_data() | |
| before = len(data["rooms"]) | |
| data["rooms"] = [r for r in data["rooms"] if r["id"] != room_id] | |
| if len(data["rooms"]) == before: | |
| raise HTTPException(404, f"Room '{room_id}' not found.") | |
| save_data(data) | |
| return {"message": f"Room '{room_id}' deleted."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MANAGE ALLOCATIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_allocations(): | |
| return {"allocations": load_data()["allocations"]} | |
| def add_allocation(alloc: AllocationIn): | |
| data = load_data() | |
| data["allocations"].append(alloc.model_dump()) | |
| save_data(data) | |
| return {"message": "Allocation added.", "allocation": alloc.model_dump()} | |
| def update_allocation(idx: int, alloc: AllocationIn): | |
| data = load_data() | |
| if idx < 0 or idx >= len(data["allocations"]): | |
| raise HTTPException(404, f"Allocation index {idx} out of range.") | |
| data["allocations"][idx] = alloc.model_dump() | |
| save_data(data) | |
| return {"message": "Allocation updated.", "allocation": alloc.model_dump()} | |
| def clear_allocations(): | |
| data = load_data() | |
| data["allocations"] = [] | |
| save_data(data) | |
| return {"message": "All allocations cleared."} | |
| def delete_allocation(idx: int): | |
| """Delete allocation by its 0-based index in the list.""" | |
| data = load_data() | |
| if idx < 0 or idx >= len(data["allocations"]): | |
| raise HTTPException(404, f"Allocation index {idx} out of range.") | |
| removed = data["allocations"].pop(idx) | |
| save_data(data) | |
| return {"message": "Allocation deleted.", "removed": removed} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEDULING RULES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_scheduling_rules(): | |
| data = load_data() | |
| return {"rules": data.get("scheduling_rules", [])} | |
| def add_scheduling_rule(rule: dict): | |
| import uuid as _uuid | |
| data = load_data() | |
| rules = data.get("scheduling_rules", []) | |
| rule["id"] = str(_uuid.uuid4()) | |
| rules.append(rule) | |
| data["scheduling_rules"] = rules | |
| save_data(data) | |
| return {"message": "Rule added.", "rule": rule} | |
| def update_scheduling_rule(rule_id: str, rule: dict): | |
| data = load_data() | |
| rules = data.get("scheduling_rules", []) | |
| for i, r in enumerate(rules): | |
| if r.get("id") == rule_id: | |
| rule["id"] = rule_id | |
| rules[i] = rule | |
| data["scheduling_rules"] = rules | |
| save_data(data) | |
| return {"message": "Rule updated.", "rule": rule} | |
| raise HTTPException(404, "Rule not found.") | |
| def delete_scheduling_rule(rule_id: str): | |
| data = load_data() | |
| rules = data.get("scheduling_rules", []) | |
| data["scheduling_rules"] = [r for r in rules if r.get("id") != rule_id] | |
| save_data(data) | |
| return {"message": "Rule deleted."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GENERATE TIMETABLE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate(req: GenerateRequest): | |
| try: | |
| data = load_data() | |
| missing = [k for k in ("faculties", "subjects", "sections", "rooms", "allocations") | |
| if not data.get(k)] | |
| if missing: | |
| raise HTTPException(400, f"Missing data for: {', '.join(missing)}.") | |
| # ββ Semester filtering ββββββββββββββββββββββββββββββββββββββββββββ | |
| has_sems = bool(req.semesters) | |
| has_locked = bool(req.locked_semesters) | |
| if has_sems or has_locked: | |
| selected_sems = set(req.semesters or []) | |
| locked_sems = set(req.locked_semesters or []) | |
| combined_sems = selected_sems.union(locked_sems) | |
| if combined_sems: | |
| # 1. Filter sections to only combined semesters | |
| data["sections"] = [ | |
| s for s in data["sections"] if s["semester"] in combined_sems | |
| ] | |
| if not data["sections"]: | |
| raise HTTPException(400, f"No sections found for semesters: {combined_sems}") | |
| # 2. Filter allocations to only reference surviving sections | |
| valid_section_ids = {s["id"] for s in data["sections"]} | |
| data["allocations"] = [ | |
| a for a in data["allocations"] | |
| if a["section_id"] in valid_section_ids | |
| ] | |
| # 3. Filter subjects to only those referenced by surviving allocations | |
| used_subject_codes = {a["subject_code"] for a in data["allocations"]} | |
| data["subjects"] = [ | |
| s for s in data["subjects"] if s["code"] in used_subject_codes | |
| ] | |
| # 4. Filter faculties to only those referenced by surviving allocations | |
| used_faculty_ids = {a["faculty_id"] for a in data["allocations"]} | |
| data["faculties"] = [ | |
| f for f in data["faculties"] if f["id"] in used_faculty_ids | |
| ] | |
| print(f"[generate] Filtered to combined semesters {combined_sems}: " | |
| f"{len(data['sections'])} sections, {len(data['allocations'])} allocations, " | |
| f"{len(data['subjects'])} subjects, {len(data['faculties'])} faculties") | |
| facs, subs, secs, rooms, allocs = _build_objects(data) | |
| tasks = prepare_scheduling_tasks(allocs, facs, subs, secs) | |
| if not tasks: | |
| raise HTTPException(400, "No schedulable tasks found. Check your allocations.") | |
| locked_schedule = {} | |
| if req.locked_semesters: | |
| if schedule_exists(): | |
| locked_schedule = load_schedule() | |
| else: | |
| raise HTTPException(400, "Cannot lock semesters: no existing schedule found.") | |
| solver = TimetableSolver(tasks, facs, secs, rooms) | |
| try: | |
| status, solution = solver.solve( | |
| time_limit_seconds=req.time_limit_seconds, | |
| enable_soft_constraints=True, | |
| scheduling_rules=data.get("scheduling_rules", []), | |
| locked_schedule=locked_schedule, | |
| locked_semesters=req.locked_semesters | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| if status not in ("OPTIMAL", "FEASIBLE"): | |
| diagnosis = solver.diagnose_infeasibility() | |
| raise HTTPException(400, f"Timetable generation failed. {diagnosis}") | |
| # Auto-save current schedule as a version before overwriting | |
| if schedule_exists(): | |
| save_version(label=req.version_label) | |
| save_schedule(solution) | |
| save_original_schedule(solution) | |
| clean = _clean(solution) | |
| return { | |
| "status": status, | |
| "task_count": len(tasks), | |
| "semesters_generated": req.semesters or "all", | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(500, traceback.format_exc()) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET / DELETE TIMETABLE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_schedule(): | |
| if not schedule_exists(): | |
| return {"exists": False, "schedule": None, "grid": None} | |
| data = load_schedule() | |
| clean = _clean(data["schedule"]) | |
| return { | |
| "exists": True, | |
| "generated_at": data.get("generated_at"), | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def get_original_schedule(): | |
| if not original_schedule_exists(): | |
| return {"exists": False, "grid": None} | |
| data = load_original_schedule() | |
| clean = _clean(data["schedule"]) | |
| return { | |
| "exists": True, | |
| "generated_at": data.get("generated_at"), | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def delete_schedule(version_label: Optional[str] = None): | |
| # Auto-save as version before clearing | |
| if schedule_exists(): | |
| save_version(label=version_label) | |
| clear_schedule() | |
| clear_original_schedule() | |
| clear_history() | |
| return {"message": "Schedule saved as version and cleared."} | |
| def revert_schedule(): | |
| if not original_schedule_exists(): | |
| raise HTTPException(400, "No original schedule found to revert to.") | |
| data = load_original_schedule() | |
| save_schedule(data["schedule"]) | |
| clear_history() | |
| clean = _clean(data["schedule"]) | |
| return { | |
| "status": "SUCCESS", | |
| "message": "Reverted to original schedule and cleared history.", | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEDULE VERSIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_versions(): | |
| versions = load_versions() | |
| # Return summary only (not full schedule data) for the list view | |
| return { | |
| "versions": [ | |
| { | |
| "id": v["id"], | |
| "label": v.get("label", f"Version {i+1}"), | |
| "timestamp": v.get("timestamp", ""), | |
| "generated_at": v.get("generated_at", ""), | |
| "history_count": len(v.get("history", [])), | |
| } | |
| for i, v in enumerate(versions) | |
| ] | |
| } | |
| def get_version_details(version_id: str): | |
| versions = load_versions() | |
| target = next((v for v in versions if v["id"] == version_id), None) | |
| if not target: | |
| raise HTTPException(404, "Version not found.") | |
| clean = _clean(target.get("schedule", {})) | |
| return { | |
| "status": "SUCCESS", | |
| "version_id": version_id, | |
| "label": target.get("label"), | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def restore_version_endpoint(version_id: str): | |
| # Auto-save current as a version before restoring | |
| if schedule_exists(): | |
| save_version() | |
| result = restore_version(version_id) | |
| if not result: | |
| raise HTTPException(404, "Version not found.") | |
| clean = _clean(result["schedule"]) | |
| return { | |
| "status": "SUCCESS", | |
| "message": f"Restored version: {result.get('label', version_id)}", | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def overwrite_schedule(req: OverwriteRequest): | |
| if not req.schedule: | |
| raise HTTPException(400, "Schedule payload cannot be empty.") | |
| old_raw = load_schedule() | |
| old_schedule = _clean(old_raw.get("schedule", {})) if old_raw else {} | |
| clean_new = _clean(req.schedule) | |
| changes, affected = diff_schedules(old_schedule, clean_new) | |
| save_schedule(clean_new) | |
| if changes: | |
| add_history_entry( | |
| operation_type="MANUAL_OVERWRITE", | |
| description=f"Manual drag & drop modifications ({len(changes)} cells affected)", | |
| affected_sections=affected, | |
| changes=changes, | |
| status="SUCCESS", | |
| constraints=[] | |
| ) | |
| return { | |
| "status": "SUCCESS", | |
| "message": "Schedule overwritten manually.", | |
| "schedule": clean_new, | |
| "grid": _build_grid(clean_new), | |
| **_timetable_constants(), | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEDULE PROPOSALS (SUPER TEACHER WORKFLOW) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_proposals(): | |
| from storage import load_proposals | |
| return {"proposals": load_proposals()} | |
| def propose_schedule(req: ProposeRequest): | |
| from storage import save_proposal | |
| import uuid as _uuid | |
| old_raw = load_schedule() | |
| old_schedule = _clean(old_raw.get("schedule", {})) if old_raw else {} | |
| clean_new = _clean(req.schedule) | |
| changes, affected = diff_schedules(old_schedule, clean_new) | |
| proposal = { | |
| "id": str(_uuid.uuid4()), | |
| "proposer": req.proposer, | |
| "proposer_name": req.proposer_name, | |
| "description": req.description, | |
| "timestamp": datetime.now().isoformat(), | |
| "changes_count": len(changes), | |
| "changes": changes, | |
| "schedule": clean_new, | |
| "status": "PENDING" | |
| } | |
| save_proposal(proposal) | |
| return {"status": "SUCCESS", "message": "Proposal submitted.", "proposal": proposal} | |
| def approve_proposal(proposal_id: str): | |
| from storage import load_proposals, delete_proposal | |
| proposals = load_proposals() | |
| target = next((p for p in proposals if p.get("id") == proposal_id), None) | |
| if not target: | |
| raise HTTPException(404, "Proposal not found.") | |
| clean_new = target["schedule"] | |
| save_schedule(clean_new) | |
| if target.get("changes"): | |
| add_history_entry( | |
| operation_type="PROPOSAL_APPROVED", | |
| description=f"Approved changes by {target.get('proposer_name')} ({len(target['changes'])} cells affected)", | |
| affected_sections=[], | |
| changes=target["changes"], | |
| status="SUCCESS", | |
| constraints=[] | |
| ) | |
| delete_proposal(proposal_id) | |
| return {"status": "SUCCESS", "message": "Proposal approved and applied."} | |
| def reject_proposal(proposal_id: str): | |
| from storage import delete_proposal | |
| delete_proposal(proposal_id) | |
| return {"status": "SUCCESS", "message": "Proposal rejected."} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # INJECT SUBJECT (ADD TO TIMETABLE) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def inject_subject(req: InjectRequest): | |
| """Insert one or more new class entries into the current schedule.""" | |
| if not schedule_exists(): | |
| raise HTTPException(400, "No timetable generated yet. Call POST /generate first.") | |
| if not req.entries: | |
| raise HTTPException(400, "No entries provided.") | |
| data = load_data() | |
| sched_data = load_schedule() | |
| current = sched_data["schedule"] | |
| old_schedule = _clean(dict(current)) # snapshot before mutation | |
| # Build a set of existing task IDs to avoid collisions | |
| existing_ids = set(current.keys()) | |
| # Build a lookup of faculty_name -> faculty for room assignment | |
| rooms = data.get("rooms", []) | |
| subjects_lookup = {s["code"].lower(): s for s in data.get("subjects", [])} | |
| injected_entries = [] | |
| for entry in req.entries: | |
| # Check for section collisions first | |
| for t_id, info in current.items(): | |
| if info.get("section_id") == entry.section_id and info.get("day_index") == entry.day_index: | |
| c_start = info.get("period_index", 0) | |
| c_dur = info.get("duration", 1) | |
| c_end = c_start + c_dur | |
| e_start = entry.period_index | |
| e_dur = entry.duration | |
| e_end = e_start + e_dur | |
| if max(c_start, e_start) < min(c_end, e_end): | |
| raise HTTPException(400, f"Section {entry.section_id} already has a class scheduled at {const.DAYS[entry.day_index]} Period {e_start + 1}.") | |
| # Determine room: use provided room_id, or auto-pick first available | |
| room_id = entry.room_id or "" | |
| room_name = "" | |
| if room_id: | |
| for r in rooms: | |
| if r["id"] == room_id: | |
| room_name = f"{r['id']} ({'Lab' if r.get('is_lab') else r.get('building', 'Main')})" | |
| break | |
| elif rooms: | |
| # Auto-assign: pick a room not occupied at this slot | |
| occupied_rooms = set() | |
| for info in current.values(): | |
| if info.get("day_index") == entry.day_index and info.get("period_index") == entry.period_index: | |
| occupied_rooms.add(info.get("room_id", "")) | |
| # For multi-period blocks, also check period_index + 1 | |
| dur = info.get("duration", 1) | |
| if dur > 1: | |
| for di in range(dur): | |
| if info.get("day_index") == entry.day_index and info.get("period_index") + di == entry.period_index: | |
| occupied_rooms.add(info.get("room_id", "")) | |
| # Prefer labs for lab subjects, regular rooms for theory | |
| sub_info = subjects_lookup.get(entry.subject_code.lower(), {}) | |
| is_lab_subject = sub_info.get("type", "THEORY").upper() == "LAB" | |
| for r in rooms: | |
| if r["id"] not in occupied_rooms: | |
| if is_lab_subject and r.get("is_lab"): | |
| room_id = r["id"] | |
| room_name = f"{r['id']} (Lab)" | |
| break | |
| elif not is_lab_subject and not r.get("is_lab"): | |
| room_id = r["id"] | |
| room_name = f"{r['id']} ({r.get('building', 'Main')})" | |
| break | |
| # Fallback: just pick the first available | |
| if not room_id: | |
| for r in rooms: | |
| if r["id"] not in occupied_rooms: | |
| room_id = r["id"] | |
| room_name = f"{r['id']} ({r.get('building', 'Main')})" | |
| break | |
| # Generate a unique task ID | |
| base_id = f"{entry.subject_code.lower()}-{entry.section_id.lower()}-INJECT" | |
| task_id = base_id | |
| counter = 0 | |
| while task_id in existing_ids: | |
| counter += 1 | |
| task_id = f"{base_id}-{counter}" | |
| existing_ids.add(task_id) | |
| # Compute start_slot for compatibility | |
| start_slot = entry.day_index * const.NUM_TEACHING_SLOTS_PER_DAY + entry.period_index | |
| schedule_entry = { | |
| "start_slot": start_slot, | |
| "day_index": entry.day_index, | |
| "day_name": const.DAYS[entry.day_index] if entry.day_index < len(const.DAYS) else f"Day{entry.day_index}", | |
| "period_index": entry.period_index, | |
| "room_id": room_id, | |
| "room_name": room_name, | |
| "faculty_name": entry.faculty_name, | |
| "subject_code": entry.subject_code.lower(), | |
| "section_id": entry.section_id, | |
| "duration": entry.duration, | |
| } | |
| current[task_id] = schedule_entry | |
| injected_entries.append({"task_id": task_id, **schedule_entry}) | |
| # Save updated schedule | |
| save_schedule(current) | |
| # Log history | |
| clean = _clean(current) | |
| changes, affected = diff_schedules(old_schedule, clean) | |
| if changes: | |
| add_history_entry( | |
| operation_type="INJECT_SUBJECT", | |
| description=f"Injected {len(injected_entries)} class(es) (e.g. {injected_entries[0].get('subject_code') if injected_entries else 'subject'})", | |
| affected_sections=affected, | |
| changes=changes, | |
| status="SUCCESS", | |
| constraints=[] | |
| ) | |
| return { | |
| "status": "SUCCESS", | |
| "message": f"Injected {len(injected_entries)} class(es) into the timetable.", | |
| "injected": injected_entries, | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def remove_class(req: RemoveRequest): | |
| """Remove a specific class from the timetable manually.""" | |
| if not schedule_exists(): | |
| raise HTTPException(400, "No timetable generated yet.") | |
| sched_data = load_schedule() | |
| current = sched_data["schedule"] | |
| old_schedule = _clean(dict(current)) # snapshot before mutation | |
| if req.task_id not in current: | |
| raise HTTPException(404, f"Class with ID {req.task_id} not found.") | |
| removed_entry = current.pop(req.task_id) | |
| # Save updated schedule | |
| save_schedule(current) | |
| clean = _clean(current) | |
| changes, affected = diff_schedules(old_schedule, clean) | |
| if changes: | |
| add_history_entry( | |
| operation_type="REMOVE_SUBJECT", | |
| description=f"Removed class: {removed_entry.get('subject_code')} from {removed_entry.get('section_id')}", | |
| affected_sections=affected, | |
| changes=changes, | |
| status="SUCCESS", | |
| constraints=[] | |
| ) | |
| clean = _clean(current) | |
| return { | |
| "status": "SUCCESS", | |
| "message": "Class removed successfully.", | |
| "removed": req.task_id, | |
| "schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| def get_free_teachers(day_index: int, period_index: int): | |
| """Return teachers who have NO class at the given (day, period) slot.""" | |
| if not schedule_exists(): | |
| raise HTTPException(400, "No timetable generated yet.") | |
| data = load_data() | |
| sched_data = load_schedule() | |
| current = sched_data["schedule"] | |
| faculties = data.get("faculties", []) | |
| # Find all faculty names busy at this slot | |
| busy_names = set() | |
| for info in current.values(): | |
| d = info.get("day_index") | |
| p = info.get("period_index") | |
| dur = info.get("duration", 1) | |
| if d == day_index: | |
| for i in range(dur): | |
| if p + i == period_index: | |
| busy_names.add(info.get("faculty_name", "").strip().lower()) | |
| # Return faculties NOT busy | |
| free = [] | |
| busy = [] | |
| for fac in faculties: | |
| fac_name = fac.get("name", "").strip() | |
| if fac_name.lower() in busy_names: | |
| busy.append({ | |
| "id": fac["id"], | |
| "name": fac_name, | |
| "designation": fac.get("designation", ""), | |
| "max_hours": fac.get("max_hours", 18), | |
| "status": "busy", | |
| }) | |
| else: | |
| free.append({ | |
| "id": fac["id"], | |
| "name": fac_name, | |
| "designation": fac.get("designation", ""), | |
| "max_hours": fac.get("max_hours", 18), | |
| "status": "free", | |
| }) | |
| return { | |
| "day_index": day_index, | |
| "period_index": period_index, | |
| "free_count": len(free), | |
| "busy_count": len(busy), | |
| "free_teachers": free, | |
| "busy_teachers": busy, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # UPDATE TIMETABLE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _PRIORITY_KEYWORDS = [ | |
| "replace", "substitute", "take over", "will take", "on leave", "cover", | |
| "permanently", "change faculty", "hand over", "assign all", | |
| "cancel", "no class", "holiday", "off day", | |
| "no toc", "no nlp", "no ml", "no cn", "no sepm", "no nosql", | |
| "move", "shift", "reschedule", "transfer", "relocate", | |
| "change room", "to lab", "to room", "assign room", | |
| "extra class", "makeup", "compensatory", "schedule extra", | |
| "swap", "exchange", "freeze", "lock slot", | |
| "should not be free", "must not be free", "cannot be free", | |
| "must have a class", "should have a class", "no free period", | |
| "first hour", "first period", "last period", "last hour", | |
| ] | |
| def update(req: UpdateRequest): | |
| if not schedule_exists(): | |
| raise HTTPException(400, "No timetable generated yet. Call POST /generate first.") | |
| prompt = req.prompt.strip() | |
| if not prompt: | |
| raise HTTPException(400, "Prompt cannot be empty.") | |
| try: | |
| data = load_data() | |
| constraints = [] | |
| use_local = any(kw in prompt.lower() for kw in _PRIORITY_KEYWORDS) | |
| if use_local: | |
| local = smart_parse(prompt, data["faculties"], | |
| data.get("subjects", []), data.get("sections", [])) | |
| if local: | |
| constraints = [local] | |
| if not constraints: | |
| result = get_constraint(prompt) | |
| if not result.get("success"): | |
| raise HTTPException(400, result.get("error", "Constraint parse failed.")) | |
| constraints = result.get("constraints", []) | |
| if not constraints: | |
| raise HTTPException(422, "Could not parse any constraint from the instruction.") | |
| if req.preview_only: | |
| return { | |
| "preview": True, | |
| "parsed_constraints": constraints, | |
| "constraint_type": constraints[0].get("type", "?"), | |
| } | |
| sched = load_schedule() | |
| current_solution = sched["schedule"] | |
| previous_schedule_clean = _clean(current_solution) | |
| facs, subs, secs, rooms, allocs = _build_objects(data) | |
| tasks = prepare_scheduling_tasks(allocs, facs, subs, secs) | |
| tasks_by_id = {t.task_id: t for t in tasks} | |
| for tid, info in current_solution.items(): | |
| if tid in tasks_by_id: | |
| info["task_obj"] = tasks_by_id[tid] | |
| final_solution = current_solution | |
| all_changes = [] | |
| for constraint in constraints: | |
| optimizer = PartialOptimizer(tasks, facs, secs, rooms, final_solution) | |
| op_status, new_solution, _, summary = \ | |
| optimizer.apply_constraint_and_reoptimize(constraint) | |
| if op_status in ("OPTIMAL", "FEASIBLE", "NO_CHANGE"): | |
| final_solution = new_solution | |
| all_changes.append(summary) | |
| else: | |
| all_changes.append(f"β οΈ {summary}") | |
| if req.propose_only: | |
| # Compute but don't save β return schedule for the frontend to propose | |
| clean = _clean(final_solution) | |
| return { | |
| "status": "PROPOSED", | |
| "parsed_constraints": constraints, | |
| "changes": all_changes, | |
| "schedule": clean, | |
| } | |
| save_schedule(final_solution) | |
| clean = _clean(final_solution) | |
| changes, affected = diff_schedules(previous_schedule_clean, clean) | |
| add_history_entry( | |
| operation_type="SLM_UPDATE", | |
| description=f"AI Update: {prompt}", | |
| affected_sections=affected, | |
| changes=changes, | |
| status="SUCCESS", | |
| constraints=constraints | |
| ) | |
| clean = _clean(final_solution) | |
| return { | |
| "status": "SUCCESS", | |
| "parsed_constraints": constraints, | |
| "constraint_type": constraints[0].get("type", "?"), | |
| "changes": all_changes, | |
| "previous_schedule": previous_schedule_clean, | |
| "updated_schedule": clean, | |
| "grid": _build_grid(clean), | |
| **_timetable_constants(), | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(500, traceback.format_exc()) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LEAVES & SUBSTITUTIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class LeaveRequestIn(BaseModel): | |
| faculty_id: str | |
| days: list[str] | |
| reason: str | |
| def get_leaves(): | |
| return {"leaves": load_leave_requests()} | |
| def create_leave(req: LeaveRequestIn): | |
| leaves = load_leave_requests() | |
| leave_id = str(uuid.uuid4()) | |
| new_leave = { | |
| "leave_id": leave_id, | |
| "faculty_id": req.faculty_id, | |
| "days": req.days, | |
| "reason": req.reason, | |
| "status": "PENDING" | |
| } | |
| leaves.append(new_leave) | |
| save_leave_requests(leaves) | |
| return {"message": "Leave request created", "leave": new_leave} | |
| def approve_leave(leave_id: str): | |
| leaves = load_leave_requests() | |
| target_leave: dict | None = next((l for l in leaves if l.get("leave_id") == leave_id), None) | |
| if not target_leave: | |
| raise HTTPException(404, "Leave request not found") | |
| if target_leave.get("status") != "PENDING": | |
| raise HTTPException(400, f"Leave is already {target_leave.get('status')}") | |
| target_leave["status"] = "APPROVED" | |
| save_leave_requests(leaves) | |
| # Convert dict to namedtuple or dataclass instance expected by engine | |
| from models import LeaveRequest as LRModel | |
| lr_obj = LRModel(**target_leave) | |
| # Trigger substitution finder | |
| process_leave_approval(lr_obj) | |
| return {"message": "Leave approved and substitution process started."} | |
| def reject_leave(leave_id: str): | |
| leaves = load_leave_requests() | |
| target = next((l for l in leaves if l["leave_id"] == leave_id), None) | |
| if not target: raise HTTPException(404, "Leave request not found") | |
| target["status"] = "REJECTED" | |
| save_leave_requests(leaves) | |
| return {"message": "Leave request rejected."} | |
| def get_pending_substitutions(faculty_id: str | None = None): | |
| check_timeouts() | |
| reqs = load_substitution_requests() | |
| if faculty_id: | |
| reqs = [r for r in reqs if r["candidate_faculty_id"] == faculty_id and r["status"] == "PENDING"] | |
| else: | |
| reqs = [r for r in reqs if r["status"] == "PENDING"] | |
| return {"substitutions": reqs} | |
| def accept_substitution(request_id: str): | |
| success, msg = handle_acceptance(request_id) | |
| if not success: | |
| raise HTTPException(400, msg) | |
| return {"message": msg} | |
| def decline_substitution(request_id: str): | |
| success, msg = handle_decline(request_id) | |
| if not success: | |
| raise HTTPException(400, msg) | |
| return {"message": msg} | |
| def get_unresolved_substitutions(): | |
| check_timeouts() | |
| # A slot is unresolved if all requests for it are DECLINED/TIMEOUT, and no ACCEPTED exists | |
| # Or if no requests were generated at all (handled separately or indicated by lack of requests) | |
| reqs = load_substitution_requests() | |
| leaves = load_leave_requests() | |
| unresolved_slots = [] | |
| # simplified logic: find slots where all reqs are not PENDING/ACCEPTED | |
| # A true implementation would group by leave_id + slot | |
| return {"unresolved": unresolved_slots, "message": "Not fully implemented for MVP"} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CANCELLATION REQUESTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CancellationRequestIn(BaseModel): | |
| section_id: str | |
| day: str | |
| period: int | |
| subject: str | |
| reason: str | |
| faculty_id: str | |
| def create_cancellation_request(req: CancellationRequestIn): | |
| cancellations = load_cancellations() | |
| cancel_id = str(uuid.uuid4()) | |
| new_cancel = { | |
| "id": cancel_id, | |
| "section_id": req.section_id, | |
| "day": req.day, | |
| "period": req.period, | |
| "subject": req.subject, | |
| "reason": req.reason, | |
| "faculty_id": req.faculty_id, | |
| "status": "PENDING", | |
| "created_at": datetime.now().isoformat() | |
| } | |
| cancellations.append(new_cancel) | |
| save_cancellations(cancellations) | |
| return {"message": "Cancellation request submitted", "cancellation": new_cancel} | |
| def get_cancellations(): | |
| return {"cancellations": load_cancellations()} | |
| def update_cancellation_status(cancel_id: str, payload: dict = Body(...)): | |
| status = payload.get("status") | |
| if not status: | |
| raise HTTPException(400, "Missing status") | |
| cancellations = load_cancellations() | |
| target = next((c for c in cancellations if c["id"] == cancel_id), None) | |
| if not target: | |
| raise HTTPException(404, "Cancellation not found") | |
| target["status"] = status | |
| save_cancellations(cancellations) | |
| return {"message": f"Cancellation marked as {status}", "cancellation": target} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CHANGE HISTORY | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_history(): | |
| history = load_history() | |
| return { | |
| "count": len(history), | |
| "history": list(reversed(history)), | |
| } | |
| def revert_history(history_id: str, force: bool = False): | |
| history = load_history() | |
| entry = next((e for e in history if e.get("id") == history_id), None) | |
| if not entry: | |
| raise HTTPException(404, "History entry not found.") | |
| sched_raw = load_schedule() or {} | |
| current_sched = sched_raw.get("schedule", {}) if isinstance(sched_raw, dict) else {} | |
| changes = entry.get("changes", []) | |
| if not force: | |
| # Check for conflicts | |
| conflicts = [] | |
| for c in changes: | |
| tid = c["task_id"] | |
| curr_val = _clean({tid: current_sched[tid]}).get(tid) if tid in current_sched else None | |
| after_val = c["after"] | |
| if curr_val != after_val: | |
| conflicts.append(tid) | |
| if conflicts: | |
| raise HTTPException(409, { | |
| "message": "Conflict detected: The timetable has been modified since this change was made. Reverting will overwrite those newer modifications. Do you want to force revert?", | |
| "conflicts": conflicts | |
| }) | |
| # Apply revert | |
| new_sched = dict(current_sched) | |
| for c in changes: | |
| tid = c["task_id"] | |
| before_val = c["before"] | |
| if before_val is None: | |
| if tid in new_sched: | |
| del new_sched[tid] | |
| else: | |
| new_sched[tid] = dict(before_val) | |
| clean_new = _clean(new_sched) | |
| save_schedule(clean_new) | |
| # Mark the original entry as REVERTED (keep it visible) | |
| entry["status"] = "REVERTED" | |
| save_history(history) | |
| # Log the revert itself as a new entry | |
| rev_changes, rev_affected = diff_schedules(_clean(current_sched), clean_new) | |
| if rev_changes: | |
| add_history_entry( | |
| operation_type="REVERT", | |
| description=f"Reverted: {entry.get('description', history_id)}", | |
| affected_sections=rev_affected, | |
| changes=rev_changes, | |
| status="SUCCESS" | |
| ) | |
| return { | |
| "status": "SUCCESS", | |
| "message": "Revert successful.", | |
| "schedule": clean_new, | |
| "grid": _build_grid(clean_new), | |
| **_timetable_constants(), | |
| } | |
| def delete_history(): | |
| clear_history() | |
| return {"message": "History cleared."} | |