Implement CBT system, student/teacher/course management, and update documentation

This commit is contained in:
Automated Action 2025-06-03 11:24:36 +00:00
parent aab6d7f29a
commit 3e031c0e4d
37 changed files with 2874 additions and 6 deletions

179
README.md
View File

@ -1,3 +1,178 @@
# FastAPI Application
# Role-Based School Management System with CBT
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
This is a comprehensive school management system with role-based access control and a Computer-Based Testing (CBT) module built with FastAPI and SQLite.
## Features
- **User Management** - Register and manage users with different roles (admin, teacher, student)
- **Role-Based Access Control** - Different permissions based on user roles
- **Student Management** - Register, update, and track student information
- **Teacher Management** - Manage teacher profiles and course assignments
- **Course Management** - Create and manage courses, assign teachers
- **Class Enrollment** - Enroll students in courses, track academic progress
- **Computer-Based Testing (CBT)**:
- **Exam Creation** - Create exams with different question types
- **Question Management** - Add, update, and delete questions and answer options
- **Exam Taking** - Students can take exams and submit answers
- **Result Analysis** - Automatic grading and score calculation
## API Documentation
The API documentation is available at the `/docs` or `/redoc` endpoints when the application is running.
### API Endpoints
#### Authentication
- `POST /api/v1/login/access-token` - Get JWT token
- `POST /api/v1/login/test-token` - Test token validation
#### Users
- `GET /api/v1/users/` - List users
- `POST /api/v1/users/` - Create a new user
- `GET /api/v1/users/{user_id}` - Get user details
- `PUT /api/v1/users/{user_id}` - Update a user
- `DELETE /api/v1/users/{user_id}` - Delete a user
#### Roles
- `GET /api/v1/roles/` - List roles
- `POST /api/v1/roles/` - Create a new role
- `GET /api/v1/roles/{role_id}` - Get role details
- `PUT /api/v1/roles/{role_id}` - Update a role
- `DELETE /api/v1/roles/{role_id}` - Delete a role
#### Students
- `GET /api/v1/students/` - List students
- `POST /api/v1/students/` - Create a new student profile
- `GET /api/v1/students/{student_id}` - Get student details
- `PUT /api/v1/students/{student_id}` - Update a student
- `DELETE /api/v1/students/{student_id}` - Delete a student
- `GET /api/v1/students/{student_id}/exams` - Get student's exam results
#### Teachers
- `GET /api/v1/teachers/` - List teachers
- `POST /api/v1/teachers/` - Create a new teacher profile
- `GET /api/v1/teachers/{teacher_id}` - Get teacher details
- `PUT /api/v1/teachers/{teacher_id}` - Update a teacher
- `DELETE /api/v1/teachers/{teacher_id}` - Delete a teacher
- `GET /api/v1/teachers/{teacher_id}/courses` - Get courses taught by teacher
#### Courses
- `GET /api/v1/courses/` - List courses
- `POST /api/v1/courses/` - Create a new course
- `GET /api/v1/courses/{course_id}` - Get course details
- `PUT /api/v1/courses/{course_id}` - Update a course
- `DELETE /api/v1/courses/{course_id}` - Delete a course
- `GET /api/v1/courses/{course_id}/exams` - Get exams for a course
#### Enrollments
- `GET /api/v1/enrollments/` - List enrollments
- `POST /api/v1/enrollments/` - Create a new enrollment
- `GET /api/v1/enrollments/{enrollment_id}` - Get enrollment details
- `PUT /api/v1/enrollments/{enrollment_id}` - Update an enrollment
- `DELETE /api/v1/enrollments/{enrollment_id}` - Delete an enrollment
#### Exams (CBT)
- `GET /api/v1/exams/` - List exams
- `GET /api/v1/exams/active` - Get active exams
- `GET /api/v1/exams/available` - Get available exams
- `POST /api/v1/exams/` - Create a new exam
- `GET /api/v1/exams/{exam_id}` - Get exam details with questions
- `GET /api/v1/exams/{exam_id}/creator` - Get exam with creator info
- `PUT /api/v1/exams/{exam_id}` - Update an exam
- `DELETE /api/v1/exams/{exam_id}` - Delete an exam
#### Questions (CBT)
- `GET /api/v1/questions/` - List questions
- `POST /api/v1/questions/` - Create a new question
- `GET /api/v1/questions/{question_id}` - Get question with options
- `PUT /api/v1/questions/{question_id}` - Update a question
- `DELETE /api/v1/questions/{question_id}` - Delete a question
#### Question Options (CBT)
- `GET /api/v1/options/` - List question options
- `POST /api/v1/options/` - Create a new option
- `GET /api/v1/options/{option_id}` - Get option details
- `PUT /api/v1/options/{option_id}` - Update an option
- `DELETE /api/v1/options/{option_id}` - Delete an option
#### Exam Results (CBT)
- `GET /api/v1/exam-results/` - List exam results
- `POST /api/v1/exam-results/` - Start a new exam attempt
- `GET /api/v1/exam-results/{result_id}` - Get exam result with answers
- `PUT /api/v1/exam-results/{result_id}` - Update an exam result
- `POST /api/v1/exam-results/{result_id}/complete` - Complete an exam and calculate score
- `DELETE /api/v1/exam-results/{result_id}` - Delete an exam result
### Health Check
- `GET /health` - Check application health status
## Environment Variables
The application uses the following environment variables:
- `SECRET_KEY` - Secret key for the application (default: auto-generated)
- `JWT_SECRET_KEY` - Secret key for JWT token generation (default: same as SECRET_KEY)
- `DATABASE_URL` - SQLite database URL (default: `sqlite:////app/storage/db/db.sqlite`)
- `ENVIRONMENT` - Application environment (default: `development`)
## Getting Started
### Prerequisites
- Python 3.8+
- pip
### Installation
1. Clone the repository
2. Install the dependencies:
```bash
pip install -r requirements.txt
```
3. Run the migrations:
```bash
alembic upgrade head
```
4. Start the application:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
5. Open the API documentation at http://localhost:8000/docs
## Database Schema
The application uses SQLite as the database. The main entities are:
- **User** - Base user model with authentication details
- **Role** - User roles (admin, teacher, student)
- **Student** - Student profile linked to a user
- **Teacher** - Teacher profile linked to a user
- **Course** - Course details and teacher assignment
- **ClassEnrollment** - Student enrollment in courses
- **Exam** - CBT exam definitions
- **Question** - Exam questions with different types
- **QuestionOption** - Answer options for questions
- **ExamResult** - Student exam attempts and scores
- **StudentAnswer** - Student answers to questions
## CBT System
The Computer-Based Testing (CBT) system allows teachers to create exams with different question types, and students to take these exams online. The system supports:
- Multiple choice questions
- True/False questions
- Short answer questions
- Essay questions
Exams can be time-limited, have specific availability periods, and include different scoring mechanisms. After an exam is completed, the system automatically calculates the score based on the correct answers.
## Security
The application uses JWT tokens for authentication and role-based authorization for controlling access to resources. Password hashing is implemented using bcrypt.
## License
This project is licensed under the MIT License.

View File

@ -1,6 +1,9 @@
from fastapi import APIRouter
from app.api.v1.endpoints import login, users, roles, students, teachers, courses, enrollments
from app.api.v1.endpoints import (
login, users, roles, students, teachers, courses, enrollments,
exams, questions, question_options, exam_results
)
api_router = APIRouter()
api_router.include_router(login.router, tags=["login"])
@ -9,4 +12,8 @@ api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(students.router, prefix="/students", tags=["students"])
api_router.include_router(teachers.router, prefix="/teachers", tags=["teachers"])
api_router.include_router(courses.router, prefix="/courses", tags=["courses"])
api_router.include_router(enrollments.router, prefix="/enrollments", tags=["enrollments"])
api_router.include_router(enrollments.router, prefix="/enrollments", tags=["enrollments"])
api_router.include_router(exams.router, prefix="/exams", tags=["exams"])
api_router.include_router(questions.router, prefix="/questions", tags=["questions"])
api_router.include_router(question_options.router, prefix="/options", tags=["question_options"])
api_router.include_router(exam_results.router, prefix="/exam-results", tags=["exam_results"])

View File

@ -0,0 +1,4 @@
# Re-export all endpoint modules
__all__ = ["login", "users", "roles", "exams", "questions", "question_options", "exam_results"]
from app.api.v1.endpoints import login, users, roles, exams, questions, question_options, exam_results

View File

