Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, HTTPException | |
| 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 | |
| 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", "") | |
| ps = sec_id.split("-")[0].upper() | |
| day = info.get("day_index", 0) | |
| period = info.get("period_index", 0) | |
| dur = info.get("duration", 1) | |
| subject = info.get("subject_code", "?").upper() | |
| faculty = info.get("faculty_name", "") | |
| 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(), info.get("subject_code", "").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", "") | |
| 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 | |
| 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() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 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 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 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 clear_sections(): | |
| data = load_data() | |
| data["sections"] = [] | |
| save_data(data) | |
| return {"message": "All sections cleared."} | |
| 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 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 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 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)}.") | |
| 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.") | |
| solver = TimetableSolver(tasks, facs, secs, rooms) | |
| status, solution = solver.solve( | |
| time_limit_seconds=req.time_limit_seconds, | |
| enable_soft_constraints=True, | |
| scheduling_rules=data.get("scheduling_rules", []), | |
| ) | |
| if status not in ("OPTIMAL", "FEASIBLE"): | |
| raise HTTPException(400, f"Solver returned: {status}.") | |
| # 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), | |
| "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 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"} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 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."} | |