"""Admin (JWT-protected) student-management endpoints. Private to the teacher: students & parent records, weekly schedule, monthly attendance sheet with per-student offs and whole-day holidays/class-offs, a "today" teaching list, and a monthly summary (present/absent counts + expected income from fees). Nothing here is exposed on the public site. """ from __future__ import annotations from calendar import monthrange from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.db import get_db from app.models import Attendance, DayMark, ScheduleSlot, Student from app.schemas import ( AttendanceClearIn, AttendanceIn, AttendanceOut, DayMarkIn, DayMarkOut, MonthDataOut, ScheduleSlotIn, ScheduleSlotOut, StudentIn, StudentOut, StudentSummaryOut, SummaryOut, TodayEntryOut, ) from app.security import get_current_admin router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_admin)]) _STATUSES = {"present", "absent", "off"} _KINDS = {"holiday", "class_off"} def _month_bounds(month: str) -> tuple[date, date]: """Return (first_day, last_day) for a 'YYYY-MM' string.""" try: year, mon = (int(p) for p in month.split("-")) first = date(year, mon, 1) last = date(year, mon, monthrange(year, mon)[1]) except (ValueError, TypeError): raise HTTPException(400, "month must be YYYY-MM") from None return first, last # ---- Students CRUD ---- @router.get("/students", response_model=list[StudentOut]) def list_students(db: Session = Depends(get_db)) -> list[Student]: return db.query(Student).order_by(Student.name.asc(), Student.id.asc()).all() @router.post("/students", response_model=StudentOut, status_code=201) def create_student(body: StudentIn, db: Session = Depends(get_db)) -> Student: data = body.model_dump() if data.get("start_date") is None: data["start_date"] = date.today() obj = Student(**data) db.add(obj) db.commit() db.refresh(obj) return obj @router.put("/students/{item_id}", response_model=StudentOut) def update_student(item_id: int, body: StudentIn, db: Session = Depends(get_db)) -> Student: obj = db.get(Student, item_id) if not obj: raise HTTPException(404, "Student not found") data = body.model_dump() if data.get("start_date") is None: data.pop("start_date") for k, v in data.items(): setattr(obj, k, v) db.commit() db.refresh(obj) return obj @router.delete("/students/{item_id}", status_code=204) def delete_student(item_id: int, db: Session = Depends(get_db)) -> None: obj = db.get(Student, item_id) if obj: db.delete(obj) db.commit() # ---- Schedule ---- @router.get("/students/{item_id}/schedule", response_model=list[ScheduleSlotOut]) def get_schedule(item_id: int, db: Session = Depends(get_db)) -> list[ScheduleSlot]: if not db.get(Student, item_id): raise HTTPException(404, "Student not found") return ( db.query(ScheduleSlot) .filter(ScheduleSlot.student_id == item_id) .order_by(ScheduleSlot.weekday.asc(), ScheduleSlot.start_time.asc()) .all() ) @router.put("/students/{item_id}/schedule", response_model=list[ScheduleSlotOut]) def set_schedule( item_id: int, body: list[ScheduleSlotIn], db: Session = Depends(get_db) ) -> list[ScheduleSlot]: if not db.get(Student, item_id): raise HTTPException(404, "Student not found") db.query(ScheduleSlot).filter(ScheduleSlot.student_id == item_id).delete() for slot in body: db.add(ScheduleSlot(student_id=item_id, **slot.model_dump())) db.commit() return ( db.query(ScheduleSlot) .filter(ScheduleSlot.student_id == item_id) .order_by(ScheduleSlot.weekday.asc(), ScheduleSlot.start_time.asc()) .all() ) @router.get("/schedule/today", response_model=list[TodayEntryOut]) def schedule_today(db: Session = Depends(get_db)) -> list[TodayEntryOut]: today = date.today() weekday = today.weekday() # Mon=0 marked = db.query(DayMark).filter(DayMark.date == today).first() if marked is not None: return [] # whole day off for everyone rows = ( db.query(ScheduleSlot, Student) .join(Student, Student.id == ScheduleSlot.student_id) .filter(ScheduleSlot.weekday == weekday, Student.is_active.is_(True)) .order_by(ScheduleSlot.start_time.asc()) .all() ) return [ TodayEntryOut( student_id=s.id, student_name=s.name, subject=s.subject, start_time=slot.start_time, end_time=slot.end_time, ) for slot, s in rows ] # ---- Attendance ---- @router.get("/attendance", response_model=MonthDataOut) def get_attendance( month: str = Query(..., description="YYYY-MM"), db: Session = Depends(get_db) ) -> MonthDataOut: first, last = _month_bounds(month) attendance = ( db.query(Attendance) .filter(Attendance.date >= first, Attendance.date <= last) .all() ) day_marks = ( db.query(DayMark).filter(DayMark.date >= first, DayMark.date <= last).all() ) return MonthDataOut(attendance=attendance, day_marks=day_marks) # type: ignore[arg-type] @router.put("/attendance", response_model=AttendanceOut) def set_attendance(body: AttendanceIn, db: Session = Depends(get_db)) -> Attendance: if body.status not in _STATUSES: raise HTTPException(400, f"status must be one of {sorted(_STATUSES)}") if not db.get(Student, body.student_id): raise HTTPException(404, "Student not found") obj = ( db.query(Attendance) .filter(Attendance.student_id == body.student_id, Attendance.date == body.date) .first() ) if obj is None: obj = Attendance( student_id=body.student_id, date=body.date, status=body.status, note=body.note ) db.add(obj) else: obj.status = body.status obj.note = body.note db.commit() db.refresh(obj) return obj @router.post("/attendance/clear", status_code=204) def clear_attendance(body: AttendanceClearIn, db: Session = Depends(get_db)) -> None: db.query(Attendance).filter( Attendance.student_id == body.student_id, Attendance.date == body.date ).delete() db.commit() # ---- Day marks (holiday / class off for everyone) ---- @router.post("/day-marks", response_model=DayMarkOut) def set_day_mark(body: DayMarkIn, db: Session = Depends(get_db)) -> DayMark: if body.kind not in _KINDS: raise HTTPException(400, f"kind must be one of {sorted(_KINDS)}") obj = db.query(DayMark).filter(DayMark.date == body.date).first() if obj is None: obj = DayMark(date=body.date, kind=body.kind, note=body.note) db.add(obj) else: obj.kind = body.kind obj.note = body.note db.commit() db.refresh(obj) return obj @router.delete("/day-marks/{on_date}", status_code=204) def delete_day_mark(on_date: date, db: Session = Depends(get_db)) -> None: db.query(DayMark).filter(DayMark.date == on_date).delete() db.commit() # ---- Summary ---- @router.get("/students/summary", response_model=SummaryOut) def student_summary( month: str = Query(..., description="YYYY-MM"), db: Session = Depends(get_db) ) -> SummaryOut: first, last = _month_bounds(month) students = db.query(Student).order_by(Student.name.asc(), Student.id.asc()).all() attendance = ( db.query(Attendance) .filter(Attendance.date >= first, Attendance.date <= last) .all() ) counts: dict[int, dict[str, int]] = { s.id: {"present": 0, "absent": 0, "off": 0} for s in students } for a in attendance: if a.student_id in counts and a.status in counts[a.student_id]: counts[a.student_id][a.status] += 1 rows = [ StudentSummaryOut( student_id=s.id, student_name=s.name, monthly_fee=s.monthly_fee, present=counts[s.id]["present"], absent=counts[s.id]["absent"], off=counts[s.id]["off"], ) for s in students ] expected = sum(s.monthly_fee for s in students if s.is_active) active = sum(1 for s in students if s.is_active) return SummaryOut( month=month, active_students=active, expected_income=expected, students=rows, )