template tutoring-admin-dashboard v1
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Route barrel — every router the app serves is mounted here."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.routes import admin, auth, content, health, students
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, tags=["auth"])
|
||||
api_router.include_router(content.router, tags=["content"])
|
||||
api_router.include_router(admin.router, tags=["admin"])
|
||||
api_router.include_router(students.router, tags=["students"])
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Admin (JWT-protected) endpoints: edit every section, manage enquiries.
|
||||
|
||||
All routes here depend on ``get_current_admin`` via the router dependency, so an
|
||||
unauthenticated request gets 401 before reaching a handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
Course,
|
||||
Enquiry,
|
||||
FeePlan,
|
||||
Profile,
|
||||
Review,
|
||||
SiteSettings,
|
||||
Subject,
|
||||
Testimonial,
|
||||
)
|
||||
from app.schemas import (
|
||||
CourseIn,
|
||||
CourseOut,
|
||||
EnquiryOut,
|
||||
EnquiryPatch,
|
||||
FeePlanIn,
|
||||
FeePlanOut,
|
||||
ProfileIn,
|
||||
ProfileOut,
|
||||
ReviewOut,
|
||||
ReviewPatch,
|
||||
SiteSettingsIn,
|
||||
SiteSettingsOut,
|
||||
SubjectIn,
|
||||
SubjectOut,
|
||||
TestimonialIn,
|
||||
TestimonialOut,
|
||||
)
|
||||
from app.security import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_admin)])
|
||||
|
||||
|
||||
# ---- Profile (singleton) ----
|
||||
@router.put("/profile", response_model=ProfileOut)
|
||||
def update_profile(body: ProfileIn, db: Session = Depends(get_db)) -> Profile:
|
||||
obj = db.query(Profile).first()
|
||||
if not obj:
|
||||
obj = Profile()
|
||||
db.add(obj)
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ---- Settings (singleton) ----
|
||||
@router.put("/settings", response_model=SiteSettingsOut)
|
||||
def update_settings(body: SiteSettingsIn, db: Session = Depends(get_db)) -> SiteSettings:
|
||||
obj = db.query(SiteSettings).first()
|
||||
if not obj:
|
||||
obj = SiteSettings()
|
||||
db.add(obj)
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ---- Subjects CRUD ----
|
||||
@router.post("/subjects", response_model=SubjectOut, status_code=201)
|
||||
def create_subject(body: SubjectIn, db: Session = Depends(get_db)) -> Subject:
|
||||
obj = Subject(**body.model_dump())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.put("/subjects/{item_id}", response_model=SubjectOut)
|
||||
def update_subject(item_id: int, body: SubjectIn, db: Session = Depends(get_db)) -> Subject:
|
||||
obj = db.get(Subject, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Subject not found")
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/subjects/{item_id}", status_code=204)
|
||||
def delete_subject(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(Subject, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Courses CRUD ----
|
||||
@router.post("/courses", response_model=CourseOut, status_code=201)
|
||||
def create_course(body: CourseIn, db: Session = Depends(get_db)) -> Course:
|
||||
obj = Course(**body.model_dump())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.put("/courses/{item_id}", response_model=CourseOut)
|
||||
def update_course(item_id: int, body: CourseIn, db: Session = Depends(get_db)) -> Course:
|
||||
obj = db.get(Course, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Course not found")
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/courses/{item_id}", status_code=204)
|
||||
def delete_course(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(Course, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Fees CRUD ----
|
||||
@router.post("/fees", response_model=FeePlanOut, status_code=201)
|
||||
def create_fee(body: FeePlanIn, db: Session = Depends(get_db)) -> FeePlan:
|
||||
obj = FeePlan(**body.model_dump())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.put("/fees/{item_id}", response_model=FeePlanOut)
|
||||
def update_fee(item_id: int, body: FeePlanIn, db: Session = Depends(get_db)) -> FeePlan:
|
||||
obj = db.get(FeePlan, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Fee plan not found")
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/fees/{item_id}", status_code=204)
|
||||
def delete_fee(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(FeePlan, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Testimonials CRUD ----
|
||||
@router.post("/testimonials", response_model=TestimonialOut, status_code=201)
|
||||
def create_testimonial(body: TestimonialIn, db: Session = Depends(get_db)) -> Testimonial:
|
||||
obj = Testimonial(**body.model_dump())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.put("/testimonials/{item_id}", response_model=TestimonialOut)
|
||||
def update_testimonial(item_id: int, body: TestimonialIn, db: Session = Depends(get_db)) -> Testimonial:
|
||||
obj = db.get(Testimonial, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Testimonial not found")
|
||||
for k, v in body.model_dump().items():
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/testimonials/{item_id}", status_code=204)
|
||||
def delete_testimonial(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(Testimonial, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Reviews (moderation) ----
|
||||
@router.get("/reviews", response_model=list[ReviewOut])
|
||||
def list_reviews(db: Session = Depends(get_db)) -> list[Review]:
|
||||
return db.query(Review).order_by(Review.created_at.desc(), Review.id.desc()).all()
|
||||
|
||||
|
||||
@router.patch("/reviews/{item_id}", response_model=ReviewOut)
|
||||
def patch_review(item_id: int, body: ReviewPatch, db: Session = Depends(get_db)) -> Review:
|
||||
obj = db.get(Review, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Review not found")
|
||||
obj.is_approved = body.is_approved
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/reviews/{item_id}", status_code=204)
|
||||
def delete_review(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(Review, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Enquiries ----
|
||||
@router.get("/enquiries", response_model=list[EnquiryOut])
|
||||
def list_enquiries(db: Session = Depends(get_db)) -> list[Enquiry]:
|
||||
return db.query(Enquiry).order_by(Enquiry.created_at.desc(), Enquiry.id.desc()).all()
|
||||
|
||||
|
||||
@router.patch("/enquiries/{item_id}", response_model=EnquiryOut)
|
||||
def patch_enquiry(item_id: int, body: EnquiryPatch, db: Session = Depends(get_db)) -> Enquiry:
|
||||
obj = db.get(Enquiry, item_id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Enquiry not found")
|
||||
obj.is_read = body.is_read
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/enquiries/{item_id}", status_code=204)
|
||||
def delete_enquiry(item_id: int, db: Session = Depends(get_db)) -> None:
|
||||
obj = db.get(Enquiry, item_id)
|
||||
if obj:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Admin authentication: login, me, change password."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import AdminUser
|
||||
from app.schemas import ChangePasswordIn, LoginIn, MeOut, TokenOut
|
||||
from app.security import (
|
||||
create_token,
|
||||
get_current_admin,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginIn, db: Session = Depends(get_db)) -> TokenOut:
|
||||
user = db.query(AdminUser).filter(AdminUser.username == body.username).first()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
return TokenOut(access_token=create_token(user.username), username=user.username)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeOut)
|
||||
def me(admin: AdminUser = Depends(get_current_admin)) -> MeOut:
|
||||
return MeOut(username=admin.username)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MeOut)
|
||||
def change_password(
|
||||
body: ChangePasswordIn,
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MeOut:
|
||||
if not verify_password(body.current_password, admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
||||
admin.password_hash = hash_password(body.new_password)
|
||||
db.commit()
|
||||
return MeOut(username=admin.username)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Public content endpoints — read the site, submit an enquiry.
|
||||
|
||||
These are all unauthenticated (the public site consumes them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
Course,
|
||||
Enquiry,
|
||||
FeePlan,
|
||||
Profile,
|
||||
Review,
|
||||
SiteSettings,
|
||||
Subject,
|
||||
Testimonial,
|
||||
)
|
||||
from app.schemas import (
|
||||
CourseOut,
|
||||
EnquiryIn,
|
||||
EnquiryOut,
|
||||
FeePlanOut,
|
||||
ProfileOut,
|
||||
ReviewIn,
|
||||
ReviewOut,
|
||||
SiteSettingsOut,
|
||||
SubjectOut,
|
||||
TestimonialOut,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_or_create_profile(db: Session) -> Profile:
|
||||
obj = db.query(Profile).first()
|
||||
if not obj:
|
||||
obj = Profile()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _get_or_create_settings(db: Session) -> SiteSettings:
|
||||
obj = db.query(SiteSettings).first()
|
||||
if not obj:
|
||||
obj = SiteSettings()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.get("/profile", response_model=ProfileOut)
|
||||
def get_profile(db: Session = Depends(get_db)) -> Profile:
|
||||
return _get_or_create_profile(db)
|
||||
|
||||
|
||||
@router.get("/subjects", response_model=list[SubjectOut])
|
||||
def list_subjects(db: Session = Depends(get_db)) -> list[Subject]:
|
||||
return db.query(Subject).order_by(Subject.sort_order, Subject.id).all()
|
||||
|
||||
|
||||
@router.get("/courses", response_model=list[CourseOut])
|
||||
def list_courses(db: Session = Depends(get_db)) -> list[Course]:
|
||||
return db.query(Course).order_by(Course.sort_order, Course.id).all()
|
||||
|
||||
|
||||
@router.get("/fees", response_model=list[FeePlanOut])
|
||||
def list_fees(db: Session = Depends(get_db)) -> list[FeePlan]:
|
||||
return db.query(FeePlan).order_by(FeePlan.sort_order, FeePlan.id).all()
|
||||
|
||||
|
||||
@router.get("/testimonials", response_model=list[TestimonialOut])
|
||||
def list_testimonials(db: Session = Depends(get_db)) -> list[Testimonial]:
|
||||
return db.query(Testimonial).order_by(Testimonial.sort_order, Testimonial.id).all()
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SiteSettingsOut)
|
||||
def get_settings(db: Session = Depends(get_db)) -> SiteSettings:
|
||||
return _get_or_create_settings(db)
|
||||
|
||||
|
||||
@router.get("/reviews", response_model=list[ReviewOut])
|
||||
def list_public_reviews(db: Session = Depends(get_db)) -> list[Review]:
|
||||
"""Only approved reviews are shown on the public site."""
|
||||
return (
|
||||
db.query(Review)
|
||||
.filter(Review.is_approved.is_(True))
|
||||
.order_by(Review.created_at.desc(), Review.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reviews", response_model=ReviewOut, status_code=201)
|
||||
def create_review(body: ReviewIn, db: Session = Depends(get_db)) -> Review:
|
||||
"""Public submission — stored unapproved until the teacher approves it."""
|
||||
rating = max(1, min(5, body.rating))
|
||||
obj = Review(
|
||||
author_name=body.author_name.strip() or "Anonymous",
|
||||
relation=body.relation,
|
||||
rating=rating,
|
||||
message=body.message,
|
||||
is_approved=False,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/enquiries", response_model=EnquiryOut, status_code=201)
|
||||
def create_enquiry(body: EnquiryIn, db: Session = Depends(get_db)) -> Enquiry:
|
||||
obj = Enquiry(
|
||||
name=body.name,
|
||||
email=body.email,
|
||||
phone=body.phone,
|
||||
subject_interest=body.subject_interest,
|
||||
message=body.message,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Liveness endpoint. The shape every other route module copies."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=Health)
|
||||
def health() -> Health:
|
||||
return Health(status="ok")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Unused. Replaced by content.py / admin.py. Kept empty to avoid a dangling
|
||||
import; not mounted in the router barrel."""
|
||||
@@ -0,0 +1,261 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user