template tutoring-admin-dashboard v1
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""Database engine + session.
|
||||
|
||||
Reads DATABASE_URL from the environment (provisioned Postgres). The engine is
|
||||
created with a short connect timeout and a probe: if Postgres is unreachable at
|
||||
startup, we fall back to a local SQLite file so the app still runs (Preview,
|
||||
offline dev). The rest of the app never chooses a database — it imports
|
||||
``SessionLocal`` and ``engine`` from here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize(url: str) -> str:
|
||||
# SQLAlchemy + psycopg3 wants the postgresql+psycopg driver.
|
||||
if url.startswith("postgresql://"):
|
||||
return url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
if url.startswith("postgres://"):
|
||||
return url.replace("postgres://", "postgresql+psycopg://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def _make_engine():
|
||||
url = os.getenv("DATABASE_URL", "").strip()
|
||||
if url:
|
||||
pg_url = _normalize(url)
|
||||
try:
|
||||
eng = create_engine(
|
||||
pg_url,
|
||||
pool_pre_ping=True,
|
||||
connect_args={"connect_timeout": 5},
|
||||
)
|
||||
with eng.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
print("[db] using Postgres")
|
||||
return eng
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[db] Postgres unreachable ({exc}); falling back to SQLite")
|
||||
|
||||
sqlite_path = os.path.join(os.path.dirname(__file__), "..", "app_fallback.db")
|
||||
eng = create_engine(
|
||||
f"sqlite:///{os.path.abspath(sqlite_path)}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
print("[db] using SQLite fallback")
|
||||
return eng
|
||||
|
||||
|
||||
engine = _make_engine()
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""FastAPI entry point. Deploy imports ``app.main:app`` — keep both names."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.routes import api_router
|
||||
from app.seed import init_db
|
||||
|
||||
app = FastAPI(title=os.getenv("APP_NAME", "SwatiYadav"))
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
try:
|
||||
init_db()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[startup] DB init skipped: {exc}")
|
||||
|
||||
# Allowed origins come from the environment so the deployed app and local dev
|
||||
# use the same code path. "*" only when nothing is configured (dev default).
|
||||
_origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# The one place the /api prefix is applied. Route modules must not repeat it.
|
||||
app.include_router(api_router, prefix="/api")
|
||||
@@ -0,0 +1,199 @@
|
||||
"""SQLAlchemy ORM models for the SwatiYadav tuition website.
|
||||
|
||||
Every public section is a table so the teacher can edit it from admin. The
|
||||
public site reads these; the admin panel writes them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
__tablename__ = "profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), default="")
|
||||
tagline: Mapped[str] = mapped_column(String(255), default="")
|
||||
bio: Mapped[str] = mapped_column(Text, default="")
|
||||
photo_url: Mapped[str] = mapped_column(Text, default="")
|
||||
qualifications: Mapped[str] = mapped_column(Text, default="")
|
||||
experience_years: Mapped[int] = mapped_column(Integer, default=0)
|
||||
students_taught: Mapped[int] = mapped_column(Integer, default=0)
|
||||
achievements: Mapped[str] = mapped_column(Text, default="")
|
||||
hero_cta: Mapped[str] = mapped_column(String(120), default="Book a free demo")
|
||||
|
||||
|
||||
class Subject(Base):
|
||||
__tablename__ = "subject"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), default="")
|
||||
level: Mapped[str] = mapped_column(String(120), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
mode: Mapped[str] = mapped_column(String(60), default="Online & Offline")
|
||||
icon: Mapped[str] = mapped_column(String(20), default="📘")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class Course(Base):
|
||||
__tablename__ = "course"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(160), default="")
|
||||
subject: Mapped[str] = mapped_column(String(120), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
duration: Mapped[str] = mapped_column(String(120), default="")
|
||||
mode: Mapped[str] = mapped_column(String(60), default="Online & Offline")
|
||||
highlights: Mapped[str] = mapped_column(Text, default="") # newline-separated
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class FeePlan(Base):
|
||||
__tablename__ = "fee_plan"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), default="")
|
||||
price: Mapped[str] = mapped_column(String(60), default="")
|
||||
period: Mapped[str] = mapped_column(String(60), default="per month")
|
||||
features: Mapped[str] = mapped_column(Text, default="") # newline-separated
|
||||
highlighted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class Testimonial(Base):
|
||||
__tablename__ = "testimonial"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
author_name: Mapped[str] = mapped_column(String(120), default="")
|
||||
relation: Mapped[str] = mapped_column(String(60), default="Student")
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
rating: Mapped[int] = mapped_column(Integer, default=5)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class SiteSettings(Base):
|
||||
__tablename__ = "site_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
phone: Mapped[str] = mapped_column(String(60), default="")
|
||||
email: Mapped[str] = mapped_column(String(160), default="")
|
||||
hours: Mapped[str] = mapped_column(String(255), default="")
|
||||
whatsapp: Mapped[str] = mapped_column(String(160), default="")
|
||||
instagram: Mapped[str] = mapped_column(String(160), default="")
|
||||
youtube: Mapped[str] = mapped_column(String(160), default="")
|
||||
facebook: Mapped[str] = mapped_column(String(160), default="")
|
||||
footer_text: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "review"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
author_name: Mapped[str] = mapped_column(String(160), default="")
|
||||
relation: Mapped[str] = mapped_column(String(60), default="Student")
|
||||
rating: Mapped[int] = mapped_column(Integer, default=5)
|
||||
message: Mapped[str] = mapped_column(Text, default="")
|
||||
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class Enquiry(Base):
|
||||
__tablename__ = "enquiry"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), default="")
|
||||
email: Mapped[str] = mapped_column(String(160), default="")
|
||||
phone: Mapped[str] = mapped_column(String(60), default="")
|
||||
subject_interest: Mapped[str] = mapped_column(String(160), default="")
|
||||
message: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
# ---- Student management (admin-only, private) ----
|
||||
|
||||
|
||||
class Student(Base):
|
||||
__tablename__ = "student"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), default="")
|
||||
phone: Mapped[str] = mapped_column(String(60), default="")
|
||||
parent_name: Mapped[str] = mapped_column(String(160), default="")
|
||||
parent_phone: Mapped[str] = mapped_column(String(60), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
subject: Mapped[str] = mapped_column(String(160), default="")
|
||||
monthly_fee: Mapped[int] = mapped_column(Integer, default=0)
|
||||
start_date: Mapped[date] = mapped_column(Date, default=date.today)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
slots: Mapped[list["ScheduleSlot"]] = relationship(
|
||||
back_populates="student",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ScheduleSlot(Base):
|
||||
__tablename__ = "schedule_slot"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("student.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
weekday: Mapped[int] = mapped_column(Integer, default=0) # 0=Mon .. 6=Sun
|
||||
start_time: Mapped[str] = mapped_column(String(5), default="") # "HH:MM"
|
||||
end_time: Mapped[str] = mapped_column(String(5), default="")
|
||||
|
||||
student: Mapped["Student"] = relationship(back_populates="slots")
|
||||
|
||||
|
||||
class Attendance(Base):
|
||||
__tablename__ = "attendance"
|
||||
__table_args__ = (UniqueConstraint("student_id", "date", name="uq_attendance_student_date"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("student.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(10), default="present") # present|absent|off
|
||||
note: Mapped[str] = mapped_column(Text, default="") # why absent / off
|
||||
|
||||
|
||||
class DayMark(Base):
|
||||
__tablename__ = "day_mark"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date, unique=True, nullable=False, index=True)
|
||||
kind: Mapped[str] = mapped_column(String(12), default="holiday") # holiday|class_off
|
||||
note: Mapped[str] = mapped_column(String(255), default="")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Pydantic request/response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---- Auth ----
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
username: str
|
||||
|
||||
|
||||
class MeOut(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# ---- Profile ----
|
||||
class ProfileIn(BaseModel):
|
||||
name: str = ""
|
||||
tagline: str = ""
|
||||
bio: str = ""
|
||||
photo_url: str = ""
|
||||
qualifications: str = ""
|
||||
experience_years: int = 0
|
||||
students_taught: int = 0
|
||||
achievements: str = ""
|
||||
hero_cta: str = "Book a free demo"
|
||||
|
||||
|
||||
class ProfileOut(ProfileIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Subject ----
|
||||
class SubjectIn(BaseModel):
|
||||
name: str = ""
|
||||
level: str = ""
|
||||
description: str = ""
|
||||
mode: str = "Online & Offline"
|
||||
icon: str = "📘"
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SubjectOut(SubjectIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Course ----
|
||||
class CourseIn(BaseModel):
|
||||
title: str = ""
|
||||
subject: str = ""
|
||||
description: str = ""
|
||||
duration: str = ""
|
||||
mode: str = "Online & Offline"
|
||||
highlights: str = ""
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CourseOut(CourseIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Fee ----
|
||||
class FeePlanIn(BaseModel):
|
||||
name: str = ""
|
||||
price: str = ""
|
||||
period: str = "per month"
|
||||
features: str = ""
|
||||
highlighted: bool = False
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class FeePlanOut(FeePlanIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Testimonial ----
|
||||
class TestimonialIn(BaseModel):
|
||||
author_name: str = ""
|
||||
relation: str = "Student"
|
||||
quote: str = ""
|
||||
rating: int = 5
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TestimonialOut(TestimonialIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Settings ----
|
||||
class SiteSettingsIn(BaseModel):
|
||||
address: str = ""
|
||||
phone: str = ""
|
||||
email: str = ""
|
||||
hours: str = ""
|
||||
whatsapp: str = ""
|
||||
instagram: str = ""
|
||||
youtube: str = ""
|
||||
facebook: str = ""
|
||||
footer_text: str = ""
|
||||
|
||||
|
||||
class SiteSettingsOut(SiteSettingsIn, ORMModel):
|
||||
id: int
|
||||
|
||||
|
||||
# ---- Review (public feedback, moderated) ----
|
||||
class ReviewIn(BaseModel):
|
||||
author_name: str
|
||||
relation: str = "Student"
|
||||
rating: int = 5
|
||||
message: str
|
||||
|
||||
|
||||
class ReviewOut(ORMModel):
|
||||
id: int
|
||||
author_name: str
|
||||
relation: str
|
||||
rating: int
|
||||
message: str
|
||||
is_approved: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReviewPatch(BaseModel):
|
||||
is_approved: bool
|
||||
|
||||
|
||||
# ---- Enquiry ----
|
||||
class EnquiryIn(BaseModel):
|
||||
name: str
|
||||
email: str = ""
|
||||
phone: str = ""
|
||||
subject_interest: str = ""
|
||||
message: str
|
||||
|
||||
|
||||
class EnquiryOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: str
|
||||
subject_interest: str
|
||||
message: str
|
||||
created_at: datetime
|
||||
is_read: bool
|
||||
|
||||
|
||||
class EnquiryPatch(BaseModel):
|
||||
is_read: bool
|
||||
|
||||
|
||||
# ---- Student management ----
|
||||
class ScheduleSlotIn(BaseModel):
|
||||
weekday: int = 0 # 0=Mon .. 6=Sun
|
||||
start_time: str = ""
|
||||
end_time: str = ""
|
||||
|
||||
|
||||
class ScheduleSlotOut(ScheduleSlotIn, ORMModel):
|
||||
id: int
|
||||
student_id: int
|
||||
|
||||
|
||||
class StudentIn(BaseModel):
|
||||
name: str = ""
|
||||
phone: str = ""
|
||||
parent_name: str = ""
|
||||
parent_phone: str = ""
|
||||
address: str = ""
|
||||
subject: str = ""
|
||||
monthly_fee: int = 0
|
||||
start_date: date | None = None
|
||||
is_active: bool = True
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class StudentOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
phone: str
|
||||
parent_name: str
|
||||
parent_phone: str
|
||||
address: str
|
||||
subject: str
|
||||
monthly_fee: int
|
||||
start_date: date
|
||||
is_active: bool
|
||||
notes: str
|
||||
|
||||
|
||||
class AttendanceIn(BaseModel):
|
||||
student_id: int
|
||||
date: date
|
||||
status: str = "present" # present|absent|off
|
||||
note: str = ""
|
||||
|
||||
|
||||
class AttendanceOut(ORMModel):
|
||||
id: int
|
||||
student_id: int
|
||||
date: date
|
||||
status: str
|
||||
note: str
|
||||
|
||||
|
||||
class AttendanceClearIn(BaseModel):
|
||||
student_id: int
|
||||
date: date
|
||||
|
||||
|
||||
class DayMarkIn(BaseModel):
|
||||
date: date
|
||||
kind: str = "holiday" # holiday|class_off
|
||||
note: str = ""
|
||||
|
||||
|
||||
class DayMarkOut(ORMModel):
|
||||
id: int
|
||||
date: date
|
||||
kind: str
|
||||
note: str
|
||||
|
||||
|
||||
class MonthDataOut(BaseModel):
|
||||
attendance: list[AttendanceOut]
|
||||
day_marks: list[DayMarkOut]
|
||||
|
||||
|
||||
class TodayEntryOut(BaseModel):
|
||||
student_id: int
|
||||
student_name: str
|
||||
subject: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
|
||||
|
||||
class StudentSummaryOut(BaseModel):
|
||||
student_id: int
|
||||
student_name: str
|
||||
monthly_fee: int
|
||||
present: int
|
||||
absent: int
|
||||
off: int
|
||||
|
||||
|
||||
class SummaryOut(BaseModel):
|
||||
month: str
|
||||
active_students: int
|
||||
expected_income: int
|
||||
students: list[StudentSummaryOut]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Password hashing + JWT tokens for the single admin account."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import AdminUser
|
||||
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "swatiyadav-dev-secret-change-me")
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_TTL_HOURS = 24 * 7
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _pwd.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return _pwd.verify(password, hashed)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def create_token(username: str) -> str:
|
||||
payload = {
|
||||
"sub": username,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str | None = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
creds_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not token:
|
||||
raise creds_error
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
except jwt.PyJWTError:
|
||||
raise creds_error from None
|
||||
if not username:
|
||||
raise creds_error
|
||||
user = db.query(AdminUser).filter(AdminUser.username == username).first()
|
||||
if not user:
|
||||
raise creds_error
|
||||
return user
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Create tables and seed the DEVTeacher placeholder content on first run.
|
||||
|
||||
Idempotent: if an admin already exists, nothing is seeded. The default admin is
|
||||
username ``DEVTeacher`` / password ``DEVTeacher`` (the teacher changes it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import Base, SessionLocal, engine
|
||||
from app.models import (
|
||||
AdminUser,
|
||||
Attendance,
|
||||
Course,
|
||||
DayMark,
|
||||
Enquiry,
|
||||
FeePlan,
|
||||
Profile,
|
||||
Review,
|
||||
ScheduleSlot,
|
||||
SiteSettings,
|
||||
Student,
|
||||
Subject,
|
||||
Testimonial,
|
||||
)
|
||||
from app.security import hash_password
|
||||
|
||||
DEFAULT_ADMIN_USER = "DEVTeacher"
|
||||
DEFAULT_ADMIN_PASS = "DEVTeacher"
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
db.add(AdminUser(username=DEFAULT_ADMIN_USER, password_hash=hash_password(DEFAULT_ADMIN_PASS)))
|
||||
|
||||
db.add(
|
||||
Profile(
|
||||
name="Swati Yadav",
|
||||
tagline="Making learning joyful, clear and exam-ready",
|
||||
bio=(
|
||||
"Hello! I'm Swati Yadav, a passionate tuition teacher with years of experience "
|
||||
"helping students build strong fundamentals and real confidence. I focus on "
|
||||
"concept clarity, regular practice and personal attention so every student can "
|
||||
"reach their full potential — whether the goal is topping the class, cracking "
|
||||
"board exams, or simply falling in love with a subject."
|
||||
),
|
||||
photo_url="",
|
||||
qualifications="M.Sc. Mathematics · B.Ed. · Certified Online Educator",
|
||||
experience_years=10,
|
||||
students_taught=500,
|
||||
achievements=(
|
||||
"95% of students improved by 2+ grades\n"
|
||||
"Mentored 50+ board-exam toppers\n"
|
||||
"Personalised study plans for every learner"
|
||||
),
|
||||
hero_cta="Book a free demo class",
|
||||
)
|
||||
)
|
||||
|
||||
subjects = [
|
||||
("Mathematics", "Class 6–12", "Algebra, geometry, calculus and problem-solving made simple with step-by-step methods.", "📐", 1),
|
||||
("Physics", "Class 9–12", "Concept-first physics with real-life examples, numericals and exam techniques.", "🔬", 2),
|
||||
("Chemistry", "Class 9–12", "Organic, inorganic and physical chemistry taught with clarity and easy recall tricks.", "⚗️", 3),
|
||||
("English", "Class 6–10", "Grammar, comprehension, writing skills and confident communication.", "📖", 4),
|
||||
]
|
||||
for name, level, desc, icon, order in subjects:
|
||||
db.add(Subject(name=name, level=level, description=desc, mode="Online & Offline", icon=icon, sort_order=order))
|
||||
|
||||
courses = [
|
||||
(
|
||||
"Board Exam Crash Course",
|
||||
"Mathematics & Science",
|
||||
"An intensive revision programme covering the complete syllabus with mock tests, doubt-clearing and previous-year papers.",
|
||||
"3 months",
|
||||
"Practice tests every week\nPersonal doubt sessions\nComplete syllabus revision",
|
||||
1,
|
||||
),
|
||||
(
|
||||
"Foundation Builder",
|
||||
"All Subjects",
|
||||
"A steady long-term course for younger students that builds strong basics and study habits from the ground up.",
|
||||
"Full academic year",
|
||||
"Concept-first teaching\nRegular homework & feedback\nMonthly progress reports",
|
||||
2,
|
||||
),
|
||||
(
|
||||
"One-on-One Personal Tuition",
|
||||
"Chosen Subject",
|
||||
"Fully personalised private tuition tailored to a single student's pace, goals and weak areas.",
|
||||
"Flexible",
|
||||
"Completely customised plan\nFlexible timings\nDedicated attention",
|
||||
3,
|
||||
),
|
||||
]
|
||||
for title, subject, desc, duration, highlights, order in courses:
|
||||
db.add(
|
||||
Course(
|
||||
title=title,
|
||||
subject=subject,
|
||||
description=desc,
|
||||
duration=duration,
|
||||
mode="Online & Offline",
|
||||
highlights=highlights,
|
||||
sort_order=order,
|
||||
)
|
||||
)
|
||||
|
||||
fees = [
|
||||
("Group Batch", "₹1,500", "per month", "Small batches (max 8)\n3 classes a week\nWeekly tests\nStudy material included", False, 1),
|
||||
("Premium Batch", "₹2,500", "per month", "Mini batches (max 4)\n5 classes a week\nWeekly tests + doubt sessions\nMonthly progress report", True, 2),
|
||||
("1-on-1 Private", "₹4,000", "per month", "Fully personal tuition\nFlexible timings\nCustom study plan\nUnlimited doubt support", False, 3),
|
||||
]
|
||||
for name, price, period, features, highlighted, order in fees:
|
||||
db.add(FeePlan(name=name, price=price, period=period, features=features, highlighted=highlighted, sort_order=order))
|
||||
|
||||
testimonials = [
|
||||
("Aarav Sharma", "Student", "Swati ma'am made maths so easy to understand. My marks jumped from 62 to 91 in just one year!", 5, 1),
|
||||
("Meena Gupta", "Parent", "My daughter actually looks forward to her classes now. The personal attention is wonderful.", 5, 2),
|
||||
("Rohan Verma", "Student", "The best teacher I've ever had. She explains every concept until it truly clicks.", 5, 3),
|
||||
("Kavita Nair", "Parent", "Very disciplined, caring and result-oriented. Highly recommend to every parent.", 5, 4),
|
||||
]
|
||||
for author, relation, quote, rating, order in testimonials:
|
||||
db.add(Testimonial(author_name=author, relation=relation, quote=quote, rating=rating, sort_order=order))
|
||||
|
||||
db.add(
|
||||
SiteSettings(
|
||||
address="123 Vidya Nagar, Near City Library, Your City, 000000",
|
||||
phone="+91 90000 00000",
|
||||
email="hello@swatiyadav.com",
|
||||
hours="Mon–Sat, 9:00 AM – 8:00 PM",
|
||||
whatsapp="https://wa.me/919000000000",
|
||||
instagram="https://instagram.com/",
|
||||
youtube="https://youtube.com/",
|
||||
facebook="https://facebook.com/",
|
||||
footer_text="Empowering students with knowledge, confidence and care.",
|
||||
)
|
||||
)
|
||||
|
||||
db.add(
|
||||
Enquiry(
|
||||
name="Sample Parent",
|
||||
email="parent@example.com",
|
||||
phone="+91 98765 43210",
|
||||
subject_interest="Mathematics",
|
||||
message="Hi, I'd like to know the timings for the Class 10 maths batch. Thank you!",
|
||||
is_read=False,
|
||||
)
|
||||
)
|
||||
|
||||
reviews = [
|
||||
("Priya Deshmukh", "Parent", 5, "The weekly progress updates keep me informed and my son is far more confident now.", True),
|
||||
("Ananya Rao", "Student", 5, "Doubts are always cleared patiently. Highly recommend her classes!", False),
|
||||
]
|
||||
for author, relation, rating, message, approved in reviews:
|
||||
db.add(Review(author_name=author, relation=relation, rating=rating, message=message, is_approved=approved))
|
||||
|
||||
db.commit()
|
||||
|
||||
_seed_students(db)
|
||||
|
||||
|
||||
def _seed_students(db: Session) -> None:
|
||||
"""Sample students, schedules and a little attendance for this month."""
|
||||
students_data = [
|
||||
# name, phone, parent_name, parent_phone, address, subject, fee, slots
|
||||
(
|
||||
"Aarav Sharma", "+91 98111 22233", "Rakesh Sharma", "+91 98111 00011",
|
||||
"12 Green Park, Your City", "Mathematics · Class 10", 1500,
|
||||
[(0, "16:00", "17:00"), (2, "16:00", "17:00"), (4, "16:00", "17:00")],
|
||||
),
|
||||
(
|
||||
"Isha Verma", "+91 98222 33344", "Sunita Verma", "+91 98222 00022",
|
||||
"45 Lake View, Your City", "Physics · Class 12", 2500,
|
||||
[(1, "17:30", "18:30"), (3, "17:30", "18:30")],
|
||||
),
|
||||
(
|
||||
"Rohan Gupta", "+91 98333 44455", "Manoj Gupta", "+91 98333 00033",
|
||||
"78 Rose Villa, Your City", "Chemistry · Class 11", 2500,
|
||||
[(0, "18:00", "19:00"), (3, "18:00", "19:00"), (5, "10:00", "11:00")],
|
||||
),
|
||||
(
|
||||
"Ananya Nair", "+91 98444 55566", "Kavita Nair", "+91 98444 00044",
|
||||
"9 Sunrise Apartments, Your City", "English · Class 8", 1500,
|
||||
[(1, "16:00", "17:00"), (4, "16:00", "17:00")],
|
||||
),
|
||||
]
|
||||
|
||||
student_objs: list[Student] = []
|
||||
for name, phone, pname, pphone, addr, subj, fee, slots in students_data:
|
||||
s = Student(
|
||||
name=name, phone=phone, parent_name=pname, parent_phone=pphone,
|
||||
address=addr, subject=subj, monthly_fee=fee, start_date=date.today(),
|
||||
is_active=True, notes="",
|
||||
)
|
||||
db.add(s)
|
||||
db.flush()
|
||||
for weekday, start, end in slots:
|
||||
db.add(ScheduleSlot(student_id=s.id, weekday=weekday, start_time=start, end_time=end))
|
||||
student_objs.append(s)
|
||||
|
||||
# A little attendance for the first few days of the current month.
|
||||
today = date.today()
|
||||
statuses = ["present", "present", "absent", "present", "off"]
|
||||
for day in range(1, 6):
|
||||
try:
|
||||
d = date(today.year, today.month, day)
|
||||
except ValueError:
|
||||
continue
|
||||
if d > today:
|
||||
break
|
||||
for i, s in enumerate(student_objs):
|
||||
db.add(Attendance(student_id=s.id, date=d, status=statuses[(day + i) % len(statuses)]))
|
||||
|
||||
# A sample holiday on the 1st (kept only if not in the future).
|
||||
try:
|
||||
first = date(today.year, today.month, 1)
|
||||
db.add(DayMark(date=first, kind="holiday", note="Month start holiday"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_columns() -> None:
|
||||
"""Add columns introduced after a table was first created.
|
||||
|
||||
``create_all`` never alters existing tables, so on databases that already
|
||||
have the ``attendance`` table we add the newer ``note`` column by hand.
|
||||
"""
|
||||
inspector = inspect(engine)
|
||||
if "attendance" not in inspector.get_table_names():
|
||||
return
|
||||
cols = {c["name"] for c in inspector.get_columns("attendance")}
|
||||
if "note" not in cols:
|
||||
default = "''" if engine.dialect.name != "sqlite" else "''"
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE attendance ADD COLUMN note TEXT NOT NULL DEFAULT {default}")
|
||||
)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_columns()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(AdminUser).count() == 0:
|
||||
_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user