262 lines
8.4 KiB
Python
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,
|
|
)
|