diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..9163446 --- /dev/null +++ b/.bandit @@ -0,0 +1,2 @@ +[bandit] +skips = B311 diff --git a/backend/app/routes/curriculum.py b/backend/app/routes/curriculum.py index 8965881..848f966 100644 --- a/backend/app/routes/curriculum.py +++ b/backend/app/routes/curriculum.py @@ -474,9 +474,15 @@ def get_batch_current_courses(batch_id): if not batch: return jsonify({"error": "Batch not found"}), 404 - # Get courses matching batch's program ID and current semester + # 1. Try finding courses explicitly linked to this program courses = Course.query.filter_by(program_id=batch.program_id, semester=batch.current_semester).all() + # 2. Fallback: If no courses linked to program, look for courses in the same department + # that aren't linked to ANY program (department-wide courses) + if not courses and batch.program: + dept_id = batch.program.department_id + courses = Course.query.filter_by(department_id=dept_id, semester=batch.current_semester, program_id=None).all() + courses_data = [{"id": c.id, "code": c.code, "name": c.name, "type": c.course_type} for c in courses] return ( diff --git a/backend/app/routes/pdf_export.py b/backend/app/routes/pdf_export.py index 0fc22f6..27ea0a0 100644 --- a/backend/app/routes/pdf_export.py +++ b/backend/app/routes/pdf_export.py @@ -14,20 +14,6 @@ limitations under the License. """ -""" -PDF Timetable Export - Generates printable timetables matching manual format. - -Produces landscape A4 PDFs with: -- Department header with semester/season info -- Multi-batch grid (days as columns, time periods as rows) -- Cell format: FULL_COURSE_NAME (T/P) - (FACULTY_ABBR) [word-wrapped] -- LUNCH BREAK row -- Course Details legend table: Code -> Full Name -> Type -- Faculty Details legend table: Abbreviation -> Full Name -- Signature line -- Color-coded batch rows -""" - from flask import Blueprint, request, send_file from flask_jwt_extended import jwt_required from io import BytesIO @@ -114,7 +100,7 @@ def _register_fonts(): registered[font_key] = font_key found = True break - except Exception: + except (OSError, IOError, ValueError): # nosec B110 - Font loading failure is non-critical pass if not found: @@ -678,21 +664,21 @@ def _render_pdf(data, semester_label=""): elements.append(Paragraph(f"Department of {dept.name}", styles["subtitle"])) # ── Table Construction (Image-matching structure) ──────────────────── - # Columns: [BATCH, SLOT1, SLOT2, ...] header_row = [Paragraph("BATCH / TIME", styles["day_header"])] + period_count = 0 for slot in all_slots: time_str = f"{slot['start']}-{slot['end']}" label = slot["label"] if slot["is_break"]: - # For breaks, just show the label (e.g. LUNCH) and time - display_label = label.upper() - slot_label = f"{display_label}
{time_str}" + display_label = label.upper() if label.upper() != "BREAK" else "LUNCH" + slot_label = f"{display_label} {time_str}" else: - # For periods, show "SLOT X" and time - # Convert "Period 1" to "SLOT 1" if needed + period_count += 1 display_label = label.upper().replace("PERIOD", "SLOT") - slot_label = f"{display_label}
{time_str}" + if "SLOT" not in display_label: + display_label = f"SLOT {period_count}" + slot_label = f"{display_label} {time_str}" header_row.append(Paragraph(slot_label, styles["day_header"])) @@ -785,29 +771,24 @@ def _render_pdf(data, semester_label=""): continue entry = entries_list[0] - # Highlighting logic for PDF export - if scheduled in a break, use a red tint is_illegal = is_break course_name = entry["course"].name if entry["course"] else "Course" - teacher_abbr = ( - entry["teacher"].abbreviation or _auto_abbreviation(entry["teacher"].name) - if entry["teacher"] - else "?" - ) + teacher_name = entry["teacher"].name if entry["teacher"] else "?" + room_name = entry["room"].name if entry["room"] else "?" ctype = "P" if entry["is_lab"] else "T" - cell_txt = f"{course_name} ({ctype})
({teacher_abbr})" + # Rule: Subject Name (T/P) (Faculty) Room/Lab + if ctype == "P": + lab_suffix = " Lab" if "lab" not in course_name.lower() else "" + cell_txt = f"{course_name}{lab_suffix} ({ctype})
({teacher_name})
{room_name}" + else: + cell_txt = f"{course_name} ({ctype})
({teacher_name})
{room_name}" if is_illegal: # Explicitly show that this is a break conflict l_txt = slot_info["label"].upper() if slot_info["label"].upper() != "BREAK" else "LUNCH" cell_txt = f"{l_txt} CONFLICT
{cell_txt}" - # Rule: Lab room always shows in cell. Theory room only shows if it differs from batch header room. - if entry["is_lab"] and entry["room"]: - cell_txt += f"
{entry['room'].name}" - elif entry["room"] and entry["room"].name != day_theory_room: - cell_txt += f"
{entry['room'].name}" - row.append(Paragraph(cell_txt, styles["cell"])) # Color coding background @@ -976,15 +957,20 @@ def _build_department_table(data, page_w, normal_font="Helvetica", bold_font="He # Header Row header_row = [Paragraph("BATCH", styles["day_header"])] + period_count = 0 for slot in all_slots: time_str = f"{slot['start']}-{slot['end']}" label = slot["label"] if slot["is_break"]: - display_label = label.upper() + display_label = label.upper() if label.upper() != "BREAK" else "LUNCH" + slot_label = f"{display_label} {time_str}" else: + period_count += 1 display_label = label.upper().replace("PERIOD", "SLOT") + if "SLOT" not in display_label: + display_label = f"SLOT {period_count}" + slot_label = f"{display_label} {time_str}" - slot_label = f"{display_label}
{time_str}" header_row.append(Paragraph(slot_label, styles["day_header"])) table_data = [header_row] @@ -1069,26 +1055,22 @@ def _build_department_table(data, page_w, normal_font="Helvetica", bold_font="He entry = entries[0] is_illegal = is_break course_name = entry["course"].name if entry["course"] else "Course" - teacher_abbr = ( - entry["teacher"].abbreviation or _auto_abbreviation(entry["teacher"].name) - if entry["teacher"] - else "?" - ) + teacher_name = entry["teacher"].name if entry["teacher"] else "?" + room_name = entry["room"].name if entry["room"] else "?" ctype = "P" if entry["is_lab"] else "T" - cell_txt = f"{course_name} ({ctype})
({teacher_abbr})" + # Rule: Subject Name (T/P) (Faculty) Room/Lab + if ctype == "P": + lab_suffix = " Lab" if "lab" not in course_name.lower() else "" + cell_txt = f"{course_name}{lab_suffix} ({ctype})
({teacher_name})
{room_name}" + else: + cell_txt = f"{course_name} ({ctype})
({teacher_name})
{room_name}" if is_illegal: # Explicitly show that this is a break conflict l_txt = slot_info["label"].upper() if slot_info["label"].upper() != "BREAK" else "LUNCH" cell_txt = f"{l_txt} CONFLICT
{cell_txt}" - # Rule: Lab room always shows in cell. Theory room only shows if it differs from batch header room. - if entry["is_lab"] and entry["room"]: - cell_txt += f"
{entry['room'].name}" - elif entry["room"] and entry["room"].name != day_theory_room: - cell_txt += f"
{entry['room'].name}" - row.append(Paragraph(cell_txt, styles["cell"])) if is_illegal: diff --git a/backend/app/routes/resources.py b/backend/app/routes/resources.py index 951f0ee..3b1308c 100644 --- a/backend/app/routes/resources.py +++ b/backend/app/routes/resources.py @@ -232,6 +232,7 @@ def get_batches(): "academic_year": b.academic_year, "program_id": b.program_id, "program_code": b.program.code if b.program else None, + "current_semester": b.current_semester, "section_count": len(b.sections), } for b in result.items @@ -254,6 +255,7 @@ def get_batch(batch_id): "academic_year": b.academic_year, "program_id": b.program_id, "program_code": b.program.code if b.program else None, + "current_semester": b.current_semester, "section_count": len(b.sections), } ), @@ -291,6 +293,7 @@ def add_batch(): code=data["code"].strip(), academic_year=data["academic_year"].strip(), program_id=data["program_id"], + current_semester=data.get("current_semester", 1), ) db.session.add(b) db.session.commit() @@ -318,6 +321,8 @@ def update_batch(batch_id): if not db.session.get(Program, data["program_id"]): return jsonify({"error": "Program not found"}), 404 b.program_id = data["program_id"] + if "current_semester" in data: + b.current_semester = data["current_semester"] db.session.commit() return jsonify({"message": "Batch updated", "id": b.id}), 200 @@ -1090,8 +1095,6 @@ def bulk_import_batches(): return jsonify({"error": "No file uploaded"}), 400 try: - from datetime import datetime - df = pd.read_excel(BytesIO(file.read())) success = 0 errors = [] @@ -1114,19 +1117,14 @@ def bulk_import_batches(): if not prog: raise Exception(f"Program not found with code='{program_code}'") - # Compute current_semester from Year column if available - current_semester = None - year_val = row.get("Year") + # Use CurrentSemester from Excel if provided, otherwise default to 1 + current_semester = 1 sem_val = row.get("CurrentSemester") if pd.notna(sem_val): - current_semester = int(sem_val) - elif pd.notna(year_val): - now = datetime.utcnow() - start_year = int(year_val) - start_half = 1 - current_year_half = 0 if now.month <= 6 else 1 - elapsed_halves = ((now.year - start_year) * 2) + (current_year_half - start_half) - current_semester = max(1, min(8, elapsed_halves + 1)) + try: + current_semester = int(sem_val) + except (ValueError, TypeError): + pass batch = Batch( name=str(name).strip(), diff --git a/backend/app/routes/workload.py b/backend/app/routes/workload.py index cb8704f..3b6c63c 100644 --- a/backend/app/routes/workload.py +++ b/backend/app/routes/workload.py @@ -16,8 +16,10 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required +from io import BytesIO +import pandas as pd from .. import db -from ..models import WorkloadAllocation, Section, Course, Teacher +from ..models import WorkloadAllocation, Section, Course, Teacher, Batch from .auth import roles_required workload_bp = Blueprint("workload", __name__) @@ -41,8 +43,15 @@ def get_section_workload(section_id): courses = [] if program_id and semester: + # 1. Try finding courses explicitly linked to this program courses = Course.query.filter_by(program_id=program_id, semester=semester).all() + # 2. Fallback: If no courses linked to program, look for courses in the same department + # that aren't linked to ANY program (department-wide courses) + if not courses and section.batch and section.batch.program: + dept_id = section.batch.program.department_id + courses = Course.query.filter_by(department_id=dept_id, semester=semester, program_id=None).all() + # 2. Get existing workload assignments assignments = WorkloadAllocation.query.filter_by(section_id=section_id).all() assigned_map = {a.course_id: a for a in assignments} @@ -73,6 +82,7 @@ def get_section_workload(section_id): "section_id": section_id, "section_name": section.name, "batch_name": section.batch.name if section.batch else "Unknown", + "current_semester": section.batch.current_semester if section.batch else None, "courses": result, } ), @@ -148,11 +158,6 @@ def unassign_workload(): return jsonify({"message": "No assignment found to remove"}), 200 -import pandas as pd -from io import BytesIO -from ..models import Batch - - @workload_bp.route("/import", methods=["POST"]) @roles_required("admin") def bulk_import_workload(): @@ -236,8 +241,16 @@ def auto_assign_all_workload(): program_id = section.batch.program_id semester = section.batch.current_semester + + # 1. Try finding courses explicitly linked to this program courses = Course.query.filter_by(program_id=program_id, semester=semester).all() + # 2. Fallback: If no courses linked to program, look for courses in the same department + # that aren't linked to ANY program (department-wide courses) + if not courses and section.batch and section.batch.program: + dept_id = section.batch.program.department_id + courses = Course.query.filter_by(department_id=dept_id, semester=semester, program_id=None).all() + for course in courses: # Check if already assigned existing = WorkloadAllocation.query.filter_by(section_id=section.id, course_id=course.id).first() @@ -274,9 +287,15 @@ def rebalance_all_workload(): program_id = section.batch.program_id semester = section.batch.current_semester - # Get courses for this batch's program and semester + # 1. Try finding courses explicitly linked to this program courses = Course.query.filter_by(program_id=program_id, semester=semester).all() + # 2. Fallback: If no courses linked to program, look for courses in the same department + # that aren't linked to ANY program (department-wide courses) + if not courses and section.batch and section.batch.program: + dept_id = section.batch.program.department_id + courses = Course.query.filter_by(department_id=dept_id, semester=semester, program_id=None).all() + for course in courses: qualified = course.qualified_teachers.all() if qualified: @@ -288,3 +307,40 @@ def rebalance_all_workload(): db.session.commit() return jsonify({"message": f"Successfully rebalanced {success_count} assignments for all batches"}), 200 + + +@workload_bp.route("/summary", methods=["GET"]) +@jwt_required() +def get_workload_summary(): + """Get total workload (course counts/hours) for all teachers.""" + teachers = Teacher.query.all() + summary = [] + for t in teachers: + allocations = WorkloadAllocation.query.filter_by(teacher_id=t.id).all() + # Count courses + course_count = len(allocations) + # Calculate total hours (L+T+P) + total_hours = sum(a.course.get_hours_needed() for a in allocations if a.course) + + summary.append( + { + "teacher_id": t.id, + "teacher_name": t.name, + "teacher_email": t.email, + "course_count": course_count, + "total_hours": total_hours, + "departments": [d.name for d in t.departments], + "assignments": [ + { + "id": a.id, + "course_code": a.course.code, + "course_name": a.course.name, + "section_name": a.section.name, + "batch_name": a.section.batch.name if a.section.batch else "Unknown", + } + for a in allocations + if a.course and a.section + ], + } + ) + return jsonify(summary), 200 diff --git a/backend/app/scheduler_new/api.py b/backend/app/scheduler_new/api.py index b059e23..88d690b 100644 --- a/backend/app/scheduler_new/api.py +++ b/backend/app/scheduler_new/api.py @@ -14,9 +14,7 @@ limitations under the License. """ -""" -Flask API integration for the scheduling engine -""" +# Flask API integration for the scheduling engine from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required diff --git a/backend/app/scheduler_new/constraint_engine.py b/backend/app/scheduler_new/constraint_engine.py index 428295b..de9295f 100644 --- a/backend/app/scheduler_new/constraint_engine.py +++ b/backend/app/scheduler_new/constraint_engine.py @@ -14,9 +14,7 @@ limitations under the License. """ -""" -ConstraintEngine - Validates all scheduling constraints with O(1) lookups -""" +# ConstraintEngine - Validates all scheduling constraints with O(1) lookups from typing import List, Set, Dict, Tuple, Optional from collections import defaultdict diff --git a/backend/app/scheduler_new/data_loader.py b/backend/app/scheduler_new/data_loader.py index e320e98..6a68032 100644 --- a/backend/app/scheduler_new/data_loader.py +++ b/backend/app/scheduler_new/data_loader.py @@ -14,10 +14,6 @@ limitations under the License. """ -""" -DataLoader - Loads scheduling data from SQLAlchemy models -""" - import re from typing import List, Optional, Dict, Set, Tuple, TYPE_CHECKING from collections import defaultdict @@ -91,10 +87,11 @@ def _resolve_course_semester(course: Course) -> Optional[int]: if course.semester is not None: return course.semester - if not course.semester_name: + sem_name = getattr(course, "semester_name", None) + if not sem_name: return None - digits = re.findall(r"\d+", str(course.semester_name)) + digits = re.findall(r"\d+", str(sem_name)) if digits: return int(digits[0]) @@ -108,7 +105,7 @@ def _resolve_course_semester(course: Course) -> Optional[int]: "VII": 7, "VIII": 8, } - sem_upper = str(course.semester_name).strip().upper().replace("SEMESTER", "").strip() + sem_upper = str(sem_name).strip().upper().replace("SEMESTER", "").strip() return roman_map.get(sem_upper) @staticmethod @@ -128,28 +125,6 @@ def _resolve_course_hours(cls, course: Course) -> int: # Fallback if model doesn't have the method yet return 2 if (course.course_type or "").lower() == "lab" else 3 - @staticmethod - def _deduplicate_sections(sections: List[Section]) -> List[Section]: - """ - Collapse exact duplicate section rows caused by repeated imports. - - The live dataset currently contains multiple rows with the same - `(batch_id, name)` business identity, which makes the scheduler solve - the same academic section multiple times and inflates unscheduled - workload counts. Keep the lowest-id record as the canonical section. - """ - deduplicated = [] - seen_keys = set() - - for section in sorted(sections, key=lambda item: item.id): - key = (section.batch_id, (section.name or "").strip().upper()) - if key in seen_keys: - continue - seen_keys.add(key) - deduplicated.append(section) - - return deduplicated - def load_faculty(self, course_ids: Optional[Set[int]] = None) -> List[Faculty]: """Load all faculty from database""" teachers = Teacher.query.all() @@ -274,6 +249,7 @@ def load_courses(self, section_program_map: Dict[int, str]) -> List[SchedulerCou program_code=course.program_code, program_id=getattr(course, "program_id", None), department_id=course.department_id, + semester=self._resolve_course_semester(course), ) ) @@ -287,7 +263,9 @@ def load_workloads(self, section_ids: List[int]) -> Dict[Tuple[int, int], int]: allocations = WorkloadAllocation.query.filter(WorkloadAllocation.section_id.in_(section_ids)).all() return {(a.section_id, a.course_id): a.teacher_id for a in allocations} - def load_sections(self) -> Tuple[List[SchedulerSection], Dict[Tuple[int, int], int]]: + def load_sections( + self, course_map: Dict[int, SchedulerCourse] + ) -> Tuple[List[SchedulerSection], Dict[Tuple[int, int], int]]: """Load sections from database and their assigned workloads""" query = Section.query.options(joinedload(Section.batch).joinedload(Batch.program)) @@ -301,25 +279,35 @@ def load_sections(self) -> Tuple[List[SchedulerSection], Dict[Tuple[int, int], i section_ids = [s.id for s in sections] workload_map = self.load_workloads(section_ids) - # Group workload by section for quick lookup - workload_by_section = defaultdict(list) - for (sec_id, crs_id), teacher_id in workload_map.items(): - workload_by_section[sec_id].append(crs_id) - section_list = [] + for section in sections: - program = section.batch.program if section.batch else None - # If no program metadata, we still process the section if it has workloads - program_code = program.code if program else "UNK" - program_id = program.id if program else None + batch = section.batch + if not batch: + continue - # The heart of the change: We ONLY schedule courses that have a workload entry. - course_ids = list(set(workload_by_section.get(section.id, []))) + # Use current_semester from Batch model directly (manual control) + active_sem = batch.current_semester or 1 + + # ── 2. FILTER WORKLOAD TO ACTIVE CURRICULUM ────────────────── + valid_course_ids = [] + for (sec_id, crs_id), _ in workload_map.items(): + if sec_id != section.id: + continue + course = course_map.get(crs_id) + if course and self._resolve_course_semester(course) == active_sem: + valid_course_ids.append(crs_id) + + course_ids = list(set(valid_course_ids)) if not course_ids: - # If no courses are assigned to this section, skip it from scheduling + # If no courses are assigned for the current semester, skip it continue + program = batch.program if batch else None + program_code = program.code if program else "UNK" + program_id = program.id if program else None + section_list.append( SchedulerSection( id=section.id, @@ -329,8 +317,8 @@ def load_sections(self) -> Tuple[List[SchedulerSection], Dict[Tuple[int, int], i program_code=program_code, program_id=program_id, department_id=program.department_id if program else None, - current_semester=section.batch.current_semester if section.batch else None, - batch_code=section.batch.code if section.batch else None, + current_semester=active_sem, # Use the calculated semester + batch_code=batch.code if batch else None, course_ids=course_ids, ) ) @@ -349,7 +337,7 @@ def load_timeslots(self) -> List[Timeslot]: b_end = b.get("end") if b_start and b_end: breaks_list.append((b_start, b_end)) - except Exception: + except (AttributeError, TypeError): # nosec B112 - Malformed break data should be skipped continue def is_break(start, end): @@ -396,31 +384,68 @@ def is_break(start, end): return timeslots + def _deduplicate_sections(self, db_sections: List[Section]) -> List[Section]: + """Ensure each section ID is unique in the list.""" + seen = set() + deduped = [] + for s in db_sections: + if s.id not in seen: + deduped.append(s) + seen.add(s.id) + return deduped + def load_problem(self) -> "SchedulingProblem": """Load complete scheduling problem""" from .models import SchedulingProblem # Keep local import for runtime to avoid circularity - sections, workload_map = self.load_sections() + # ── 1. PRE-LOAD ALL COURSES ─────────────────────────────────── + # We need course metadata early to filter sections by semester. + all_courses = self.load_courses({}) # Load all department courses + course_map = {c.id: c for c in all_courses} - # Build program map for course loading - section_program_map = {} - for section in sections: - section_program_map[section.id] = section.program_code + # ── 2. LOAD SECTIONS WITH SEMESTER FILTERING ────────────────── + sections, workload_map = self.load_sections(course_map) + + # ── 3. FILTER COURSES TO ACTIVE ONES ───────────────────────── + # Only keep courses that are actually used in the filtered sections. + active_course_ids = set() + for s in sections: + active_course_ids.update(s.course_ids) - courses = self.load_courses(section_program_map) + active_courses = [c for c in all_courses if c.id in active_course_ids] - # Get all course IDs for faculty filtering - all_course_ids = {c.id for c in courses} - faculty = self.load_faculty(course_ids=all_course_ids) + # Get all active course IDs for faculty filtering + faculty = self.load_faculty(course_ids=active_course_ids) rooms = self.load_rooms() timeslots = self.load_timeslots() + # ── 4. CURRICULUM GAP DETECTION ────────────────────────────── + unassigned_curriculum = [] + active_section_semesters = set() + for s in sections: + if s.program_id and s.current_semester: + active_section_semesters.add((s.program_id, s.current_semester)) + + for c in all_courses: + if c.id not in active_course_ids: + if (c.program_id, self._resolve_course_semester(c)) in active_section_semesters: + unassigned_curriculum.append( + { + "id": c.id, + "code": c.code, + "name": c.name, + "type": c.course_type, + "reason": "Missing from Workload Page", + } + ) + return SchedulingProblem( sections=sections, - courses=courses, + courses=active_courses, faculty=faculty, rooms=rooms, timeslots=timeslots, workload_map=workload_map, + unassigned_curriculum=unassigned_curriculum, ) diff --git a/backend/app/scheduler_new/greedy_engine.py b/backend/app/scheduler_new/greedy_engine.py index cfa5afa..c7ddb6b 100644 --- a/backend/app/scheduler_new/greedy_engine.py +++ b/backend/app/scheduler_new/greedy_engine.py @@ -14,10 +14,9 @@ limitations under the License. """ -""" -Greedy Timetable Scheduler - Fast heuristic approach -Uses constraint-based greedy assignment with backtracking -""" +# Greedy Timetable Scheduler - Fast heuristic approach +# Uses constraint-based greedy assignment with backtracking + from collections import defaultdict from typing import List, Tuple, Optional, Callable, Set, Dict @@ -58,7 +57,7 @@ def solve(self, progress_callback: Optional[Callable[[int, str], None]] = None) for h in range(1, 8): for i in range(num_timeslots): if i + h <= num_timeslots: - consecutive_slots = timeslot_list[i : i + h] + consecutive_slots = timeslot_list[i : i + h] # noqa: E203 valid = True for j in range(h - 1): if ( diff --git a/backend/app/scheduler_new/hybrid_engine.py b/backend/app/scheduler_new/hybrid_engine.py index 0e3d060..716f689 100644 --- a/backend/app/scheduler_new/hybrid_engine.py +++ b/backend/app/scheduler_new/hybrid_engine.py @@ -14,12 +14,6 @@ limitations under the License. """ -""" -Hybrid Timetable Scheduler - Multi-pass with local search optimization -Pass 1: Greedy assignment -Pass 2: Local search to resolve conflicts -""" - from collections import defaultdict from typing import Callable, Dict, List, Optional, Tuple @@ -38,16 +32,17 @@ def __init__(self, problem: SchedulingProblem, max_iterations: int = 1000, debug self._timeslot_index: Dict[Tuple[str, str], int] = {} def _get_faculty_candidates(self, section, course): - """Cache valid faculty per section-course pair (uses explicit workload assignment).""" + """Cache valid faculty per section-course pair (ABSOLUTE SOURCE OF TRUTH).""" cache_key = (section.id, course.id) if cache_key not in self._faculty_candidates: - # Use explicit workload assignment + # Use explicit workload assignment from the workload map (GROUND TRUTH) assigned_id = self.problem.workload_map.get(cache_key) if assigned_id: candidates = [f for f in self.problem.faculty if f.id == assigned_id] else: - # Fallback: any available faculty - candidates = list(self.problem.faculty) + # If no workload is assigned, we should NOT auto-assign random faculty + # as per user requirement: "DO NOT auto-assign or override" + candidates = [] self._faculty_candidates[cache_key] = candidates return self._faculty_candidates[cache_key] @@ -65,59 +60,78 @@ def _get_room_candidates(self, section, course): room for room in self.problem.rooms if room.can_accommodate(section.student_count) - and room.can_be_used_by_program(None, section_dept_id) + and room.can_be_used_by_program(section.program_id, section_dept_id) and room.is_suitable_for(course.course_type) ] - # C2: Pass 2: Fallback for Labs/Moot if no specialized rooms are available + # Rule: If course requires specific lab type, assign only from eligible pool + if course_type_lower == "lab": + keywords = ["physics", "chemistry", "biology", "computer", "pharmacy", "mechanical", "electrical", "civil"] + course_name_lower = course.name.lower() + matched_keywords = [k for k in keywords if k in course_name_lower] + + if matched_keywords: + # Filter candidates that match any of these keywords in their room name/type + specialized = [ + r + for r in candidates + if any(k in (r.name or "").lower() or k in (r.room_type or "").lower() for k in matched_keywords) + ] + if specialized: + candidates = specialized + + # Pass 2: Fallback for specialized types if no specialized rooms fit if not candidates and course_type_lower in ["lab", "moot court", "moot"]: - if self.debug: - print(f"DEBUG: No specialized rooms for {course.course_type}. Falling back to general rooms.") candidates = [ room for room in self.problem.rooms - if room.can_accommodate(section.student_count) and room.can_be_used_by_program(None, section_dept_id) - # Allow fallback to regular classrooms for labs if no labs fit + if room.can_accommodate(section.student_count) + and room.can_be_used_by_program(None, section_dept_id) + and not ("lab" in (room.room_type or "").lower() if course_type_lower != "lab" else False) ] self._room_candidates[cache_key] = candidates return candidates def _new_schedule_state(self): - """Keep occupancy in direct lookup sets so conflict checks stay O(1). - - Pre-populates room_slots from cross-department TimetableEntry records - to prevent double-booking shared rooms across departments. - """ + """Keep occupancy in direct lookup sets so conflict checks stay O(1).""" state = { "section_hours": defaultdict(set), - "batch_hours": defaultdict(set), # C6 + "batch_hours": defaultdict(set), "faculty_hours": defaultdict(int), "faculty_daily_hours": defaultdict(lambda: defaultdict(int)), "faculty_slots": defaultdict(set), "room_slots": defaultdict(set), "section_course_days": defaultdict(set), + "batch_lab_days": defaultdict(set), # Rule: Max 1 lab per day per batch "faculty_program_day_sessions": defaultdict(int), "section_course_faculty": {}, } # ── Pre-populate room_slots from cross-department bookings ────── - # Rooms carry `global_busy_slots` (set by DataLoader) that map - # day -> set of timeslot labels already taken by other departments. - # We convert these labels into timeslot indices so the engine's - # O(1) conflict check works automatically. for room in self.problem.rooms: global_busy = getattr(room, "global_busy_slots", None) if not global_busy: continue for day, busy_labels in global_busy.items(): for label in busy_labels: - # label is "HH:MM-HH:MM" — extract start time as slot key start = label.split("-")[0] if "-" in label else label slot_idx = self._timeslot_index.get((day, start)) if slot_idx is not None: state["room_slots"][room.id].add(slot_idx) + # ── Pre-populate faculty_slots from cross-department bookings ────── + for faculty in self.problem.faculty: + global_busy = getattr(faculty, "global_busy_slots", None) + if not global_busy: + continue + for day, busy_labels in global_busy.items(): + for label in busy_labels: + start = label.split("-")[0] if "-" in label else label + slot_idx = self._timeslot_index.get((day, start)) + if slot_idx is not None: + state["faculty_slots"][faculty.id].add(slot_idx) + return state @staticmethod @@ -127,13 +141,15 @@ def _occupy_assignment( """Apply one accepted assignment block to all trackers.""" for slot_idx in slots_needed: state["section_hours"][section_id].add(slot_idx) - state["batch_hours"][batch_id].add(slot_idx) # C6 + state["batch_hours"][batch_id].add(slot_idx) state["faculty_slots"][faculty_id].add(slot_idx) state["room_slots"][room_id].add(slot_idx) state["faculty_hours"][faculty_id] += hours state["faculty_daily_hours"][faculty_id][day] += hours - if not is_lab: + if is_lab: + state["batch_lab_days"][batch_id].add(day) + else: state["section_course_days"][(section_id, course_id)].add(day) state["faculty_program_day_sessions"][(faculty_id, program_code, day)] += 1 @@ -165,6 +181,40 @@ def solve(self, progress_callback: Optional[Callable[[int, str], None]] = None) valid_starts = self._compute_valid_starts(timeslot_list) classes = self.problem.get_section_courses() + # ── CAPACITY & WORKLOAD VALIDATION (HARD RULE) ────────────────── + # Check if any section is overloaded BEFORE starting the solver. + weekly_capacity = len(timeslot_list) + section_demand = defaultdict(int) + for section_id, course_id, hours in classes: + section_demand[section_id] += hours + + overloaded_reports = [] + for sid, demand in section_demand.items(): + if demand > weekly_capacity: + section = self.problem.section_map.get(sid) + sname = section.get_full_name() if section else f"Section ID {sid}" + overloaded_reports.append(f"{sname}: {demand} weekly hours requested (Max {weekly_capacity} capacity)") + + if overloaded_reports: + error_msg = ( + "INVALID WORKLOAD: Scheduling halted. The following sections exceed " + "the weekly time slot capacity (40 hours). Please reduce their workload mapping.\n\n" + + "\n".join(overloaded_reports) + ) + if progress_callback: + progress_callback(0, "Halted: Capacity Overflow Detected") + + return ScheduleResult( + success=False, + schedule=[], + error_message=error_msg, + stats={ + "error_type": "CAPACITY_OVERFLOW", + "overloaded_sections": overloaded_reports, + "total_classes": len(classes), + }, + ) + if progress_callback: progress_callback(20, f"Pass 1: Greedy scheduling {len(classes)} classes...") @@ -204,6 +254,7 @@ def solve(self, progress_callback: Optional[Callable[[int, str], None]] = None) "failed_details": failed_details, "failed_workloads": len(failed_details), "success_rate": f"{success_rate * 100:.1f}%", + "unassigned_curriculum": self.problem.unassigned_curriculum, }, ) @@ -237,9 +288,10 @@ def _build_failed_details(self, schedule, unassigned): for entry in schedule: course = self.problem.course_map.get(entry.course_id) - allocated_hours[(entry.section_id, entry.course_id)] += ( - course.get_hours_needed() if course and self._is_lab_course(course) else 1 - ) + if course and self._is_lab_course(course): + allocated_hours[(entry.section_id, entry.course_id)] += 2 # All labs are exactly 2 hours + else: + allocated_hours[(entry.section_id, entry.course_id)] += 1 for _, section_id, course_id, hours in unassigned: missing_hours[(section_id, course_id)] += hours @@ -284,7 +336,7 @@ def _compute_valid_starts(self, timeslot_list): if start_idx + hours > num_timeslots: continue - consecutive_slots = timeslot_list[start_idx : start_idx + hours] + consecutive_slots = timeslot_list[start_idx : start_idx + hours] # noqa: E203 is_valid = True for offset in range(hours - 1): current = consecutive_slots[offset] @@ -325,15 +377,21 @@ def _build_class_order(self, classes, valid_starts): return [(item[0], item[5], item[2], item[3], item[4]) for item in ranked] def _greedy_assign(self, classes, valid_starts, timeslot_list): - """First pass: greedy assignment with constrained-class ordering.""" + """First pass: greedy assignment with session-isolation and zero-gap logic.""" class_order = self._build_class_order(classes, valid_starts) schedule = [] state = self._new_schedule_state() unassigned = [] + # Identify slot boundaries for each day + day_slots = defaultdict(list) + for idx, ts in enumerate(timeslot_list): + day_slots[ts.day].append(idx) + for _, class_idx, section_id, course_id, hours in class_order: course = self.problem.course_map[course_id] section = self.problem.section_map[section_id] + batch_id = section.batch_id is_lab = self._is_lab_course(course) program_code = section.program_code or "__unknown_program__" sc_key = (section_id, course_id) @@ -341,23 +399,50 @@ def _greedy_assign(self, classes, valid_starts, timeslot_list): possible_faculty = self._get_faculty_candidates(section, course) possible_rooms = self._get_room_candidates(section, course) - # --- Teacher-course-section consistency (greedy pass) --- - # If a teacher has already been assigned to this (section, course) - # pair on a previous slot, lock all future slots to the same teacher. locked_fid = state["section_course_faculty"].get(sc_key) if locked_fid is not None: possible_faculty = [f for f in possible_faculty if f.id == locked_fid] + assigned = False all_candidates = [] for start_idx in valid_starts.get(hours, []): slots_needed = list(range(start_idx, start_idx + hours)) day = timeslot_list[start_idx].day + # Zero-gap & Session Logic: + # Assuming 8 slots per day: 0-3 (Morning), 4-7 (Afternoon) + day_start_idx = day_slots[day][0] + relative_slots = [s - day_start_idx for s in slots_needed] + + is_morning = all(0 <= s <= 3 for s in relative_slots) + is_afternoon = all(4 <= s <= 7 for s in relative_slots) + + # Rule: Must be entirely within Morning or entirely within Afternoon session + if not (is_morning or is_afternoon): + continue + if any(slot_idx in state["section_hours"][section_id] for slot_idx in slots_needed): continue + + # Rule: Lab scheduling logic (Max 1 lab per day per batch) + if is_lab and day in state["batch_lab_days"][batch_id]: + continue if not is_lab and day in state["section_course_days"][(section_id, course_id)]: continue + # Rule: Zero-gap back-to-back logic + if is_morning: + session_range = range(day_start_idx, day_start_idx + 4) + else: + session_range = range(day_start_idx + 4, day_start_idx + 8) + existing_in_session = [s for s in session_range if s in state["section_hours"][section_id]] + + if existing_in_session: + min_e = min(existing_in_session) + max_e = max(existing_in_session) + if not (max(slots_needed) == min_e - 1 or min(slots_needed) == max_e + 1): + continue + for faculty in possible_faculty: if state["faculty_hours"][faculty.id] + hours > faculty.max_hours_per_week: continue @@ -374,14 +459,19 @@ def _greedy_assign(self, classes, valid_starts, timeslot_list): if any(slot_idx in state["room_slots"][room.id] for slot_idx in slots_needed): continue - # E4: Score candidate based on daily load balance and gap penalty - # Prefer days where the faculty has fewer hours + # Scoring: Session preferences + faculty balance + session_score = 0 + if is_lab: + session_score += 20 if is_afternoon else 5 + else: + session_score += 20 if is_morning else 5 + fac_day_load = state["faculty_daily_hours"][faculty.id][day] balance_score = 10 - fac_day_load all_candidates.append( { - "score": balance_score, + "score": session_score + balance_score, "start_idx": start_idx, "faculty_id": faculty.id, "room_id": room.id, @@ -391,14 +481,13 @@ def _greedy_assign(self, classes, valid_starts, timeslot_list): ) if all_candidates: - # E4: Sort by score descending all_candidates.sort(key=lambda x: x["score"], reverse=True) best = all_candidates[0] self._occupy_assignment( state, section_id, - section.batch_id, + batch_id, course_id, best["faculty_id"], best["room_id"], @@ -426,10 +515,15 @@ def _greedy_assign(self, classes, valid_starts, timeslot_list): return schedule, unassigned def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, timeslot_list): - """Second pass: try to fit remaining classes into the free gaps.""" + """Second pass: try to fit remaining classes into the free gaps while maintaining session rules.""" state = self._new_schedule_state() class_hours = {(section_id, course_id): hours for section_id, course_id, hours in classes} + # Identify slot boundaries for each day + day_slots = defaultdict(list) + for idx, ts in enumerate(timeslot_list): + day_slots[ts.day].append(idx) + for entry in schedule: hours = class_hours.get((entry.section_id, entry.course_id), 1) start_idx = self._timeslot_index.get((entry.timeslot.day, entry.timeslot.start_time)) @@ -454,7 +548,6 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti hours, is_lab, ) - # Rebuild teacher-course-section lock from already-scheduled entries sc_key = (entry.section_id, entry.course_id) state["section_course_faculty"].setdefault(sc_key, entry.faculty_id) @@ -463,6 +556,7 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti for class_idx, section_id, course_id, hours in unassigned: course = self.problem.course_map[course_id] section = self.problem.section_map[section_id] + batch_id = section.batch_id is_lab = self._is_lab_course(course) program_code = section.program_code or "__unknown_program__" sc_key = (section_id, course_id) @@ -470,7 +564,6 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti possible_faculty = self._get_faculty_candidates(section, course) possible_rooms = self._get_room_candidates(section, course) - # --- Teacher-course-section consistency (local-search pass) --- locked_fid = state["section_course_faculty"].get(sc_key) if locked_fid is not None: possible_faculty = [f for f in possible_faculty if f.id == locked_fid] @@ -481,11 +574,34 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti slots_needed = list(range(start_idx, start_idx + hours)) day = timeslot_list[start_idx].day + day_start_idx = day_slots[day][0] + relative_slots = [s - day_start_idx for s in slots_needed] + is_morning = all(0 <= s <= 3 for s in relative_slots) + is_afternoon = all(4 <= s <= 7 for s in relative_slots) + + if not (is_morning or is_afternoon): + continue + if any(slot_idx in state["section_hours"][section_id] for slot_idx in slots_needed): continue + if is_lab and day in state["batch_lab_days"][batch_id]: + continue if not is_lab and day in state["section_course_days"][(section_id, course_id)]: continue + # Zero-gap back-to-back logic + if is_morning: + session_range = range(day_start_idx, day_start_idx + 4) + else: + session_range = range(day_start_idx + 4, day_start_idx + 8) + existing_in_session = [s for s in session_range if s in state["section_hours"][section_id]] + + if existing_in_session: + min_e = min(existing_in_session) + max_e = max(existing_in_session) + if not (max(slots_needed) == min_e - 1 or min(slots_needed) == max_e + 1): + continue + for faculty in possible_faculty: if state["faculty_hours"][faculty.id] + hours > faculty.max_hours_per_week: continue @@ -505,7 +621,7 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti self._occupy_assignment( state, section_id, - section.batch_id, + batch_id, course_id, faculty.id, room.id, @@ -527,10 +643,8 @@ def _local_search_optimize(self, schedule, unassigned, classes, valid_starts, ti state["section_course_faculty"].setdefault(sc_key, faculty.id) assigned = True break - if assigned: break - if assigned: break diff --git a/backend/app/scheduler_new/models.py b/backend/app/scheduler_new/models.py index a54a4e1..f804dbe 100644 --- a/backend/app/scheduler_new/models.py +++ b/backend/app/scheduler_new/models.py @@ -14,10 +14,8 @@ limitations under the License. """ -""" -Production-grade Timetable Scheduling Engine -Backtracking CSP solver with MRV, LCV, and Forward Checking -""" +# Production-grade Timetable Scheduling Engine +# Backtracking CSP solver with MRV, LCV, and Forward Checking from dataclasses import dataclass, field from typing import List, Dict, Set, Optional, Tuple, Any @@ -85,9 +83,28 @@ class Room: department_id: Optional[int] = None program_id: Optional[int] = None - def can_accommodate(self, section_size: int) -> bool: - """Check if room can accommodate section""" - return self.capacity >= section_size + def can_accommodate(self, section_size: int, tolerance_percent: float = 10.0) -> bool: + """Check if room can accommodate section with tolerance. + + Rules: + - Labs: Strict capacity check (no tolerance) - can't add extra lab stations + - Theory/Lecture: Allow 10% over-capacity tolerance for extra chairs/standing room + + Args: + section_size: Number of students in the section + tolerance_percent: Allowed over-capacity percentage for theory rooms (default 10%) + Real-world example: 45 capacity + 10% = 49.5 → allows 50 students + """ + room_type_lower = (self.room_type or "Classroom").lower().strip() + is_lab_room = "lab" in room_type_lower + + if is_lab_room: + # Labs: Strict check - you can't magically create extra lab workstations + return self.capacity >= section_size + else: + # Theory/Lecture rooms: Allow tolerance for extra chairs/standing room + effective_capacity = self.capacity * (1 + tolerance_percent / 100) + return effective_capacity >= section_size def is_suitable_for(self, course_type: str) -> bool: """Check if room is suitable for course type""" @@ -151,6 +168,7 @@ class Course: program_code: Optional[str] = None program_id: Optional[int] = None department_id: Optional[int] = None + semester: Optional[int] = None def is_lab(self) -> bool: """Return True when this course must be scheduled as a lab block.""" @@ -257,6 +275,7 @@ def to_dict(self) -> Dict: "error": self.error_message, "conflicts": self.conflicts, "stats": self.stats, + "unassigned_curriculum": self.stats.get("unassigned_curriculum", []), } @@ -273,6 +292,9 @@ class SchedulingProblem: # Explicit Workload Allocation: (section_id, course_id) -> teacher_id workload_map: Dict[Tuple[int, int], int] = field(default_factory=dict) + # Rule: Track curriculum courses that were skipped because they had no workload allocation + unassigned_curriculum: List[Dict] = field(default_factory=list) + # Lookup maps for O(1) access section_map: Dict[int, Section] = field(init=False) course_map: Dict[int, Course] = field(init=False) @@ -297,8 +319,8 @@ def get_section_courses(self) -> List[Tuple[int, int, int]]: course = self.course_map.get(course_id) if course: if course.is_lab(): - # Each lab course is scheduled once per week as one 2-slot block. - instances.append((section.id, course_id, course.get_hours_needed())) + # Rule: Each lab course is scheduled once per week as exactly ONE 2-slot block. + instances.append((section.id, course_id, 2)) else: # Theory courses are scheduled as distinct 1-hour slots individually. for _ in range(course.get_hours_needed()): diff --git a/backend/app/scheduler_new/ortools_engine.py b/backend/app/scheduler_new/ortools_engine.py index 814536b..9001b2e 100644 --- a/backend/app/scheduler_new/ortools_engine.py +++ b/backend/app/scheduler_new/ortools_engine.py @@ -14,10 +14,9 @@ limitations under the License. """ -""" -OR-Tools CP-SAT based Timetable Scheduler Engine -Designed for massive scaling across thousands of nodes and university campuses. -""" +# OR-Tools CP-SAT based Timetable Scheduler Engine +# Designed for massive scaling across thousands of nodes and university campuses. + import time import random from collections import defaultdict @@ -53,7 +52,7 @@ def solve(self, progress_callback: Optional[Callable[[int, str], None]] = None) for h in range(1, 8): for i in range(num_timeslots): if i + h <= num_timeslots: - consecutive_slots = timeslot_list[i : i + h] + consecutive_slots = timeslot_list[i : i + h] # noqa: E203 valid = True for j in range(h - 1): if ( @@ -161,14 +160,18 @@ def solve(self, progress_callback: Optional[Callable[[int, str], None]] = None) f_per_slot = max(1, min_per_faculty // len(f_slots)) f_selected = [] for ts_idx in f_slots: - f_selected.extend(random.sample(f_by_slot[ts_idx], min(len(f_by_slot[ts_idx]), f_per_slot))) + f_selected.extend( + random.sample(f_by_slot[ts_idx], min(len(f_by_slot[ts_idx]), f_per_slot)) # nosec B311 + ) if len(f_selected) < min_per_faculty: rem = [c for c in f_combos if c not in f_selected] - f_selected.extend(random.sample(rem, min(len(rem), min_per_faculty - len(f_selected)))) + f_selected.extend( + random.sample(rem, min(len(rem), min_per_faculty - len(f_selected))) # nosec B311 + ) selected.extend(f_selected) if len(selected) > MAX_COMBINATIONS_PER_CLASS: - all_combinations = random.sample(selected, MAX_COMBINATIONS_PER_CLASS) + all_combinations = random.sample(selected, MAX_COMBINATIONS_PER_CLASS) # nosec B311 else: all_combinations = selected diff --git a/backend/app/scheduler_new/scheduler_engine.py b/backend/app/scheduler_new/scheduler_engine.py index df6accc..2d96c15 100644 --- a/backend/app/scheduler_new/scheduler_engine.py +++ b/backend/app/scheduler_new/scheduler_engine.py @@ -14,10 +14,8 @@ limitations under the License. """ -""" -SchedulerEngine - Backtracking CSP solver with MRV, LCV, and Forward Checking -Optimized for performance with caching and selective constraint checking -""" +# SchedulerEngine - Backtracking CSP solver with MRV, LCV, and Forward Checking +# Optimized for performance with caching and selective constraint checking import time import random @@ -196,7 +194,7 @@ def _find_consecutive_slots( if start_idx + hours_needed > len(day_slots): return None - consecutive = day_slots[start_idx : start_idx + hours_needed] + consecutive = day_slots[start_idx : start_idx + hours_needed] # noqa: E203 # Verify they're consecutive for i in range(len(consecutive) - 1): if consecutive[i].end_time != consecutive[i + 1].start_time: diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx index 28f78ab..cf45613 100644 --- a/frontend/src/pages/BatchesPage.tsx +++ b/frontend/src/pages/BatchesPage.tsx @@ -35,6 +35,7 @@ const batchSchema = z.object({ code: z.string().min(1, 'Code is required'), academic_year: z.string().min(4, 'Academic year required'), program_id: z.coerce.number().min(1, 'Select a program'), + current_semester: z.coerce.number().min(1).max(10).default(1), }) type BatchFormData = z.infer @@ -140,7 +141,7 @@ function BatchesTab() { // ── actions ──────────────────────────────────────────────────────────────── const openCreateBatch = () => { setEditBatch(null) - batchForm.reset({ name: '', code: '', academic_year: '', program_id: 0 }) + batchForm.reset({ name: '', code: '', academic_year: '', program_id: 0, current_semester: 1 }) setBatchModalOpen(true) } const openEditBatch = (b: Batch) => { @@ -149,6 +150,7 @@ function BatchesTab() { batchForm.setValue('code', b.code) batchForm.setValue('academic_year', b.academic_year) batchForm.setValue('program_id', b.program_id) + batchForm.setValue('current_semester', b.current_semester) setBatchModalOpen(true) } const onBatchSubmit = async (data: BatchFormData) => { @@ -231,6 +233,16 @@ function BatchesTab() { {String(row.program_code ?? '—')} ) }, + { + key: 'current_semester', label: 'Semester', sortable: true, render: row => { + const sem = row.current_semester ?? (row as unknown as Record).currentSemester; + return ( + + {sem !== undefined && sem !== null ? String(sem) : '—'} + + ); + } + }, ]} // eslint-disable-next-line @typescript-eslint/no-explicit-any data={batchTable.paginated as any} @@ -291,6 +303,11 @@ function BatchesTab() { {batchForm.formState.errors.program_id &&

