first commit

This commit is contained in:
Space
2026-01-17 13:37:57 +01:00
commit 3e34d84a29
49 changed files with 8579 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# Backend routes
from . import auth_routes
from . import subject_routes
from . import grade_routes
from . import period_routes
__all__ = ["auth_routes", "subject_routes", "grade_routes", "period_routes"]

View File

@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from datetime import timedelta
from typing import Optional
from models import UserCreate, UserLogin, UserResponse, UserInDB, Token
from auth import get_password_hash, verify_password, create_access_token, decode_access_token
from database import get_database
from config import settings
from bson import ObjectId
router = APIRouter(prefix="/auth", tags=["authentication"])
security = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInDB:
"""Get current authenticated user"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
db = get_database()
user_dict = await db.users.find_one({"_id": ObjectId(user_id)})
if user_dict is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
user_dict["_id"] = str(user_dict["_id"])
return UserInDB(**user_dict)
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate):
"""Register a new user"""
db = get_database()
# Check if user already exists
existing_user = await db.users.find_one({"email": user.email})
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
existing_username = await db.users.find_one({"username": user.username})
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
# Create new user
user_in_db = UserInDB(
email=user.email,
username=user.username,
hashed_password=get_password_hash(user.password)
)
user_dict = user_in_db.model_dump(by_alias=True)
user_dict["_id"] = ObjectId(user_dict["_id"])
result = await db.users.insert_one(user_dict)
user_dict["_id"] = str(result.inserted_id)
# Create access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": str(result.inserted_id)},
expires_delta=access_token_expires
)
user_response = UserResponse(
_id=str(result.inserted_id),
email=user.email,
username=user.username,
created_at=user_in_db.created_at
)
return Token(access_token=access_token, user=user_response)
@router.post("/login", response_model=Token)
async def login(user_login: UserLogin):
"""Login a user"""
db = get_database()
# Find user by email
user_dict = await db.users.find_one({"email": user_login.email})
if not user_dict:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
user_dict["_id"] = str(user_dict["_id"])
user = UserInDB(**user_dict)
# Verify password
if not verify_password(user_login.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.id},
expires_delta=access_token_expires
)
user_response = UserResponse(
_id=user.id,
email=user.email,
username=user.username,
created_at=user.created_at
)
return Token(access_token=access_token, user=user_response)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: UserInDB = Depends(get_current_user)):
"""Get current user information"""
return UserResponse(
_id=current_user.id,
email=current_user.email,
username=current_user.username,
created_at=current_user.created_at
)

View File

@@ -0,0 +1,202 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List, Optional
from models import GradeCreate, GradeUpdate, GradeResponse, GradeInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/grades", tags=["grades"])
@router.post("", response_model=GradeResponse, status_code=status.HTTP_201_CREATED)
async def create_grade(
grade: GradeCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new grade/test entry"""
db = get_database()
# Verify subject exists and belongs to user
if not ObjectId.is_valid(grade.subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(grade.subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Verify category exists in subject
category_names = [cat["name"] for cat in subject.get("grading_categories", [])]
if grade.category_name not in category_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Category '{grade.category_name}' not found in subject. Available: {', '.join(category_names)}"
)
grade_in_db = GradeInDB(
**grade.model_dump(),
user_id=current_user.id
)
grade_dict = grade_in_db.model_dump(by_alias=True)
grade_dict["_id"] = ObjectId(grade_dict["_id"])
result = await db.grades.insert_one(grade_dict)
grade_dict["_id"] = str(result.inserted_id)
return GradeResponse(**grade_dict)
@router.get("", response_model=List[GradeResponse])
async def get_grades(
subject_id: Optional[str] = Query(None),
current_user: UserInDB = Depends(get_current_user)
):
"""Get all grades for the current user, optionally filtered by subject"""
db = get_database()
query = {"user_id": current_user.id}
if subject_id:
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
query["subject_id"] = subject_id
cursor = db.grades.find(query).sort("date", -1) # Most recent first
grades = await cursor.to_list(length=None)
for grade in grades:
grade["_id"] = str(grade["_id"])
return [GradeResponse(**grade) for grade in grades]
@router.get("/{grade_id}", response_model=GradeResponse)
async def get_grade(
grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
grade["_id"] = str(grade["_id"])
return GradeResponse(**grade)
@router.put("/{grade_id}", response_model=GradeResponse)
async def update_grade(
grade_id: str,
grade_update: GradeUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
# Check if grade exists and belongs to user
existing_grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not existing_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
# If updating category, verify it exists in subject
if grade_update.category_name:
subject = await db.subjects.find_one({
"_id": ObjectId(existing_grade["subject_id"]),
"user_id": current_user.id
})
if subject:
category_names = [cat["name"] for cat in subject.get("grading_categories", [])]
if grade_update.category_name not in category_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Category '{grade_update.category_name}' not found in subject"
)
# Update only provided fields
update_data = grade_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.grades.update_one(
{"_id": ObjectId(grade_id)},
{"$set": update_data}
)
# Fetch updated grade
updated_grade = await db.grades.find_one({"_id": ObjectId(grade_id)})
updated_grade["_id"] = str(updated_grade["_id"])
return GradeResponse(**updated_grade)
@router.delete("/{grade_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_grade(
grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
# Check if grade exists and belongs to user
grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
# Delete grade
await db.grades.delete_one({"_id": ObjectId(grade_id)})
return None

View File

@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from models import ReportPeriodCreate, ReportPeriodUpdate, ReportPeriodResponse, ReportPeriodInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/periods", tags=["report-periods"])
@router.post("", response_model=ReportPeriodResponse, status_code=status.HTTP_201_CREATED)
async def create_period(
period: ReportPeriodCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new report period"""
db = get_database()
period_in_db = ReportPeriodInDB(
**period.model_dump(),
user_id=current_user.id
)
period_dict = period_in_db.model_dump(by_alias=True)
period_dict["_id"] = ObjectId(period_dict["_id"])
result = await db.periods.insert_one(period_dict)
period_dict["_id"] = str(result.inserted_id)
return ReportPeriodResponse(**period_dict)
@router.get("", response_model=List[ReportPeriodResponse])
async def get_periods(current_user: UserInDB = Depends(get_current_user)):
"""Get all report periods for the current user"""
db = get_database()
cursor = db.periods.find({"user_id": current_user.id}).sort("start_date", -1)
periods = await cursor.to_list(length=None)
for period in periods:
period["_id"] = str(period["_id"])
return [ReportPeriodResponse(**period) for period in periods]
@router.get("/{period_id}", response_model=ReportPeriodResponse)
async def get_period(
period_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
period["_id"] = str(period["_id"])
return ReportPeriodResponse(**period)
@router.put("/{period_id}", response_model=ReportPeriodResponse)
async def update_period(
period_id: str,
period_update: ReportPeriodUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
# Check if period exists and belongs to user
existing_period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not existing_period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
# Update only provided fields
update_data = period_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.periods.update_one(
{"_id": ObjectId(period_id)},
{"$set": update_data}
)
# Fetch updated period
updated_period = await db.periods.find_one({"_id": ObjectId(period_id)})
updated_period["_id"] = str(updated_period["_id"])
return ReportPeriodResponse(**updated_period)
@router.delete("/{period_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_period(
period_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
# Check if period exists and belongs to user
period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
# Delete period
await db.periods.delete_one({"_id": ObjectId(period_id)})
return None

View File

@@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from models import SubjectCreate, SubjectUpdate, SubjectResponse, SubjectInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/subjects", tags=["subjects"])
@router.post("", response_model=SubjectResponse, status_code=status.HTTP_201_CREATED)
async def create_subject(
subject: SubjectCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new subject"""
db = get_database()
subject_in_db = SubjectInDB(
**subject.model_dump(),
user_id=current_user.id
)
subject_dict = subject_in_db.model_dump(by_alias=True)
subject_dict["_id"] = ObjectId(subject_dict["_id"])
result = await db.subjects.insert_one(subject_dict)
subject_dict["_id"] = str(result.inserted_id)
return SubjectResponse(**subject_dict)
@router.get("", response_model=List[SubjectResponse])
async def get_subjects(current_user: UserInDB = Depends(get_current_user)):
"""Get all subjects for the current user"""
db = get_database()
cursor = db.subjects.find({"user_id": current_user.id})
subjects = await cursor.to_list(length=None)
for subject in subjects:
subject["_id"] = str(subject["_id"])
return [SubjectResponse(**subject) for subject in subjects]
@router.get("/{subject_id}", response_model=SubjectResponse)
async def get_subject(
subject_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific subject"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
subject["_id"] = str(subject["_id"])
return SubjectResponse(**subject)
@router.put("/{subject_id}", response_model=SubjectResponse)
async def update_subject(
subject_id: str,
subject_update: SubjectUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a subject"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
# Check if subject exists and belongs to user
existing_subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not existing_subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Update only provided fields
update_data = subject_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.subjects.update_one(
{"_id": ObjectId(subject_id)},
{"$set": update_data}
)
# Fetch updated subject
updated_subject = await db.subjects.find_one({"_id": ObjectId(subject_id)})
updated_subject["_id"] = str(updated_subject["_id"])
return SubjectResponse(**updated_subject)
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_subject(
subject_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a subject and all associated grades"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
# Check if subject exists and belongs to user
subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Delete subject
await db.subjects.delete_one({"_id": ObjectId(subject_id)})
# Delete all grades for this subject
await db.grades.delete_many({"subject_id": subject_id, "user_id": current_user.id})
return None

View File

@@ -0,0 +1,186 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List, Optional
from models import TeacherGradeCreate, TeacherGradeUpdate, TeacherGradeResponse, TeacherGradeInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/teacher-grades", tags=["teacher-grades"])
@router.post("", response_model=TeacherGradeResponse, status_code=status.HTTP_201_CREATED)
async def create_teacher_grade(
teacher_grade: TeacherGradeCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create or update a teacher-provided grade override for a subject in a period"""
db = get_database()
# Verify subject exists and belongs to user
if not ObjectId.is_valid(teacher_grade.subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(teacher_grade.subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Verify period exists and belongs to user
if not ObjectId.is_valid(teacher_grade.period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
period = await db.periods.find_one({
"_id": ObjectId(teacher_grade.period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Period not found"
)
# Check if teacher grade already exists for this subject and period
existing = await db.teacher_grades.find_one({
"subject_id": teacher_grade.subject_id,
"period_id": teacher_grade.period_id,
"user_id": current_user.id
})
if existing:
# Update existing teacher grade
update_data = teacher_grade.model_dump()
update_data["updated_at"] = datetime.utcnow()
await db.teacher_grades.update_one(
{"_id": existing["_id"]},
{"$set": update_data}
)
updated = await db.teacher_grades.find_one({"_id": existing["_id"]})
updated["_id"] = str(updated["_id"])
return TeacherGradeResponse(**updated)
else:
# Create new teacher grade
teacher_grade_in_db = TeacherGradeInDB(
**teacher_grade.model_dump(),
user_id=current_user.id
)
grade_dict = teacher_grade_in_db.model_dump(by_alias=True)
grade_dict["_id"] = ObjectId(grade_dict["_id"])
result = await db.teacher_grades.insert_one(grade_dict)
grade_dict["_id"] = str(result.inserted_id)
return TeacherGradeResponse(**grade_dict)
@router.get("", response_model=List[TeacherGradeResponse])
async def get_teacher_grades(
subject_id: Optional[str] = Query(None),
period_id: Optional[str] = Query(None),
current_user: UserInDB = Depends(get_current_user)
):
"""Get all teacher grades for the current user, optionally filtered by subject or period"""
db = get_database()
query = {"user_id": current_user.id}
if subject_id:
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
query["subject_id"] = subject_id
if period_id:
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
query["period_id"] = period_id
cursor = db.teacher_grades.find(query)
teacher_grades = await cursor.to_list(length=None)
for grade in teacher_grades:
grade["_id"] = str(grade["_id"])
return [TeacherGradeResponse(**grade) for grade in teacher_grades]
@router.get("/{teacher_grade_id}", response_model=TeacherGradeResponse)
async def get_teacher_grade(
teacher_grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific teacher grade"""
db = get_database()
if not ObjectId.is_valid(teacher_grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid teacher grade ID"
)
teacher_grade = await db.teacher_grades.find_one({
"_id": ObjectId(teacher_grade_id),
"user_id": current_user.id
})
if not teacher_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Teacher grade not found"
)
teacher_grade["_id"] = str(teacher_grade["_id"])
return TeacherGradeResponse(**teacher_grade)
@router.delete("/{teacher_grade_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_teacher_grade(
teacher_grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a teacher grade override"""
db = get_database()
if not ObjectId.is_valid(teacher_grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid teacher grade ID"
)
# Check if teacher grade exists and belongs to user
teacher_grade = await db.teacher_grades.find_one({
"_id": ObjectId(teacher_grade_id),
"user_id": current_user.id
})
if not teacher_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Teacher grade not found"
)
# Delete teacher grade
await db.teacher_grades.delete_one({"_id": ObjectId(teacher_grade_id)})
return None