template tutoring-admin-dashboard v1

This commit is contained in:
DevVibeCoder
2026-09-14 23:53:29 +05:30 Unverified
commit c4d9755262
70 changed files with 7955 additions and 0 deletions
+13
View File
@@ -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"])
+241
View File
@@ -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()
+49
View File
@@ -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)
+128
View File
@@ -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
+15
View File
@@ -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")
+2
View File
@@ -0,0 +1,2 @@
"""Unused. Replaced by content.py / admin.py. Kept empty to avoid a dangling
import; not mounted in the router barrel."""
+261
View File
@@ -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,
)