@ -0,0 +1,211 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Response, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[schemas.Course])
def read_courses(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
teacher_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve courses.
"""
if teacher_id:
# Filter courses by teacher_id
courses = db.query(models.Course).filter(models.Course.teacher_id == teacher_id).offset(skip).limit(limit).all()
else:
# Get all courses
courses = db.query(models.Course).offset(skip).limit(limit).all()
return courses
@router.get("/{course_id}", response_model=schemas.CourseWithTeacher)
def read_course(
*,
db: Session = Depends(get_db),
course_id: int = Path(..., title="The ID of the course to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get course by ID with teacher details.
"""
course = db.query(models.Course).filter(models.Course.id == course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Get teacher details if there is a teacher assigned
teacher = None
if course.teacher_id:
teacher = db.query(models.Teacher).filter(models.Teacher.id == course.teacher_id).first()
# Create a combined response
result = schemas.CourseWithTeacher.from_orm(course)
if teacher:
result.teacher = schemas.Teacher.from_orm(teacher)
return result
@router.post("/", response_model=schemas.Course)
def create_course(
*,
db: Session = Depends(get_db),
course_in: schemas.CourseCreate,
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Create new course.
"""
# Check if course code is unique
existing_course = db.query(models.Course).filter(models.Course.course_code == course_in.course_code).first()
if existing_course:
raise HTTPException(
status_code=400,
detail=f"Course with code {course_in.course_code} already exists",
)
# Check if teacher exists if provided
if course_in.teacher_id:
teacher = db.query(models.Teacher).filter(models.Teacher.id == course_in.teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail=f"Teacher with ID {course_in.teacher_id} not found",
)
# Create the course
db_course = models.Course(
course_code=course_in.course_code,
title=course_in.title,
description=course_in.description,
credits=course_in.credits,
is_active=course_in.is_active,
teacher_id=course_in.teacher_id,
)
db.add(db_course)
db.commit()
db.refresh(db_course)
return db_course
@router.put("/{course_id}", response_model=schemas.Course)
def update_course(
*,
db: Session = Depends(get_db),
course_id: int = Path(..., title="The ID of the course to update"),
course_in: schemas.CourseUpdate,
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Update a course.
"""
course = db.query(models.Course).filter(models.Course.id == course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Check if course code is unique if it's being updated
if course_in.course_code and course_in.course_code != course.course_code:
existing_course = db.query(models.Course).filter(models.Course.course_code == course_in.course_code).first()
if existing_course:
raise HTTPException(
status_code=400,
detail=f"Course with code {course_in.course_code} already exists",
)
# Check if teacher exists if provided
if course_in.teacher_id and course_in.teacher_id != course.teacher_id:
teacher = db.query(models.Teacher).filter(models.Teacher.id == course_in.teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail=f"Teacher with ID {course_in.teacher_id} not found",
)
# Update course fields
update_data = course_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(course, field, value)
db.add(course)
db.commit()
db.refresh(course)
return course
@router.delete("/{course_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_course(
*,
db: Session = Depends(get_db),
course_id: int = Path(..., title="The ID of the course to delete"),
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Delete a course.
"""
course = db.query(models.Course).filter(models.Course.id == course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Check if there are any exams associated with this course
exams = db.query(models.Exam).filter(models.Exam.course_id == course_id).first()
if exams:
raise HTTPException(
status_code=400,
detail="Cannot delete course with associated exams",
)
# Check if there are any enrollments for this course
enrollments = db.query(models.ClassEnrollment).filter(models.ClassEnrollment.course_id == course_id).first()
if enrollments:
raise HTTPException(
status_code=400,
detail="Cannot delete course with student enrollments",
)
db.delete(course)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{course_id}/exams", response_model=List[schemas.Exam])
def read_course_exams(
*,
db: Session = Depends(get_db),
course_id: int = Path(..., title="The ID of the course to get exams for"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get all exams for a course.
"""
course = db.query(models.Course).filter(models.Course.id == course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
exams = db.query(models.Exam).filter(models.Exam.course_id == course_id).all()
return exams

View File

@ -0,0 +1,247 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Response, status
from sqlalchemy.orm import Session
from datetime import datetime
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[schemas.ClassEnrollment])
def read_enrollments(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
student_id: Optional[int] = None,
course_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve class enrollments.
"""
# Regular users can only see their own enrollments if they are a student
if not crud.user.is_admin(current_user):
user_student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
if user_student:
student_id = user_student.id
else:
# Check if they are a teacher, in which case they can see enrollments for their courses
user_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not user_teacher:
return []
# Get courses taught by this teacher
teacher_courses = db.query(models.Course).filter(models.Course.teacher_id == user_teacher.id).all()
if not teacher_courses:
return []
course_ids = [course.id for course in teacher_courses]
enrollments = db.query(models.ClassEnrollment).filter(
models.ClassEnrollment.course_id.in_(course_ids)
).offset(skip).limit(limit).all()
return enrollments
# Apply filters
query = db.query(models.ClassEnrollment)
if student_id:
query = query.filter(models.ClassEnrollment.student_id == student_id)
if course_id:
query = query.filter(models.ClassEnrollment.course_id == course_id)
enrollments = query.offset(skip).limit(limit).all()
return enrollments
@router.get("/{enrollment_id}", response_model=schemas.ClassEnrollmentWithDetails)
def read_enrollment(
*,
db: Session = Depends(get_db),
enrollment_id: int = Path(..., title="The ID of the enrollment to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get class enrollment by ID with student and course details.
"""
enrollment = db.query(models.ClassEnrollment).filter(models.ClassEnrollment.id == enrollment_id).first()
if not enrollment:
raise HTTPException(
status_code=404,
detail="Class enrollment not found",
)
# Check permissions
if not crud.user.is_admin(current_user):
# Students can only see their own enrollments
user_student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
if user_student and user_student.id == enrollment.student_id:
pass # OK, student is viewing their own enrollment
else:
# Check if they are a teacher for this course
user_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not user_teacher:
raise HTTPException(
status_code=403,
detail="Not enough permissions to view this enrollment",
)
course = db.query(models.Course).filter(
models.Course.id == enrollment.course_id,
models.Course.teacher_id == user_teacher.id
).first()
if not course:
raise HTTPException(
status_code=403,
detail="Not enough permissions to view this enrollment",
)
# Get student and course details
student = db.query(models.Student).filter(models.Student.id == enrollment.student_id).first()
course = db.query(models.Course).filter(models.Course.id == enrollment.course_id).first()
# Create a combined response
result = schemas.ClassEnrollmentWithDetails.from_orm(enrollment)
result.student = schemas.Student.from_orm(student) if student else None
result.course = schemas.Course.from_orm(course) if course else None
return result
@router.post("/", response_model=schemas.ClassEnrollment)
def create_enrollment(
*,
db: Session = Depends(get_db),
enrollment_in: schemas.ClassEnrollmentCreate,
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Create new class enrollment.
"""
# Check if student exists
student = db.query(models.Student).filter(models.Student.id == enrollment_in.student_id).first()
if not student:
raise HTTPException(
status_code=404,
detail=f"Student with ID {enrollment_in.student_id} not found",
)
# Check if course exists
course = db.query(models.Course).filter(models.Course.id == enrollment_in.course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail=f"Course with ID {enrollment_in.course_id} not found",
)
# Check if student is already enrolled in this course for this semester/academic year
existing_enrollment = db.query(models.ClassEnrollment).filter(
models.ClassEnrollment.student_id == enrollment_in.student_id,
models.ClassEnrollment.course_id == enrollment_in.course_id,
models.ClassEnrollment.semester == enrollment_in.semester,
models.ClassEnrollment.academic_year == enrollment_in.academic_year,
).first()
if existing_enrollment:
raise HTTPException(
status_code=400,
detail=f"Student is already enrolled in this course for {enrollment_in.semester} {enrollment_in.academic_year}",
)
# Create enrollment with current date if not provided
enrollment_data = enrollment_in.dict()
if not enrollment_data.get("enrollment_date"):
enrollment_data["enrollment_date"] = datetime.utcnow()
db_enrollment = models.ClassEnrollment(**enrollment_data)
db.add(db_enrollment)
db.commit()
db.refresh(db_enrollment)
return db_enrollment
@router.put("/{enrollment_id}", response_model=schemas.ClassEnrollment)
def update_enrollment(
*,
db: Session = Depends(get_db),
enrollment_id: int = Path(..., title="The ID of the enrollment to update"),
enrollment_in: schemas.ClassEnrollmentUpdate,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Update a class enrollment.
"""
enrollment = db.query(models.ClassEnrollment).filter(models.ClassEnrollment.id == enrollment_id).first()
if not enrollment:
raise HTTPException(
status_code=404,
detail="Class enrollment not found",
)
# Check permissions - only teachers of the course or admins can update enrollments
if not crud.user.is_admin(current_user):
user_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not user_teacher:
raise HTTPException(
status_code=403,
detail="Not enough permissions to update this enrollment",
)
course = db.query(models.Course).filter(
models.Course.id == enrollment.course_id,
models.Course.teacher_id == user_teacher.id
).first()
if not course:
raise HTTPException(
status_code=403,
detail="Not enough permissions to update this enrollment",
)
# Update enrollment fields
update_data = enrollment_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(enrollment, field, value)
db.add(enrollment)
db.commit()
db.refresh(enrollment)
return enrollment
@router.delete("/{enrollment_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_enrollment(
*,
db: Session = Depends(get_db),
enrollment_id: int = Path(..., title="The ID of the enrollment to delete"),
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Delete a class enrollment.
"""
enrollment = db.query(models.ClassEnrollment).filter(models.ClassEnrollment.id == enrollment_id).first()
if not enrollment:
raise HTTPException(
status_code=404,
detail="Class enrollment not found",
)
# Check if there are any exam results for this student in this course
exam_results = db.query(models.ExamResult).join(
models.Exam, models.Exam.id == models.ExamResult.exam_id
).filter(
models.ExamResult.student_id == enrollment.student_id,
models.Exam.course_id == enrollment.course_id
).first()
if exam_results:
raise HTTPException(
status_code=400,
detail="Cannot delete enrollment with associated exam results",
)
db.delete(enrollment)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,289 @@
from typing import Any, List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Response, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_teacher_user
router = APIRouter()
@router.get("/", response_model=List[schemas.ExamResult])
def read_exam_results(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
exam_id: Optional[int] = None,
student_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve exam results.
"""
# Check permissions
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
is_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first() is not None
# Students can only see their own results
if not (is_admin or is_teacher):
student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
if not student:
raise HTTPException(
status_code=403,
detail="You don't have permission to view these exam results",
)
student_id = student.id
# Apply filters
if exam_id and student_id:
exam_result = crud.exam_result.get_by_student_and_exam(
db, student_id=student_id, exam_id=exam_id
)
return [exam_result] if exam_result else []
elif exam_id:
return crud.exam_result.get_by_exam(db, exam_id=exam_id)
elif student_id:
return crud.exam_result.get_by_student(db, student_id=student_id)
else:
return crud.exam_result.get_multi(db, skip=skip, limit=limit)
@router.post("/", response_model=schemas.ExamResult)
def create_exam_result(
*,
db: Session = Depends(get_db),
exam_result_in: schemas.ExamResultCreate,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Create new exam result (start an exam).
"""
# Check if the exam exists
exam = crud.exam.get(db=db, id=exam_result_in.exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail=f"Exam with ID {exam_result_in.exam_id} not found",
)
# Check if the student exists
student = crud.student.get(db=db, id=exam_result_in.student_id)
if not student:
raise HTTPException(
status_code=404,
detail=f"Student with ID {exam_result_in.student_id} not found",
)
# Check if the current user is the student or has permission
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
is_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first() is not None
if not (is_admin or is_teacher):
if student.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="You don't have permission to start an exam for this student",
)
# Check if the exam is available
now = datetime.utcnow()
if not exam.is_active:
raise HTTPException(
status_code=400,
detail="This exam is not active",
)
if exam.start_time and exam.start_time > now:
raise HTTPException(
status_code=400,
detail="This exam is not yet available",
)
if exam.end_time and exam.end_time < now:
raise HTTPException(
status_code=400,
detail="This exam has expired",
)
# Check if there's already an active attempt
existing_attempt = crud.exam_result.get_active_exam_attempt(
db, student_id=student.id, exam_id=exam.id
)
if existing_attempt:
raise HTTPException(
status_code=400,
detail="There is already an active attempt for this exam",
)
# Create the exam result
exam_result = crud.exam_result.create(db=db, obj_in=exam_result_in)
return exam_result
@router.get("/{result_id}", response_model=schemas.ExamResultWithAnswers)
def read_exam_result(
*,
db: Session = Depends(get_db),
result_id: int = Path(..., title="The ID of the exam result to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get exam result by ID with answers.
"""
exam_result = crud.exam_result.get(db=db, id=result_id)
if not exam_result:
raise HTTPException(
status_code=404,
detail="Exam result not found",
)
# Check permissions
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
is_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first() is not None
# Students can only see their own results
if not (is_admin or is_teacher):
student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
if not student or student.id != exam_result.student_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to view this exam result",
)
# Get answers for this exam result
answers = crud.student_answer.get_by_exam_result(db=db, exam_result_id=result_id)
# Create a combined response
result = schemas.ExamResultWithAnswers.from_orm(exam_result)
result.answers = answers
return result
@router.put("/{result_id}", response_model=schemas.ExamResult)
def update_exam_result(
*,
db: Session = Depends(get_db),
result_id: int = Path(..., title="The ID of the exam result to update"),
result_in: schemas.ExamResultUpdate,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Update an exam result.
"""
exam_result = crud.exam_result.get(db=db, id=result_id)
if not exam_result:
raise HTTPException(
status_code=404,
detail="Exam result not found",
)
# Check permissions
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
is_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first() is not None
# Only teacher and admin can update results directly
if not (is_admin or is_teacher):
raise HTTPException(
status_code=403,
detail="You don't have permission to update this exam result",
)
exam_result = crud.exam_result.update(db=db, db_obj=exam_result, obj_in=result_in)
return exam_result
@router.post("/{result_id}/complete", response_model=schemas.ExamResult)
def complete_exam(
*,
db: Session = Depends(get_db),
result_id: int = Path(..., title="The ID of the exam result to complete"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Complete an exam and calculate the score.
"""
exam_result = crud.exam_result.get(db=db, id=result_id)
if not exam_result:
raise HTTPException(
status_code=404,
detail="Exam result not found",
)
# Check if already completed
if exam_result.is_completed:
raise HTTPException(
status_code=400,
detail="This exam is already completed",
)
# Check permissions
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
is_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first() is not None
# Students can only complete their own exams
if not (is_admin or is_teacher):
student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
if not student or student.id != exam_result.student_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to complete this exam",
)
# Get the exam details
exam = crud.exam.get(db=db, id=exam_result.exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
# Get all questions for this exam
questions = crud.question.get_by_exam_id(db=db, exam_id=exam.id)
# Calculate the maximum possible score
max_score = sum(q.points for q in questions)
# Get all student answers for this exam
answers = crud.student_answer.get_by_exam_result(db=db, exam_result_id=exam_result.id)
# Calculate the score
score = sum(a.points_earned for a in answers)
# Complete the exam
exam_result = crud.exam_result.complete_exam(
db=db, db_obj=exam_result, score=score, max_score=max_score
)
return exam_result
@router.delete("/{result_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_exam_result(
*,
db: Session = Depends(get_db),
result_id: int = Path(..., title="The ID of the exam result to delete"),
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Delete an exam result.
"""
exam_result = crud.exam_result.get(db=db, id=result_id)
if not exam_result:
raise HTTPException(
status_code=404,
detail="Exam result not found",
)
# Only admin can delete results
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
raise HTTPException(
status_code=403,
detail="Only administrators can delete exam results",
)
crud.exam_result.remove(db=db, id=result_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,209 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_teacher_user
router = APIRouter()
@router.get("/", response_model=List[schemas.Exam])
def read_exams(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
course_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve exams.
"""
if course_id:
exams = crud.exam.get_by_course_id(db, course_id=course_id)
else:
exams = crud.exam.get_multi(db, skip=skip, limit=limit)
return exams
@router.get("/active", response_model=List[schemas.Exam])
def read_active_exams(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve active exams.
"""
exams = crud.exam.get_active_exams(db, skip=skip, limit=limit)
return exams
@router.get("/available", response_model=List[schemas.Exam])
def read_available_exams(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve available exams (active and within time window).
"""
exams = crud.exam.get_available_exams(db, skip=skip, limit=limit)
return exams
@router.post("/", response_model=schemas.Exam)
def create_exam(
*,
db: Session = Depends(get_db),
exam_in: schemas.ExamCreate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Create new exam.
"""
# Check if the course exists
course = db.query(models.Course).filter(models.Course.id == exam_in.course_id).first()
if not course:
raise HTTPException(
status_code=404,
detail=f"Course with ID {exam_in.course_id} not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to create an exam for this course",
)
# Set the created_by field to the current user's ID
exam_data = exam_in.dict()
exam_data["created_by"] = current_user.id
exam_obj = schemas.ExamCreate(**exam_data)
exam = crud.exam.create(db=db, obj_in=exam_obj)
return exam
@router.get("/{exam_id}", response_model=schemas.ExamWithQuestions)
def read_exam(
*,
db: Session = Depends(get_db),
exam_id: int,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get exam by ID with questions.
"""
exam = crud.exam.get(db=db, id=exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
# Get questions for this exam
questions = crud.question.get_by_exam_id(db=db, exam_id=exam_id)
# Create a combined response
result = schemas.ExamWithQuestions.from_orm(exam)
result.questions = questions
return result
@router.get("/{exam_id}/creator", response_model=schemas.ExamWithCreator)
def read_exam_with_creator(
*,
db: Session = Depends(get_db),
exam_id: int,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get exam by ID with creator information.
"""
exam = crud.exam.get(db=db, id=exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
# Create a combined response
result = schemas.ExamWithCreator.from_orm(exam)
return result
@router.put("/{exam_id}", response_model=schemas.Exam)
def update_exam(
*,
db: Session = Depends(get_db),
exam_id: int,
exam_in: schemas.ExamUpdate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Update an exam.
"""
exam = crud.exam.get(db=db, id=exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to update this exam",
)
exam = crud.exam.update(db=db, db_obj=exam, obj_in=exam_in)
return exam
@router.delete("/{exam_id}", response_model=schemas.Exam)
def delete_exam(
*,
db: Session = Depends(get_db),
exam_id: int,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Delete an exam.
"""
exam = crud.exam.get(db=db, id=exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to delete this exam",
)
exam = crud.exam.remove(db=db, id=exam_id)
return exam

View File

@ -0,0 +1,202 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_teacher_user
router = APIRouter()
@router.get("/", response_model=List[schemas.QuestionOption])
def read_options(
db: Session = Depends(get_db),
question_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve question options.
"""
if question_id:
options = crud.question_option.get_by_question_id(db, question_id=question_id)
else:
options = crud.question_option.get_multi(db)
return options
@router.post("/", response_model=schemas.QuestionOption)
def create_option(
*,
db: Session = Depends(get_db),
option_in: schemas.QuestionOptionCreate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Create new question option.
"""
# Check if the question exists
question = crud.question.get(db=db, id=option_in.question_id)
if not question:
raise HTTPException(
status_code=404,
detail=f"Question with ID {option_in.question_id} not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
exam = db.query(models.Exam).filter(models.Exam.id == question.exam_id).first()
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to create an option for this question",
)
# If this is marked as correct, check if there are other correct options
if option_in.is_correct and question.question_type != schemas.QuestionType.MULTIPLE_CHOICE:
existing_correct = crud.question_option.get_correct_option(db=db, question_id=question.id)
if existing_correct:
# For non-multiple-choice questions, only one option can be correct
raise HTTPException(
status_code=400,
detail=f"This question type ({question.question_type}) can only have one correct option",
)
option = crud.question_option.create(db=db, obj_in=option_in)
return option
@router.get("/{option_id}", response_model=schemas.QuestionOption)
def read_option(
*,
db: Session = Depends(get_db),
option_id: int = Path(..., title="The ID of the option to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get question option by ID.
"""
option = crud.question_option.get(db=db, id=option_id)
if not option:
raise HTTPException(
status_code=404,
detail="Question option not found",
)
return option
@router.put("/{option_id}", response_model=schemas.QuestionOption)
def update_option(
*,
db: Session = Depends(get_db),
option_id: int = Path(..., title="The ID of the option to update"),
option_in: schemas.QuestionOptionUpdate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Update a question option.
"""
option = crud.question_option.get(db=db, id=option_id)
if not option:
raise HTTPException(
status_code=404,
detail="Question option not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
question = db.query(models.Question).filter(models.Question.id == option.question_id).first()
if not question:
raise HTTPException(
status_code=404,
detail="Question not found",
)
exam = db.query(models.Exam).filter(models.Exam.id == question.exam_id).first()
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to update this question option",
)
# If changing to correct, check if there are other correct options
if option_in.is_correct is not None and option_in.is_correct:
question = db.query(models.Question).filter(models.Question.id == option.question_id).first()
if question and question.question_type != schemas.QuestionType.MULTIPLE_CHOICE:
existing_correct = crud.question_option.get_correct_option(db=db, question_id=question.id)
if existing_correct and existing_correct.id != option_id:
# For non-multiple-choice questions, only one option can be correct
raise HTTPException(
status_code=400,
detail=f"This question type ({question.question_type}) can only have one correct option",
)
option = crud.question_option.update(db=db, db_obj=option, obj_in=option_in)
return option
@router.delete("/{option_id}", response_model=schemas.QuestionOption)
def delete_option(
*,
db: Session = Depends(get_db),
option_id: int = Path(..., title="The ID of the option to delete"),
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Delete a question option.
"""
option = crud.question_option.get(db=db, id=option_id)
if not option:
raise HTTPException(
status_code=404,
detail="Question option not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
question = db.query(models.Question).filter(models.Question.id == option.question_id).first()
if not question:
raise HTTPException(
status_code=404,
detail="Question not found",
)
exam = db.query(models.Exam).filter(models.Exam.id == question.exam_id).first()
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to delete this question option",
)
option = crud.question_option.remove(db=db, id=option_id)
return option

View File

@ -0,0 +1,169 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_teacher_user
router = APIRouter()
@router.get("/", response_model=List[schemas.Question])
def read_questions(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
exam_id: Optional[int] = None,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve questions.
"""
if exam_id:
questions = crud.question.get_by_exam_id(db, exam_id=exam_id)
else:
questions = crud.question.get_multi(db, skip=skip, limit=limit)
return questions
@router.post("/", response_model=schemas.Question)
def create_question(
*,
db: Session = Depends(get_db),
question_in: schemas.QuestionCreate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Create new question.
"""
# Check if the exam exists
exam = crud.exam.get(db=db, id=question_in.exam_id)
if not exam:
raise HTTPException(
status_code=404,
detail=f"Exam with ID {question_in.exam_id} not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to create a question for this exam",
)
question = crud.question.create(db=db, obj_in=question_in)
return question
@router.get("/{question_id}", response_model=schemas.QuestionWithOptions)
def read_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., title="The ID of the question to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get question by ID with options.
"""
question = crud.question.get(db=db, id=question_id)
if not question:
raise HTTPException(
status_code=404,
detail="Question not found",
)
# Get options for this question
options = crud.question_option.get_by_question_id(db=db, question_id=question_id)
# Create a combined response
result = schemas.QuestionWithOptions.from_orm(question)
result.options = options
return result
@router.put("/{question_id}", response_model=schemas.Question)
def update_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., title="The ID of the question to update"),
question_in: schemas.QuestionUpdate,
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Update a question.
"""
question = crud.question.get(db=db, id=question_id)
if not question:
raise HTTPException(
status_code=404,
detail="Question not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
exam = db.query(models.Exam).filter(models.Exam.id == question.exam_id).first()
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to update this question",
)
question = crud.question.update(db=db, db_obj=question, obj_in=question_in)
return question
@router.delete("/{question_id}", response_model=schemas.Question)
def delete_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., title="The ID of the question to delete"),
current_user: models.User = Depends(get_teacher_user),
) -> Any:
"""
Delete a question.
"""
question = crud.question.get(db=db, id=question_id)
if not question:
raise HTTPException(
status_code=404,
detail="Question not found",
)
# Check if the user is a teacher of this course or an admin
is_admin = any(role.name == "admin" for role in db.query(models.Role).all() if role.id == current_user.role_id)
if not is_admin:
exam = db.query(models.Exam).filter(models.Exam.id == question.exam_id).first()
if not exam:
raise HTTPException(
status_code=404,
detail="Exam not found",
)
course = db.query(models.Course).filter(models.Course.id == exam.course_id).first()
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
if not teacher or not course or teacher.id != course.teacher_id:
raise HTTPException(
status_code=403,
detail="You don't have permission to delete this question",
)
question = crud.question.remove(db=db, id=question_id)
return question

View File

@ -0,0 +1,188 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, Path, Response, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[schemas.Student])
def read_students(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve students.
"""
# Regular users can only see their own student profile if they are a student
if crud.user.is_admin(current_user):
students = crud.student.get_multi(db, skip=skip, limit=limit)
else:
student = db.query(models.Student).filter(models.Student.user_id == current_user.id).first()
students = [student] if student else []
return students
@router.get("/{student_id}", response_model=schemas.StudentWithUser)
def read_student(
*,
db: Session = Depends(get_db),
student_id: int = Path(..., title="The ID of the student to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get student by ID with user details.
"""
student = crud.student.get(db=db, id=student_id)
if not student:
raise HTTPException(
status_code=404,
detail="Student not found",
)
# Regular users can only see their own student profile
if not crud.user.is_admin(current_user) and student.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not enough permissions to view this student profile"
)
# Get user details
user = crud.user.get(db=db, id=student.user_id)
# Create a combined response
result = schemas.StudentWithUser.from_orm(student)
result.user = user
return result
@router.post("/", response_model=schemas.Student)
def create_student(
*,
db: Session = Depends(get_db),
student_in: schemas.StudentCreate,
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Create new student.
"""
# Check if the user exists
user = crud.user.get(db=db, id=student_in.user_id)
if not user:
raise HTTPException(
status_code=404,
detail=f"User with ID {student_in.user_id} not found",
)
# Check if student profile already exists for this user
existing_student = db.query(models.Student).filter(models.Student.user_id == user.id).first()
if existing_student:
raise HTTPException(
status_code=400,
detail=f"Student profile already exists for user ID {user.id}",
)
# Check if student_id is unique
existing_student_id = db.query(models.Student).filter(models.Student.student_id == student_in.student_id).first()
if existing_student_id:
raise HTTPException(
status_code=400,
detail=f"Student ID {student_in.student_id} is already taken",
)
student = crud.student.create(db=db, obj_in=student_in)
return student
@router.put("/{student_id}", response_model=schemas.Student)
def update_student(
*,
db: Session = Depends(get_db),
student_id: int = Path(..., title="The ID of the student to update"),
student_in: schemas.StudentUpdate,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Update a student.
"""
student = crud.student.get(db=db, id=student_id)
if not student:
raise HTTPException(
status_code=404,
detail="Student not found",
)
# Regular users can only update their own student profile
if not crud.user.is_admin(current_user) and student.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not enough permissions to update this student profile"
)
# Check if student_id is unique if it's being updated
if student_in.student_id and student_in.student_id != student.student_id:
existing_student_id = db.query(models.Student).filter(models.Student.student_id == student_in.student_id).first()
if existing_student_id:
raise HTTPException(
status_code=400,
detail=f"Student ID {student_in.student_id} is already taken",
)
student = crud.student.update(db=db, db_obj=student, obj_in=student_in)
return student
@router.delete("/{student_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_student(
*,
db: Session = Depends(get_db),
student_id: int = Path(..., title="The ID of the student to delete"),
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Delete a student.
"""
student = crud.student.get(db=db, id=student_id)
if not student:
raise HTTPException(
status_code=404,
detail="Student not found",
)
crud.student.remove(db=db, id=student_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{student_id}/exams", response_model=List[schemas.ExamResult])
def read_student_exam_results(
*,
db: Session = Depends(get_db),
student_id: int = Path(..., title="The ID of the student to get exam results for"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get all exam results for a student.
"""
student = crud.student.get(db=db, id=student_id)
if not student:
raise HTTPException(
status_code=404,
detail="Student not found",
)
# Regular users can only see their own exam results
if not crud.user.is_admin(current_user) and student.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not enough permissions to view this student's exam results"
)
exam_results = crud.exam_result.get_by_student(db=db, student_id=student_id)
return exam_results

View File

@ -0,0 +1,204 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, Path, Response, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api.v1.deps import get_db, get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[schemas.Teacher])
def read_teachers(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve teachers.
"""
# Regular users can only see their own teacher profile if they are a teacher
if crud.user.is_admin(current_user):
teachers = db.query(models.Teacher).offset(skip).limit(limit).all()
else:
teacher = db.query(models.Teacher).filter(models.Teacher.user_id == current_user.id).first()
teachers = [teacher] if teacher else []
return teachers
@router.get("/{teacher_id}", response_model=schemas.TeacherWithUser)
def read_teacher(
*,
db: Session = Depends(get_db),
teacher_id: int = Path(..., title="The ID of the teacher to get"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get teacher by ID.
"""
teacher = db.query(models.Teacher).filter(models.Teacher.id == teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail="Teacher not found",
)
# Regular users can only see their own teacher profile
if not crud.user.is_admin(current_user) and teacher.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not enough permissions to view this teacher profile"
)
# Get user details
user = crud.user.get(db=db, id=teacher.user_id)
# Create a combined response
result = schemas.TeacherWithUser.from_orm(teacher)
result.user = user
return result
@router.post("/", response_model=schemas.Teacher)
def create_teacher(
*,
db: Session = Depends(get_db),
teacher_in: schemas.teacher.TeacherCreate,
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Create new teacher.
"""
# Check if the user exists
user = crud.user.get(db=db, id=teacher_in.user_id)
if not user:
raise HTTPException(
status_code=404,
detail=f"User with ID {teacher_in.user_id} not found",
)
# Check if teacher profile already exists for this user
existing_teacher = db.query(models.Teacher).filter(models.Teacher.user_id == user.id).first()
if existing_teacher:
raise HTTPException(
status_code=400,
detail=f"Teacher profile already exists for user ID {user.id}",
)
# Check if teacher_id is unique
existing_teacher_id = db.query(models.Teacher).filter(models.Teacher.teacher_id == teacher_in.teacher_id).first()
if existing_teacher_id:
raise HTTPException(
status_code=400,
detail=f"Teacher ID {teacher_in.teacher_id} is already taken",
)
# Create teacher profile
db_teacher = models.Teacher(
teacher_id=teacher_in.teacher_id,
date_of_birth=teacher_in.date_of_birth,
address=teacher_in.address,
phone_number=teacher_in.phone_number,
department=teacher_in.department,
hire_date=teacher_in.hire_date,
user_id=teacher_in.user_id,
)
db.add(db_teacher)
db.commit()
db.refresh(db_teacher)
return db_teacher
@router.put("/{teacher_id}", response_model=schemas.Teacher)
def update_teacher(
*,
db: Session = Depends(get_db),
teacher_id: int = Path(..., title="The ID of the teacher to update"),
teacher_in: schemas.teacher.TeacherUpdate,
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Update a teacher.
"""
teacher = db.query(models.Teacher).filter(models.Teacher.id == teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail="Teacher not found",
)
# Regular users can only update their own teacher profile
if not crud.user.is_admin(current_user) and teacher.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not enough permissions to update this teacher profile"
)
# Check if teacher_id is unique if it's being updated
if teacher_in.teacher_id and teacher_in.teacher_id != teacher.teacher_id:
existing_teacher_id = db.query(models.Teacher).filter(models.Teacher.teacher_id == teacher_in.teacher_id).first()
if existing_teacher_id:
raise HTTPException(
status_code=400,
detail=f"Teacher ID {teacher_in.teacher_id} is already taken",
)
# Update teacher fields
update_data = teacher_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(teacher, field, value)
db.add(teacher)
db.commit()
db.refresh(teacher)
return teacher
@router.delete("/{teacher_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT)
def delete_teacher(
*,
db: Session = Depends(get_db),
teacher_id: int = Path(..., title="The ID of the teacher to delete"),
current_user: models.User = Depends(get_admin_user),
) -> Any:
"""
Delete a teacher.
"""
teacher = db.query(models.Teacher).filter(models.Teacher.id == teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail="Teacher not found",
)
db.delete(teacher)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{teacher_id}/courses", response_model=List[schemas.Course])
def read_teacher_courses(
*,
db: Session = Depends(get_db),
teacher_id: int = Path(..., title="The ID of the teacher to get courses for"),
current_user: models.User = Depends(get_current_active_user),
) -> Any:
"""
Get all courses taught by a teacher.
"""
teacher = db.query(models.Teacher).filter(models.Teacher.id == teacher_id).first()
if not teacher:
raise HTTPException(
status_code=404,
detail="Teacher not found",
)
courses = db.query(models.Course).filter(models.Course.teacher_id == teacher_id).all()
return courses

View File

@ -25,6 +25,9 @@ class Settings(BaseSettings):
PROJECT_NAME: str = "Role-Based School Management System"
# Environment
ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "development")
# Database configuration
SQLALCHEMY_DATABASE_URL: str = os.environ.get(
"DATABASE_URL", "sqlite:////app/storage/db/db.sqlite"

View File

@ -1,5 +1,19 @@
from app.crud.crud_user import user
from app.crud.crud_role import role
from app.crud.crud_student import student
from app.crud.crud_exam import exam
from app.crud.crud_question import question
from app.crud.crud_question_option import question_option
from app.crud.crud_exam_result import exam_result
from app.crud.crud_student_answer import student_answer
__all__ = ["user", "role", "student"]
__all__ = [
"user",
"role",
"student",
"exam",
"question",
"question_option",
"exam_result",
"student_answer"
]

45
app/crud/crud_exam.py Normal file
View File

@ -0,0 +1,45 @@
from typing import List
from sqlalchemy.orm import Session
from datetime import datetime
from app.crud.base import CRUDBase
from app.models.exam import Exam
from app.schemas.exam import ExamCreate, ExamUpdate
class CRUDExam(CRUDBase[Exam, ExamCreate, ExamUpdate]):
def get_by_course_id(self, db: Session, *, course_id: int) -> List[Exam]:
"""Get all exams for a specific course"""
return db.query(self.model).filter(Exam.course_id == course_id).all()
def get_by_creator(self, db: Session, *, creator_id: int) -> List[Exam]:
"""Get all exams created by a specific user"""
return db.query(self.model).filter(Exam.created_by == creator_id).all()
def get_active_exams(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Exam]:
"""Get all active exams"""
return (
db.query(self.model)
.filter(Exam.is_active)
.offset(skip)
.limit(limit)
.all()
)
def get_available_exams(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Exam]:
"""Get all exams that are currently available (active and within time window)"""
now = datetime.utcnow()
return (
db.query(self.model)
.filter(
Exam.is_active,
(Exam.start_time.is_(None) | (Exam.start_time <= now)),
(Exam.end_time.is_(None) | (Exam.end_time >= now)),
)
.offset(skip)
.limit(limit)
.all()
)
exam = CRUDExam(Exam)

View File

@ -0,0 +1,60 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from datetime import datetime
from app.crud.base import CRUDBase
from app.models.exam_result import ExamResult
from app.schemas.exam_result import ExamResultCreate, ExamResultUpdate
class CRUDExamResult(CRUDBase[ExamResult, ExamResultCreate, ExamResultUpdate]):
def get_by_student(self, db: Session, *, student_id: int) -> List[ExamResult]:
"""Get all exam results for a specific student"""
return db.query(self.model).filter(ExamResult.student_id == student_id).all()
def get_by_exam(self, db: Session, *, exam_id: int) -> List[ExamResult]:
"""Get all exam results for a specific exam"""
return db.query(self.model).filter(ExamResult.exam_id == exam_id).all()
def get_by_student_and_exam(
self, db: Session, *, student_id: int, exam_id: int
) -> Optional[ExamResult]:
"""Get a student's result for a specific exam"""
return (
db.query(self.model)
.filter(
ExamResult.student_id == student_id,
ExamResult.exam_id == exam_id
)
.first()
)
def get_active_exam_attempt(
self, db: Session, *, student_id: int, exam_id: int
) -> Optional[ExamResult]:
"""Get a student's active (incomplete) attempt for an exam"""
return (
db.query(self.model)
.filter(
ExamResult.student_id == student_id,
ExamResult.exam_id == exam_id,
~ExamResult.is_completed
)
.first()
)
def complete_exam(
self, db: Session, *, db_obj: ExamResult, score: float, max_score: float
) -> ExamResult:
"""Mark an exam as completed with the final score"""
db_obj.score = score
db_obj.max_score = max_score
db_obj.completed_at = datetime.utcnow()
db_obj.is_completed = True
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
exam_result = CRUDExamResult(ExamResult)

36
app/crud/crud_question.py Normal file
View File

@ -0,0 +1,36 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.question import Question, QuestionType
from app.schemas.question import QuestionCreate, QuestionUpdate
class CRUDQuestion(CRUDBase[Question, QuestionCreate, QuestionUpdate]):
def get_by_exam_id(self, db: Session, *, exam_id: int) -> List[Question]:
"""Get all questions for a specific exam"""
return db.query(self.model).filter(Question.exam_id == exam_id).all()
def get_by_exam_id_and_type(
self, db: Session, *, exam_id: int, question_type: QuestionType
) -> List[Question]:
"""Get all questions of a specific type for an exam"""
return (
db.query(self.model)
.filter(
Question.exam_id == exam_id,
Question.question_type == question_type
)
.all()
)
def get_with_options(self, db: Session, *, id: int) -> Optional[Question]:
"""Get a question with its options"""
return (
db.query(self.model)
.filter(Question.id == id)
.first()
)
question = CRUDQuestion(Question)

View File

@ -0,0 +1,26 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.question_option import QuestionOption
from app.schemas.question_option import QuestionOptionCreate, QuestionOptionUpdate
class CRUDQuestionOption(CRUDBase[QuestionOption, QuestionOptionCreate, QuestionOptionUpdate]):
def get_by_question_id(self, db: Session, *, question_id: int) -> List[QuestionOption]:
"""Get all options for a specific question"""
return db.query(self.model).filter(QuestionOption.question_id == question_id).all()
def get_correct_option(self, db: Session, *, question_id: int) -> Optional[QuestionOption]:
"""Get the correct option for a question"""
return (
db.query(self.model)
.filter(
QuestionOption.question_id == question_id,
QuestionOption.is_correct
)
.first()
)
question_option = CRUDQuestionOption(QuestionOption)

View File

@ -0,0 +1,49 @@
from typing import List, Optional, Dict, Any, Union
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.student_answer import StudentAnswer
from app.schemas.student_answer import StudentAnswerCreate, StudentAnswerUpdate
class CRUDStudentAnswer(CRUDBase[StudentAnswer, StudentAnswerCreate, StudentAnswerUpdate]):
def get_by_exam_result(self, db: Session, *, exam_result_id: int) -> List[StudentAnswer]:
"""Get all answers for a specific exam result"""
return db.query(self.model).filter(StudentAnswer.exam_result_id == exam_result_id).all()
def get_by_question(
self, db: Session, *, exam_result_id: int, question_id: int
) -> Optional[StudentAnswer]:
"""Get a student's answer for a specific question in an exam"""
return (
db.query(self.model)
.filter(
StudentAnswer.exam_result_id == exam_result_id,
StudentAnswer.question_id == question_id
)
.first()
)
def update_answer(
self,
db: Session,
*,
db_obj: StudentAnswer,
obj_in: Union[StudentAnswerUpdate, Dict[str, Any]]
) -> StudentAnswer:
"""Update a student's answer"""
return super().update(db, db_obj=db_obj, obj_in=obj_in)
def grade_answer(
self, db: Session, *, db_obj: StudentAnswer, is_correct: bool, points_earned: float
) -> StudentAnswer:
"""Grade a student's answer"""
db_obj.is_correct = is_correct
db_obj.points_earned = points_earned
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
student_answer = CRUDStudentAnswer(StudentAnswer)

View File

@ -53,6 +53,13 @@ class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def is_active(self, user: User) -> bool:
return user.is_active
def is_admin(self, user: User) -> bool:
"""Check if a user has admin role"""
if not user:
return False
# Assuming that the role with id 1 or name 'admin' is the admin role
return user.role_id == 1 or user.role.name.lower() == 'admin'
user = CRUDUser(User)

View File

@ -0,0 +1,17 @@
# Re-export all model modules
__all__ = [
"User", "Role", "Student", "Teacher", "Course", "ClassEnrollment",
"Exam", "Question", "QuestionOption", "ExamResult", "StudentAnswer"
]
from app.models.user import User
from app.models.role import Role
from app.models.student import Student
from app.models.teacher import Teacher
from app.models.course import Course
from app.models.class_enrollment import ClassEnrollment
from app.models.exam import Exam
from app.models.question import Question
from app.models.question_option import QuestionOption
from app.models.exam_result import ExamResult
from app.models.student_answer import StudentAnswer

30
app/models/exam.py Normal file
View File

@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Exam(Base):
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
course_id = Column(Integer, ForeignKey("course.id"), nullable=False)
duration_minutes = Column(Integer, default=60)
passing_score = Column(Float, default=50.0) # Percentage required to pass
is_active = Column(Boolean, default=True)
is_randomized = Column(Boolean, default=False) # Whether questions should be randomized
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
start_time = Column(DateTime, nullable=True) # When the exam becomes available
end_time = Column(DateTime, nullable=True) # When the exam becomes unavailable
created_by = Column(Integer, ForeignKey("user.id"), nullable=True)
# Relationships
course = relationship("Course", backref="exams")
questions = relationship("Question", back_populates="exam", cascade="all, delete-orphan")
exam_results = relationship("ExamResult", back_populates="exam", cascade="all, delete-orphan")
creator = relationship("User", back_populates="created_exams")
def __repr__(self):
return f"<Exam {self.title}>"

25
app/models/exam_result.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class ExamResult(Base):
id = Column(Integer, primary_key=True, index=True)
exam_id = Column(Integer, ForeignKey("exam.id"), nullable=False)
student_id = Column(Integer, ForeignKey("student.id"), nullable=False)
score = Column(Float, nullable=True) # Final score
max_score = Column(Float, nullable=True) # Maximum possible score
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
is_completed = Column(Boolean, default=False)
remarks = Column(Text, nullable=True)
# Relationships
exam = relationship("Exam", back_populates="exam_results")
student = relationship("Student", backref="exam_results")
answers = relationship("StudentAnswer", back_populates="exam_result", cascade="all, delete-orphan")
def __repr__(self):
return f"<ExamResult {self.id}: Student {self.student_id}, Exam {self.exam_id}>"

29
app/models/question.py Normal file
View File

@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Text, Float, ForeignKey, Enum
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class QuestionType(str, enum.Enum):
MULTIPLE_CHOICE = "multiple_choice"
TRUE_FALSE = "true_false"
SHORT_ANSWER = "short_answer"
ESSAY = "essay"
class Question(Base):
id = Column(Integer, primary_key=True, index=True)
exam_id = Column(Integer, ForeignKey("exam.id"), nullable=False)
text = Column(Text, nullable=False)
question_type = Column(Enum(QuestionType), nullable=False, default=QuestionType.MULTIPLE_CHOICE)
points = Column(Float, default=1.0)
image_url = Column(String(255), nullable=True) # Optional image for the question
solution = Column(Text, nullable=True) # Explanation of the answer
# Relationships
exam = relationship("Exam", back_populates="questions")
options = relationship("QuestionOption", back_populates="question", cascade="all, delete-orphan")
def __repr__(self):
return f"<Question {self.id}: {self.text[:30]}...>"

View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class QuestionOption(Base):
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("question.id"), nullable=False)
text = Column(Text, nullable=False)
is_correct = Column(Boolean, default=False)
# Relationships
question = relationship("Question", back_populates="options")
def __repr__(self):
return f"<QuestionOption {self.id}: {self.text[:30]}...>"

View File

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey, Float
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class StudentAnswer(Base):
id = Column(Integer, primary_key=True, index=True)
exam_result_id = Column(Integer, ForeignKey("exam_result.id"), nullable=False)
question_id = Column(Integer, ForeignKey("question.id"), nullable=False)
selected_option_id = Column(Integer, ForeignKey("question_option.id"), nullable=True) # For multiple choice/true-false
text_answer = Column(Text, nullable=True) # For short answer/essay
is_correct = Column(Boolean, nullable=True) # Whether the answer is correct
points_earned = Column(Float, default=0.0) # Points earned for this answer
# Relationships
exam_result = relationship("ExamResult", back_populates="answers")
question = relationship("Question")
selected_option = relationship("QuestionOption")
def __repr__(self):
return f"<StudentAnswer {self.id}: Result {self.exam_result_id}, Question {self.question_id}>"

View File

@ -23,6 +23,7 @@ class User(Base):
role = relationship("Role", back_populates="users")
student = relationship("Student", back_populates="user", uselist=False)
teacher = relationship("Teacher", back_populates="user", uselist=False)
created_exams = relationship("Exam", back_populates="creator")
def __repr__(self):
return f"<User {self.username}>"

View File

@ -0,0 +1,40 @@
# Re-export all schema modules
__all__ = [
# Token schemas
"Token", "TokenPayload",
# User schemas
"User", "UserCreate", "UserUpdate", "UserInDB",
# Role schemas
"Role", "RoleCreate", "RoleUpdate",
# Student schemas
"Student", "StudentCreate", "StudentUpdate", "StudentWithUser",
# Teacher schemas
"Teacher", "TeacherCreate", "TeacherUpdate", "TeacherWithUser",
# Course schemas
"Course", "CourseCreate", "CourseUpdate", "CourseWithTeacher",
# Class enrollment schemas
"ClassEnrollment", "ClassEnrollmentCreate", "ClassEnrollmentUpdate", "ClassEnrollmentWithDetails",
# Exam schemas
"Exam", "ExamCreate", "ExamUpdate", "ExamWithQuestions", "ExamWithCreator",
# Question schemas
"Question", "QuestionCreate", "QuestionUpdate", "QuestionWithOptions", "QuestionType",
# Question option schemas
"QuestionOption", "QuestionOptionCreate", "QuestionOptionUpdate", "QuestionOptionForExam",
# Exam result schemas
"ExamResult", "ExamResultCreate", "ExamResultUpdate", "ExamResultWithAnswers",
# Student answer schemas
"StudentAnswer", "StudentAnswerCreate", "StudentAnswerUpdate"
]
from app.schemas.token import Token, TokenPayload
from app.schemas.user import User, UserCreate, UserUpdate, UserInDB
from app.schemas.role import Role, RoleCreate, RoleUpdate
from app.schemas.student import Student, StudentCreate, StudentUpdate, StudentWithUser
from app.schemas.teacher import Teacher, TeacherCreate, TeacherUpdate, TeacherWithUser
from app.schemas.course import Course, CourseCreate, CourseUpdate, CourseWithTeacher
from app.schemas.class_enrollment import ClassEnrollment, ClassEnrollmentCreate, ClassEnrollmentUpdate, ClassEnrollmentWithDetails
from app.schemas.exam import Exam, ExamCreate, ExamUpdate, ExamWithQuestions, ExamWithCreator
from app.schemas.question import Question, QuestionCreate, QuestionUpdate, QuestionWithOptions, QuestionType
from app.schemas.question_option import QuestionOption, QuestionOptionCreate, QuestionOptionUpdate, QuestionOptionForExam
from app.schemas.exam_result import ExamResult, ExamResultCreate, ExamResultUpdate, ExamResultWithAnswers
from app.schemas.student_answer import StudentAnswer, StudentAnswerCreate, StudentAnswerUpdate

View File

@ -0,0 +1,49 @@
from typing import Optional
from datetime import datetime
from pydantic import BaseModel
from app.schemas.student import Student
from app.schemas.course import Course
# Shared properties
class ClassEnrollmentBase(BaseModel):
student_id: int
course_id: int
enrollment_date: Optional[datetime] = None
grade: Optional[float] = None
semester: str
academic_year: str
# Properties to receive via API on creation
class ClassEnrollmentCreate(ClassEnrollmentBase):
pass
# Properties to receive via API on update
class ClassEnrollmentUpdate(BaseModel):
grade: Optional[float] = None
semester: Optional[str] = None
academic_year: Optional[str] = None
class ClassEnrollmentInDBBase(ClassEnrollmentBase):
id: int
class Config:
from_attributes = True
# Additional properties to return via API
class ClassEnrollment(ClassEnrollmentInDBBase):
pass
# Class enrollment with Student and Course details
class ClassEnrollmentWithDetails(ClassEnrollment):
student: Optional[Student] = None
course: Optional[Course] = None
# Update forward refs
ClassEnrollmentWithDetails.model_rebuild()

48
app/schemas/course.py Normal file
View File

@ -0,0 +1,48 @@
from typing import Optional
from pydantic import BaseModel
# Import at the top to avoid circular imports
from app.schemas.teacher import Teacher
# Shared properties
class CourseBase(BaseModel):
course_code: str
title: str
description: Optional[str] = None
credits: Optional[int] = None
is_active: bool = True
teacher_id: Optional[int] = None
# Properties to receive via API on creation
class CourseCreate(CourseBase):
course_code: str
title: str
# Properties to receive via API on update
class CourseUpdate(CourseBase):
course_code: Optional[str] = None
title: Optional[str] = None
teacher_id: Optional[int] = None
class CourseInDBBase(CourseBase):
id: int
class Config:
from_attributes = True
# Additional properties to return via API
class Course(CourseInDBBase):
pass
# Course with Teacher details
class CourseWithTeacher(Course):
teacher: Optional[Teacher] = None
# Update forward refs
CourseWithTeacher.model_rebuild()

65
app/schemas/exam.py Normal file
View File

@ -0,0 +1,65 @@
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from pydantic import BaseModel
if TYPE_CHECKING:
from app.schemas.question import Question
from app.schemas.user import User
# Shared properties
class ExamBase(BaseModel):
title: str
description: Optional[str] = None
course_id: int
duration_minutes: int = 60
passing_score: float = 50.0
is_active: bool = True
is_randomized: bool = False
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
created_by: Optional[int] = None
# Properties to receive via API on creation
class ExamCreate(ExamBase):
pass
# Properties to receive via API on update
class ExamUpdate(ExamBase):
title: Optional[str] = None
course_id: Optional[int] = None
duration_minutes: Optional[int] = None
passing_score: Optional[float] = None
is_active: Optional[bool] = None
is_randomized: Optional[bool] = None
class ExamInDBBase(ExamBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Exam(ExamInDBBase):
pass
# Exam with questions
class ExamWithQuestions(Exam):
questions: List['Question'] = []
# Exam with creator info
class ExamWithCreator(Exam):
creator: Optional['User'] = None
# Update forward references
from app.schemas.question import Question # noqa: E402
from app.schemas.user import User # noqa: E402
ExamWithQuestions.model_rebuild()
ExamWithCreator.model_rebuild()

View File

@ -0,0 +1,54 @@
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from pydantic import BaseModel
if TYPE_CHECKING:
from app.schemas.student_answer import StudentAnswer
# Shared properties
class ExamResultBase(BaseModel):
exam_id: int
student_id: int
is_completed: bool = False
remarks: Optional[str] = None
# Properties to receive via API on creation
class ExamResultCreate(ExamResultBase):
pass
# Properties to receive via API on update
class ExamResultUpdate(BaseModel):
score: Optional[float] = None
max_score: Optional[float] = None
completed_at: Optional[datetime] = None
is_completed: Optional[bool] = None
remarks: Optional[str] = None
class ExamResultInDBBase(ExamResultBase):
id: int
score: Optional[float] = None
max_score: Optional[float] = None
started_at: datetime
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class ExamResult(ExamResultInDBBase):
pass
# Exam result with student answers
class ExamResultWithAnswers(ExamResult):
answers: List['StudentAnswer'] = []
# Update forward references
from app.schemas.student_answer import StudentAnswer # noqa: E402
ExamResultWithAnswers.model_rebuild()

60
app/schemas/question.py Normal file
View File

@ -0,0 +1,60 @@
from typing import Optional, List, TYPE_CHECKING
from enum import Enum
from pydantic import BaseModel
if TYPE_CHECKING:
from app.schemas.question_option import QuestionOption
# Question type enum
class QuestionType(str, Enum):
MULTIPLE_CHOICE = "multiple_choice"
TRUE_FALSE = "true_false"
SHORT_ANSWER = "short_answer"
ESSAY = "essay"
# Shared properties
class QuestionBase(BaseModel):
exam_id: int
text: str
question_type: QuestionType = QuestionType.MULTIPLE_CHOICE
points: float = 1.0
image_url: Optional[str] = None
solution: Optional[str] = None
# Properties to receive via API on creation
class QuestionCreate(QuestionBase):
pass
# Properties to receive via API on update
class QuestionUpdate(BaseModel):
text: Optional[str] = None
question_type: Optional[QuestionType] = None
points: Optional[float] = None
image_url: Optional[str] = None
solution: Optional[str] = None
class QuestionInDBBase(QuestionBase):
id: int
class Config:
from_attributes = True
# Additional properties to return via API
class Question(QuestionInDBBase):
pass
# Question with options
class QuestionWithOptions(Question):
options: List['QuestionOption'] = []
# Update forward references
from app.schemas.question_option import QuestionOption # noqa: E402
QuestionWithOptions.model_rebuild()

View File

@ -0,0 +1,41 @@
from typing import Optional
from pydantic import BaseModel
# Shared properties
class QuestionOptionBase(BaseModel):
question_id: int
text: str
is_correct: bool = False
# Properties to receive via API on creation
class QuestionOptionCreate(QuestionOptionBase):
pass
# Properties to receive via API on update
class QuestionOptionUpdate(BaseModel):
text: Optional[str] = None
is_correct: Optional[bool] = None
class QuestionOptionInDBBase(QuestionOptionBase):
id: int
class Config:
from_attributes = True
# Additional properties to return via API
class QuestionOption(QuestionOptionInDBBase):
pass
# For students taking an exam - hide the is_correct field
class QuestionOptionForExam(BaseModel):
id: int
text: str
class Config:
from_attributes = True

View File

@ -0,0 +1,37 @@
from typing import Optional
from pydantic import BaseModel
# Shared properties
class StudentAnswerBase(BaseModel):
exam_result_id: int
question_id: int
selected_option_id: Optional[int] = None
text_answer: Optional[str] = None
# Properties to receive via API on creation
class StudentAnswerCreate(StudentAnswerBase):
pass
# Properties to receive via API on update
class StudentAnswerUpdate(BaseModel):
selected_option_id: Optional[int] = None
text_answer: Optional[str] = None
is_correct: Optional[bool] = None
points_earned: Optional[float] = None
class StudentAnswerInDBBase(StudentAnswerBase):
id: int
is_correct: Optional[bool] = None
points_earned: float = 0.0
class Config:
from_attributes = True
# Additional properties to return via API
class StudentAnswer(StudentAnswerInDBBase):
pass

48
app/schemas/teacher.py Normal file
View File

@ -0,0 +1,48 @@
from typing import Optional
from datetime import date
from pydantic import BaseModel
# Import at the top to avoid circular imports
from app.schemas.user import User as UserOut
# Shared properties
class TeacherBase(BaseModel):
teacher_id: Optional[str] = None
date_of_birth: Optional[date] = None
address: Optional[str] = None
phone_number: Optional[str] = None
department: Optional[str] = None
hire_date: Optional[date] = None
user_id: Optional[int] = None
# Properties to receive via API on creation
class TeacherCreate(TeacherBase):
teacher_id: str
user_id: int
# Properties to receive via API on update
class TeacherUpdate(TeacherBase):
pass
class TeacherInDBBase(TeacherBase):
id: Optional[int] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Teacher(TeacherInDBBase):
pass
# Teacher with User details
class TeacherWithUser(Teacher):
user: Optional[UserOut] = None
# Update forward refs
TeacherWithUser.model_rebuild()

17
main.py
View File

@ -28,8 +28,23 @@ app.include_router(api_router, prefix=settings.API_V1_STR)
async def health_check():
"""
Health check endpoint
Returns:
dict: A dictionary containing the health status and application information
"""
return {"status": "healthy"}
return {
"status": "healthy",
"version": "1.0.0",
"application": settings.PROJECT_NAME,
"environment": settings.ENVIRONMENT,
"features": {
"cbt": True,
"role_based_access": True,
"student_management": True,
"teacher_management": True,
"course_management": True
}
}
if __name__ == "__main__":

View File

@ -0,0 +1,125 @@
"""Add CBT tables
Revision ID: 002
Revises: 001
Create Date: 2023-10-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create exam table
op.create_table(
'exam',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('course_id', sa.Integer(), nullable=False),
sa.Column('duration_minutes', sa.Integer(), nullable=True, default=60),
sa.Column('passing_score', sa.Float(), nullable=True, default=50.0),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_randomized', sa.Boolean(), nullable=True, default=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['course_id'], ['course.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exam_id'), 'exam', ['id'], unique=False)
# Create question table
op.create_table(
'question',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('exam_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('question_type', sa.Enum('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'ESSAY', name='questiontype'), nullable=False),
sa.Column('points', sa.Float(), nullable=True),
sa.Column('image_url', sa.String(length=255), nullable=True),
sa.Column('solution', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_question_id'), 'question', ['id'], unique=False)
# Create question_option table
op.create_table(
'question_option',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('is_correct', sa.Boolean(), nullable=True, default=False),
sa.ForeignKeyConstraint(['question_id'], ['question.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_question_option_id'), 'question_option', ['id'], unique=False)
# Create exam_result table
op.create_table(
'exam_result',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('exam_id', sa.Integer(), nullable=False),
sa.Column('student_id', sa.Integer(), nullable=False),
sa.Column('score', sa.Float(), nullable=True),
sa.Column('max_score', sa.Float(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('is_completed', sa.Boolean(), nullable=True, default=False),
sa.Column('remarks', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['exam_id'], ['exam.id'], ),
sa.ForeignKeyConstraint(['student_id'], ['student.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exam_result_id'), 'exam_result', ['id'], unique=False)
# Create student_answer table
op.create_table(
'student_answer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('exam_result_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('selected_option_id', sa.Integer(), nullable=True),
sa.Column('text_answer', sa.Text(), nullable=True),
sa.Column('is_correct', sa.Boolean(), nullable=True),
sa.Column('points_earned', sa.Float(), nullable=True, default=0.0),
sa.ForeignKeyConstraint(['exam_result_id'], ['exam_result.id'], ),
sa.ForeignKeyConstraint(['question_id'], ['question.id'], ),
sa.ForeignKeyConstraint(['selected_option_id'], ['question_option.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_student_answer_id'), 'student_answer', ['id'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order of creation
op.drop_index(op.f('ix_student_answer_id'), table_name='student_answer')
op.drop_table('student_answer')
op.drop_index(op.f('ix_exam_result_id'), table_name='exam_result')
op.drop_table('exam_result')
op.drop_index(op.f('ix_question_option_id'), table_name='question_option')
op.drop_table('question_option')
op.drop_index(op.f('ix_question_id'), table_name='question')
op.drop_table('question')
op.drop_index(op.f('ix_exam_id'), table_name='exam')
op.drop_table('exam')
# Drop the enum type
op.execute('DROP TYPE questiontype')