Files
template-tutoring-admin-das…/backend/app/routes/students.py
T

262 lines
8.4 KiB
Python

"""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,
)