diff --git a/README.md b/README.md index e8acfba..f50cacf 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 67357b5..28b9c14 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -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"]) \ No newline at end of file +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"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py index e69de29..ed015e7 100644 --- a/app/api/v1/endpoints/__init__.py +++ b/app/api/v1/endpoints/__init__.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/courses.py b/app/api/v1/endpoints/courses.py new file mode 100644 index 0000000..bd0af89 --- /dev/null +++ b/app/api/v1/endpoints/courses.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/enrollments.py b/app/api/v1/endpoints/enrollments.py new file mode 100644 index 0000000..8c10825 --- /dev/null +++ b/app/api/v1/endpoints/enrollments.py @@ -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) \ No newline at end of file diff --git a/app/api/v1/endpoints/exam_results.py b/app/api/v1/endpoints/exam_results.py new file mode 100644 index 0000000..8dee444 --- /dev/null +++ b/app/api/v1/endpoints/exam_results.py @@ -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) \ No newline at end of file diff --git a/app/api/v1/endpoints/exams.py b/app/api/v1/endpoints/exams.py new file mode 100644 index 0000000..f475a1c --- /dev/null +++ b/app/api/v1/endpoints/exams.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/question_options.py b/app/api/v1/endpoints/question_options.py new file mode 100644 index 0000000..6edaf28 --- /dev/null +++ b/app/api/v1/endpoints/question_options.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/questions.py b/app/api/v1/endpoints/questions.py new file mode 100644 index 0000000..fc51879 --- /dev/null +++ b/app/api/v1/endpoints/questions.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/students.py b/app/api/v1/endpoints/students.py new file mode 100644 index 0000000..9383a4c --- /dev/null +++ b/app/api/v1/endpoints/students.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/teachers.py b/app/api/v1/endpoints/teachers.py new file mode 100644 index 0000000..eb0f801 --- /dev/null +++ b/app/api/v1/endpoints/teachers.py @@ -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 \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 744f5c4..bf3b148 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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" diff --git a/app/crud/__init__.py b/app/crud/__init__.py index 8881762..db57a03 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -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"] \ No newline at end of file +__all__ = [ + "user", + "role", + "student", + "exam", + "question", + "question_option", + "exam_result", + "student_answer" +] \ No newline at end of file diff --git a/app/crud/crud_exam.py b/app/crud/crud_exam.py new file mode 100644 index 0000000..4be1e8f --- /dev/null +++ b/app/crud/crud_exam.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_exam_result.py b/app/crud/crud_exam_result.py new file mode 100644 index 0000000..1493924 --- /dev/null +++ b/app/crud/crud_exam_result.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_question.py b/app/crud/crud_question.py new file mode 100644 index 0000000..1b41ba6 --- /dev/null +++ b/app/crud/crud_question.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_question_option.py b/app/crud/crud_question_option.py new file mode 100644 index 0000000..3b69601 --- /dev/null +++ b/app/crud/crud_question_option.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_student_answer.py b/app/crud/crud_student_answer.py new file mode 100644 index 0000000..5122584 --- /dev/null +++ b/app/crud/crud_student_answer.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py index 1d81f56..83b648a 100644 --- a/app/crud/crud_user.py +++ b/app/crud/crud_user.py @@ -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) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index e69de29..c3473a8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 \ No newline at end of file diff --git a/app/models/exam.py b/app/models/exam.py new file mode 100644 index 0000000..a61d629 --- /dev/null +++ b/app/models/exam.py @@ -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"" \ No newline at end of file diff --git a/app/models/exam_result.py b/app/models/exam_result.py new file mode 100644 index 0000000..df8df57 --- /dev/null +++ b/app/models/exam_result.py @@ -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"" \ No newline at end of file diff --git a/app/models/question.py b/app/models/question.py new file mode 100644 index 0000000..53d3517 --- /dev/null +++ b/app/models/question.py @@ -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"" \ No newline at end of file diff --git a/app/models/question_option.py b/app/models/question_option.py new file mode 100644 index 0000000..dd38ca9 --- /dev/null +++ b/app/models/question_option.py @@ -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"" \ No newline at end of file diff --git a/app/models/student_answer.py b/app/models/student_answer.py new file mode 100644 index 0000000..7e0ab65 --- /dev/null +++ b/app/models/student_answer.py @@ -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"" \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 67e1a19..a55c6e3 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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"" \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index e69de29..537791b 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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 \ No newline at end of file diff --git a/app/schemas/class_enrollment.py b/app/schemas/class_enrollment.py new file mode 100644 index 0000000..f133074 --- /dev/null +++ b/app/schemas/class_enrollment.py @@ -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() \ No newline at end of file diff --git a/app/schemas/course.py b/app/schemas/course.py new file mode 100644 index 0000000..3522149 --- /dev/null +++ b/app/schemas/course.py @@ -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() \ No newline at end of file diff --git a/app/schemas/exam.py b/app/schemas/exam.py new file mode 100644 index 0000000..6677e6e --- /dev/null +++ b/app/schemas/exam.py @@ -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() \ No newline at end of file diff --git a/app/schemas/exam_result.py b/app/schemas/exam_result.py new file mode 100644 index 0000000..3dff157 --- /dev/null +++ b/app/schemas/exam_result.py @@ -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() \ No newline at end of file diff --git a/app/schemas/question.py b/app/schemas/question.py new file mode 100644 index 0000000..fe3aec7 --- /dev/null +++ b/app/schemas/question.py @@ -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() \ No newline at end of file diff --git a/app/schemas/question_option.py b/app/schemas/question_option.py new file mode 100644 index 0000000..8b2677e --- /dev/null +++ b/app/schemas/question_option.py @@ -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 \ No newline at end of file diff --git a/app/schemas/student_answer.py b/app/schemas/student_answer.py new file mode 100644 index 0000000..c523c25 --- /dev/null +++ b/app/schemas/student_answer.py @@ -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 \ No newline at end of file diff --git a/app/schemas/teacher.py b/app/schemas/teacher.py new file mode 100644 index 0000000..ad7ebdb --- /dev/null +++ b/app/schemas/teacher.py @@ -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() \ No newline at end of file diff --git a/main.py b/main.py index c2894e6..3235b99 100644 --- a/main.py +++ b/main.py @@ -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__": diff --git a/migrations/versions/002_add_cbt_tables.py b/migrations/versions/002_add_cbt_tables.py new file mode 100644 index 0000000..0f16683 --- /dev/null +++ b/migrations/versions/002_add_cbt_tables.py @@ -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') \ No newline at end of file