{batchForm.formState.errors.program_id.message}

} +
+ + + {batchForm.formState.errors.current_semester &&

{batchForm.formState.errors.current_semester.message}

} +
@@ -307,13 +324,13 @@ function BatchesTab() { isOpen={batchImportOpen} onClose={() => setBatchImportOpen(false)} resourceName="Batches" - headers={['Name', 'Code', 'Year', 'ProgramCode', 'AcademicYear']} + headers={['Name', 'Code', 'AcademicYear', 'ProgramCode', 'CurrentSemester']} formatExamples={{ 'Name': 'B.Tech Mining-2024', - 'Code': 'B.Tech Mining-2024', - 'Year': '2024', - 'ProgramCode': 'B.Tech Mining', - 'AcademicYear': '2024-2028' + 'Code': 'B24', + 'AcademicYear': '2024-2028', + 'ProgramCode': 'BT-MINING', + 'CurrentSemester': '1' }} onImport={(f) => batchService.bulkImport(f)} onSuccess={load} @@ -464,6 +481,13 @@ function SectionsTab() { return {prog || '—'} } }, + { + key: 'semester', label: 'Semester', render: row => { + const batch = batchMap[row.batch_id as number]; + const sem = batch?.current_semester ?? (batch as unknown as Record)?.currentSemester; + return {sem !== undefined && sem !== null ? String(sem) : '—'}; + } + }, ]} // eslint-disable-next-line @typescript-eslint/no-explicit-any data={table.paginated as any} diff --git a/frontend/src/pages/TimetablePage.tsx b/frontend/src/pages/TimetablePage.tsx index 1228127..fbe188c 100644 --- a/frontend/src/pages/TimetablePage.tsx +++ b/frontend/src/pages/TimetablePage.tsx @@ -308,8 +308,8 @@ function BatchTimetableView({ timetable, batches, sections, teachers, workingDay {slot.isBreak ? 'BREAK' : `SLOT ${slot.periodNumber}`}
- {slot.isBreak - ? (slot.label && slot.label !== slot.key ? slot.label : 'LUNCH') + {slot.isBreak + ? (slot.label && slot.label !== slot.key ? slot.label : 'LUNCH') : `Period ${slot.periodNumber}`}
@@ -426,7 +426,7 @@ function BatchTimetableView({ timetable, batches, sections, teachers, workingDay ) } - + // Highlight illegal entries in break slots const isIllegalBreakAssignment = isBreakSlot && entry; @@ -750,6 +750,14 @@ export function TimetablePage() { } setReadiness(nextReadiness) + + // Sync top-level states so the grid and legends can render immediately + setSections(sectionData) + setBatches(sectionResult.batches) + setTeachers(allTeachers) + setRooms(deptRooms) + setCourses(deptCourses) + return nextReadiness } catch (err) { const fallback = { diff --git a/frontend/src/pages/WorkloadPage.tsx b/frontend/src/pages/WorkloadPage.tsx index c4538c2..facc181 100644 --- a/frontend/src/pages/WorkloadPage.tsx +++ b/frontend/src/pages/WorkloadPage.tsx @@ -15,8 +15,10 @@ */ import React, { useEffect, useState, useMemo, useCallback } from 'react' -import { Check, ClipboardList, Search, User, UserPlus, X, GraduationCap as BatchIcon, Filter, ArrowLeft, Upload, Zap, RefreshCw } from 'lucide-react' -import { sectionService, workloadService, departmentService, teacherService } from '@/services/resources.service' +import { Check, ClipboardList, Search, User, UserPlus, X, GraduationCap as BatchIcon, Filter, ArrowLeft, Upload,RefreshCw, BarChart3, LayoutGrid, Plus, BookOpen } from 'lucide-react' +import { DataTable } from '@/components/common/DataTable' +import { sectionService, workloadService, departmentService, teacherService, batchService } from '@/services/resources.service' +import { useTable } from '@/hooks/useTable' import { useToast } from '@/context/useToast' import { PageLoader } from '@/components/ui/Loading' import { BulkImportModal } from '@/components/common/BulkImportModal' @@ -41,40 +43,62 @@ interface SectionWorkloadResponse { section_id: number; section_name: string; batch_name: string; + current_semester?: number; courses: WorkloadItem[]; } export function WorkloadPage() { const { toast } = useToast() - + // Data states const [sections, setSections] = useState([]) const [departments, setDepartments] = useState([]) const [allTeachers, setAllTeachers] = useState([]) - + // UI states const [selectedDeptId, setSelectedDeptId] = useState('all') const [selectedSectionId, setSelectedSectionId] = useState(null) const [workloadData, setWorkloadData] = useState(null) const [viewMode, setViewMode] = useState<'selection' | 'mapping'>('selection') - + const [loading, setLoading] = useState(true) const [loadingWorkload, setLoadingWorkload] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [importModalOpen, setImportModalOpen] = useState(false) + const [activeTab, setActiveTab] = useState<'sections' | 'summary'>('sections') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [summaryData, setSummaryData] = useState([]) + const [loadingSummary, setLoadingSummary] = useState(false) + const [quickAddOpen, setQuickAddOpen] = useState(false) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const summaryTable = useTable({ data: summaryData as any, searchFields: ['teacher_name', 'teacher_email'] as any, defaultSortKey: 'teacher_name' }) + + // Manual Add State + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [allBatches, setAllBatches] = useState([]) + const [selectedBatchId, setSelectedBatchId] = useState('') + const [selectedSectionIdManual, setSelectedSectionIdManual] = useState('') + const [selectedCourseIdManual, setSelectedCourseIdManual] = useState('') + const [selectedTeacherIdManual, setSelectedTeacherIdManual] = useState('') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [availableCourses, setAvailableCourses] = useState([]) + const [submitting, setSubmitting] = useState(false) // ── Load initial data ────────────────────────────────────────────────────── const loadResources = useCallback(async () => { setLoading(true) try { - const [secsRes, deptsRes, teachersRes] = await Promise.all([ + const [secsRes, deptsRes, teachersRes, batchesRes] = await Promise.all([ sectionService.list(), departmentService.list(), - teacherService.list() + teacherService.list(), + batchService.list() ]) setSections(secsRes.data) setDepartments(deptsRes.data) setAllTeachers(teachersRes.data) + setAllBatches(batchesRes.data) } catch { toast('error', 'Failed to load resources') } finally { @@ -86,6 +110,24 @@ export function WorkloadPage() { loadResources() }, [loadResources]) + const loadSummary = useCallback(async () => { + setLoadingSummary(true) + try { + const data = await workloadService.getSummary() + setSummaryData(data) + } catch { + toast('error', 'Failed to load workload summary') + } finally { + setLoadingSummary(false) + } + }, [toast]) + + useEffect(() => { + if (activeTab === 'summary') { + loadSummary() + } + }, [activeTab, loadSummary]) + // ── Load workload for selected section ───────────────────────────────────── useEffect(() => { if (!selectedSectionId) { @@ -116,14 +158,57 @@ export function WorkloadPage() { const matchesSearch = s.name.toLowerCase().includes(query) || (s.batch_name?.toLowerCase().includes(query)) || (s.program_name?.toLowerCase().includes(query)); - + const matchesDept = selectedDeptId === 'all' || s.department_id === selectedDeptId; - + return matchesSearch && matchesDept; }) }, [sections, searchQuery, selectedDeptId]) + useEffect(() => { + if (!selectedBatchId || !selectedSectionIdManual) { + setAvailableCourses([]) + return + } + async function fetchBatchCourses() { + try { + const res = await workloadService.getSectionWorkload(selectedSectionIdManual as number) + setAvailableCourses(res.courses) + } catch { + toast('error', 'Failed to fetch courses for this section') + } + } + fetchBatchCourses() + }, [selectedBatchId, selectedSectionIdManual, toast]) + // ── Handlers ─────────────────────────────────────────────────────────────── + const handleManualAdd = async () => { + if (!selectedSectionIdManual || !selectedCourseIdManual || !selectedTeacherIdManual) { + toast('error', 'Please select all fields') + return + } + setSubmitting(true) + try { + await workloadService.assign( + selectedSectionIdManual as number, + selectedCourseIdManual as number, + selectedTeacherIdManual as number + ) + toast('success', 'Workload added successfully') + setQuickAddOpen(false) + // Reset + setSelectedBatchId('') + setSelectedSectionIdManual('') + setSelectedCourseIdManual('') + setSelectedTeacherIdManual('') + loadResources() + if (activeTab === 'summary') loadSummary() + } catch { + toast('error', 'Failed to add workload') + } finally { + setSubmitting(false) + } + } const handleAssign = async (courseId: number, teacherId: number) => { if (!selectedSectionId) return try { @@ -150,45 +235,18 @@ export function WorkloadPage() { } } - const handleAutoAssign = async () => { - if (!window.confirm("Auto-assign teachers for ALL batches & sections? (Only unassigned courses will be affected)")) return - setLoading(true) - try { - const res = await workloadService.autoAssignAll() - toast('success', res.message) - await loadResources() - } catch { - toast('error', 'Auto-assignment failed') - } finally { - setLoading(false) - } - } - - const handleRebalance = async () => { - if (!window.confirm("🛑 CRITICAL ACTION: This will DELETE ALL current assignments (including those from Bulk Import!) and recreate them evenly across all faculty to solve teacher-overload issues. Is this what you want?")) return - setLoading(true) - try { - const res = await workloadService.rebalanceAll() - toast('success', res.message || 'Workload rebalanced successfully') - await loadResources() - } catch { - toast('error', 'Rebalance failed') - } finally { - setLoading(false) - } - } if (loading) return return (
- + {/* ── Page Title ── */} -
+
{viewMode === 'mapping' && ( -
+ {viewMode === 'selection' && (
- - +
)}
- {/* ── SELECTION MODE: Grid of Sections ── */} + {/* ── Tab Switcher (Only in Selection Mode) ── */} {viewMode === 'selection' && ( +
+ + +
+ )} + + {/* ── SELECTION MODE: Sections Grid ── */} + {viewMode === 'selection' && activeTab === 'sections' && (
- + {/* Filters Bar */}
-
- +
- setSearchQuery(e.target.value)} /> @@ -287,12 +353,12 @@ export function WorkloadPage() { {/* Sections Grid */}
{filteredSections.map(sec => ( -
setSelectedSectionId(sec.id)} - style={{ - padding: '1.5rem', + style={{ + padding: '1.5rem', cursor: 'pointer', border: '1px solid var(--border)', display: 'flex', @@ -303,11 +369,11 @@ export function WorkloadPage() { }} >
- + {sec.program_name} - +

{sec.name}

{sec.batch_name}

@@ -335,29 +401,111 @@ export function WorkloadPage() {
)} + {/* ── SELECTION MODE: Summary View ── */} + {viewMode === 'selection' && activeTab === 'summary' && ( +
+
+

Faculty Workload Summary

+ +
+ + ( +
+
{row.teacher_name}
+
{row.teacher_email}
+
+ ) + }, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: 'departments', label: 'Departments', render: (row: any) => ( +
+ {(row.departments || []).map((d: string) => {d})} +
+ ) + }, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: 'course_count', label: 'Courses', sortable: true, render: (row: any) => ( + {row.course_count} Courses + ) + }, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: 'total_hours', label: 'Total Hours', sortable: true, render: (row: any) => ( + {row.total_hours} hrs / week + ) + }, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: 'assignments', label: 'Teaching Assignments', render: (row: any) => ( +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {(row.assignments || []).slice(0, 3).map((a: any) => ( +
+ + {a.course_code} + ({a.section_name}) +
+ ))} + {(row.assignments || []).length > 3 && ( + + {row.assignments.length - 3} more... + )} +
+ ) + } + ]} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data={summaryTable.paginated as any} + search={summaryTable.search} + onSearch={summaryTable.setSearch} + page={summaryTable.page} + totalPages={summaryTable.totalPages} + onPageChange={summaryTable.setPage} + total={summaryTable.total} + sortKey={summaryTable.sortKey as string} + sortDir={summaryTable.sortDir} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSort={(k) => summaryTable.toggleSort(k as any)} + emptyTitle="No workload data available" + /> +
+ )} + {/* ── MAPPING MODE: Allocation Cards ── */} {viewMode === 'mapping' && (
- + {/* Header Info */}
- {workloadData?.batch_name} +
+ {workloadData?.batch_name} + {workloadData?.current_semester && ( + Semester {workloadData.current_semester} + )} +

Manage Workload: {workloadData?.section_name}

- +
-
- {workloadData?.courses.filter(c => c.teacher_id).length} / {workloadData?.courses.length} Assigned -
-
-
c.teacher_id).length || 0) / (workloadData?.courses.length || 1)) * 100}%`, - height: '100%', - background: 'var(--primary)', - transition: 'width 0.5s ease-out' - }} /> -
+
+ {workloadData?.courses.filter(c => c.teacher_id).length} / {workloadData?.courses.length} Assigned +
+
+
c.teacher_id).length || 0) / (workloadData?.courses.length || 1)) * 100}%`, + height: '100%', + background: 'var(--primary)', + transition: 'width 0.5s ease-out' + }} /> +
@@ -366,12 +514,12 @@ export function WorkloadPage() { ) : (
{workloadData?.courses.map(course => ( -
- +
{course.course_code} @@ -412,8 +560,8 @@ export function WorkloadPage() { ))} {course.teacher_id && ( - +
+ +
+
+ + +
+ + {selectedBatchId && ( +
+ + +
+ )} + + {selectedSectionIdManual && ( +
+ + +
+ )} + +
+ + +
+
+ +
+ + +
+
+
+ )}
) } diff --git a/frontend/src/services/resources.service.ts b/frontend/src/services/resources.service.ts index e1b8039..7ec3555 100644 --- a/frontend/src/services/resources.service.ts +++ b/frontend/src/services/resources.service.ts @@ -272,6 +272,10 @@ export const roomService = { // ── Workload ──────────────────────────────────────────────────────────────── export const workloadService = { + async getSummary() { + const res = await api.get('/workload/summary') + return res.data + }, async getSectionWorkload(sectionId: number) { const res = await api.get(`/workload/sections/${sectionId}`) return res.data @@ -291,13 +295,5 @@ export const workloadService = { headers: { 'Content-Type': 'multipart/form-data' }, }) return res.data - }, - async autoAssignAll() { - const response = await api.post('/workload/auto-assign-all') - return response.data - }, - async rebalanceAll() { - const response = await api.post('/workload/rebalance-all') - return response.data } } diff --git a/frontend/src/services/scheduling.service.ts b/frontend/src/services/scheduling.service.ts index 3fa65d8..33cacc7 100644 --- a/frontend/src/services/scheduling.service.ts +++ b/frontend/src/services/scheduling.service.ts @@ -48,7 +48,7 @@ export const schedulingService = { async generateAllTimetables(payload: Partial): Promise { const res = await api.post('/scheduling/generate/all', payload) - return res.data + return res.data.results || [] }, // Timetable viewing diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5d64ba9..f9814d3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -94,6 +94,7 @@ export interface Batch { code: string; academic_year: string; program_id: number; + current_semester: number; program_code?: string; section_count?: number; } @@ -103,6 +104,7 @@ export interface BatchPayload { code: string; academic_year: string; program_id: number; + current_semester: number; } // ── Section ───────────────────────────────────────────────────────────────