Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .bandit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[bandit]
skips = B311
8 changes: 7 additions & 1 deletion backend/app/routes/curriculum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
80 changes: 31 additions & 49 deletions backend/app/routes/pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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("<b>BATCH / TIME</b>", 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"<b>{display_label}</b><br/><font size='7' color='#475569'>{time_str}</font>"
display_label = label.upper() if label.upper() != "BREAK" else "LUNCH"
slot_label = f"<b>{display_label} {time_str}</b>"
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"<b>{display_label}</b><br/><font size='7' color='#475569'>{time_str}</font>"
if "SLOT" not in display_label:
display_label = f"SLOT {period_count}"
slot_label = f"<b>{display_label} {time_str}</b>"

header_row.append(Paragraph(slot_label, styles["day_header"]))

Expand Down Expand Up @@ -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"<b>{course_name}</b> ({ctype})<br/>({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"<b>{course_name}{lab_suffix} ({ctype})</b><br/>({teacher_name})<br/>{room_name}"
else:
cell_txt = f"<b>{course_name} ({ctype})</b><br/>({teacher_name})<br/>{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"<font color='#b91c1c' size='5'><b>{l_txt} CONFLICT</b></font><br/>{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"<br/><font color='#b91c1c'>{entry['room'].name}</font>"
elif entry["room"] and entry["room"].name != day_theory_room:
cell_txt += f"<br/>{entry['room'].name}"

row.append(Paragraph(cell_txt, styles["cell"]))

# Color coding background
Expand Down Expand Up @@ -976,15 +957,20 @@ def _build_department_table(data, page_w, normal_font="Helvetica", bold_font="He

# Header Row
header_row = [Paragraph("<b>BATCH</b>", 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"<b>{display_label} {time_str}</b>"
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"<b>{display_label} {time_str}</b>"

slot_label = f"<b>{display_label}</b><br/><font size='5' color='#475569'>{time_str}</font>"
header_row.append(Paragraph(slot_label, styles["day_header"]))

table_data = [header_row]
Expand Down Expand Up @@ -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"<b>{course_name}</b> ({ctype})<br/>({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"<b>{course_name}{lab_suffix} ({ctype})</b><br/>({teacher_name})<br/>{room_name}"
else:
cell_txt = f"<b>{course_name} ({ctype})</b><br/>({teacher_name})<br/>{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"<font color='#b91c1c' size='5'><b>{l_txt} CONFLICT</b></font><br/>{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"<br/><font color='#b91c1c'>{entry['room'].name}</font>"
elif entry["room"] and entry["room"].name != day_theory_room:
cell_txt += f"<br/>{entry['room'].name}"

row.append(Paragraph(cell_txt, styles["cell"]))

if is_illegal:
Expand Down
24 changes: 11 additions & 13 deletions backend/app/routes/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
}
),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand All @@ -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(),
Expand Down
70 changes: 63 additions & 7 deletions backend/app/routes/workload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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}
Expand Down Expand Up @@ -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,
}
),
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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
4 changes: 1 addition & 3 deletions backend/app/scheduler_new/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions backend/app/scheduler_new/constraint_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading