timetable_gen / api.py
KindAlien's picture
Update api.py
d2079e8 verified
Raw
History Blame Contribute Delete
50.4 kB
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/health")
def health():
"""API liveness check β€” also returns timetable constants for convenience."""
return {
"status": "ok",
"schedule_exists": schedule_exists(),
**_timetable_constants(),
}
@app.get("/slm/health")
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"}
@app.get("/constants")
def get_constants():
"""Timetable rendering constants (days, time headers, period indices)."""
return _timetable_constants()
# ═════════════════════════════════════════════════════════════════════════════
# ALL DATA
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data")
def get_all_data():
"""Return all stored academic data in one call."""
return load_data()
# ═════════════════════════════════════════════════════════════════════════════
# MANAGE FACULTIES
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data/faculties")
def list_faculties():
return {"faculties": load_data()["faculties"]}
@app.post("/data/faculties", status_code=201)
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()}
@app.delete("/data/faculties")
def clear_faculties():
data = load_data()
data["faculties"] = []
save_data(data)
return {"message": "All faculties cleared."}
@app.delete("/data/faculties/{faculty_id}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data/subjects")
def list_subjects():
return {"subjects": load_data()["subjects"]}
@app.post("/data/subjects", status_code=201)
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()}
@app.delete("/data/subjects")
def clear_subjects():
data = load_data()
data["subjects"] = []
save_data(data)
return {"message": "All subjects cleared."}
@app.delete("/data/subjects/{subject_code}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data/sections")
def list_sections():
return {"sections": load_data()["sections"]}
@app.post("/data/sections", status_code=201)
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()}
@app.delete("/data/sections")
def clear_sections():
data = load_data()
data["sections"] = []
save_data(data)
return {"message": "All sections cleared."}
@app.delete("/data/sections/{section_id}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data/rooms")
def list_rooms():
return {"rooms": load_data()["rooms"]}
@app.post("/data/rooms", status_code=201)
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()}
@app.delete("/data/rooms")
def clear_rooms():
data = load_data()
data["rooms"] = []
save_data(data)
return {"message": "All rooms cleared."}
@app.delete("/data/rooms/{room_id}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/data/allocations")
def list_allocations():
return {"allocations": load_data()["allocations"]}
@app.post("/data/allocations", status_code=201)
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()}
@app.delete("/data/allocations")
def clear_allocations():
data = load_data()
data["allocations"] = []
save_data(data)
return {"message": "All allocations cleared."}
@app.delete("/data/allocations/{idx}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/scheduling-rules")
def get_scheduling_rules():
data = load_data()
return {"rules": data.get("scheduling_rules", [])}
@app.post("/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}
@app.delete("/scheduling-rules/{rule_id}")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.post("/generate")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/schedule")
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(),
}
@app.get("/schedule/original")
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(),
}
@app.delete("/schedule")
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."}
@app.post("/schedule/revert")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/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)
]
}
@app.post("/schedule/versions/restore/{version_id}")
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(),
}
@app.post("/schedule/overwrite")
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)
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/schedule/proposals")
def get_proposals():
from storage import load_proposals
return {"proposals": load_proposals()}
@app.post("/schedule/propose")
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}
@app.post("/schedule/proposals/{proposal_id}/approve")
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."}
@app.delete("/schedule/proposals/{proposal_id}")
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)
# ═════════════════════════════════════════════════════════════════════════════
@app.post("/schedule/inject")
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(),
}
@app.post("/schedule/remove")
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(),
}
@app.get("/schedule/free-teachers")
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",
]
@app.post("/update")
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
@app.get("/leave")
def get_leaves():
return {"leaves": load_leave_requests()}
@app.post("/leave", status_code=201)
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}
@app.post("/leave/approve/{leave_id}")
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."}
@app.post("/leave/reject/{leave_id}")
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."}
@app.get("/substitution")
@app.get("/substitution/pending")
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}
@app.post("/substitution/{request_id}/accept")
def accept_substitution(request_id: str):
success, msg = handle_acceptance(request_id)
if not success:
raise HTTPException(400, msg)
return {"message": msg}
@app.post("/substitution/{request_id}/decline")
def decline_substitution(request_id: str):
success, msg = handle_decline(request_id)
if not success:
raise HTTPException(400, msg)
return {"message": msg}
@app.get("/substitution/unresolved")
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
# ═════════════════════════════════════════════════════════════════════════════
@app.get("/history")
def get_history():
history = load_history()
return {
"count": len(history),
"history": list(reversed(history)),
}
@app.post("/history/revert/{history_id}")
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(),
}
@app.delete("/history")
def delete_history():
clear_history()
return {"message": "History cleared."}