diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a4d59e --- /dev/null +++ b/README.md @@ -0,0 +1,482 @@ +# Course Enrollment API + +A FastAPI-based backend system for managing student course enrollments and grades. + +## Tech Stack + +- **FastAPI**: Modern web framework for building APIs +- **SQLAlchemy**: ORM for database operations +- **Pydantic**: Data validation and serialization +- **PostgreSQL**: Relational database +- **uvicorn**: ASGI server +- **psycopg2-binary**: PostgreSQL driver +- **Python 3.12**: Programming language + +## Project Architecture + +This project follows a layered architecture pattern: + +``` +Router Layer (API endpoints) + ↓ +Service Layer (Business logic & validation) + ↓ +Repository Layer (Database queries) + ↓ +Database (PostgreSQL) +``` + +### Folder Structure + +``` +course-enrollment-api/ +├── app/ +│ ├── main.py # FastAPI app initialization +│ ├── database.py # Database connection setup +│ ├── models.py # SQLAlchemy ORM models +│ ├── schemas.py # Pydantic schemas +│ ├── routers/ # API route handlers +│ │ ├── students_router.py +│ │ ├── courses_router.py +│ │ ├── enrollments_router.py +│ │ └── grades_router.py +│ ├── services/ # Business logic layer +│ │ ├── students_service.py +│ │ ├── courses_service.py +│ │ ├── enrollments_service.py +│ │ └── grades_service.py +│ └── repositories/ # Database query layer +│ ├── students_repository.py +│ ├── courses_repository.py +│ ├── enrollments_repository.py +│ └── grades_repository.py +├── screenshots/ # Postman API screenshots +├── .env # Environment variables +├── .gitignore +├── requirements.txt +└── README.md +``` + +## Database Schema + +### Students Table +- `id`: Primary key (auto-increment) +- `name`: Student's full name +- `email`: Unique email address + +### Courses Table +- `id`: Primary key (auto-increment) +- `course_name`: Full course name +- `course_code`: Unique course identifier +- `credits`: Number of credits + +### Enrollments Table +- `id`: Primary key (auto-increment) +- `student_id`: Foreign key to students +- `course_id`: Foreign key to courses +- `enrollment_date`: Date of enrollment + +### Grades Table +- `id`: Primary key (auto-increment) +- `enrollment_id`: Foreign key to enrollments +- `marks`: Numerical marks (0-100) +- `final_grade`: Letter grade (A, B, C, D, F) + +## Setup Instructions + +### 1. Clone the Repository + +```bash +git clone https://github.com/nexpectArpit/ojt3Project.git +cd course-enrollment-api +``` + +### 2. Create Virtual Environment + +```bash +python3.12 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Configure Database + +Create a `.env` file in the project root: + +```env +DATABASE_URL=postgresql://username:password@localhost:5432/course_enrollment_db +``` + +**Note**: Replace `username` and `password` with your PostgreSQL credentials. + +Create the database: + +```bash +psql -U your_username -c "CREATE DATABASE course_enrollment_db;" +``` + +### 5. Run the Server + +```bash +uvicorn app.main:app --reload +``` + +The API will be available at: `http://localhost:8000` + +### 6. Access API Documentation + +FastAPI provides automatic interactive documentation: + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## API Endpoints + +### Students + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/students/` | Create a new student | +| GET | `/students/` | Get all students | +| GET | `/students/{id}` | Get student by ID | +| PUT | `/students/{id}` | Update student | +| DELETE | `/students/{id}` | Delete student | + +### Courses + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/courses/` | Create a new course | +| GET | `/courses/` | Get all courses | +| GET | `/courses/{id}` | Get course by ID | +| PUT | `/courses/{id}` | Update course | +| DELETE | `/courses/{id}` | Delete course | + +### Enrollments + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/enrollments/` | Enroll student in course | +| GET | `/enrollments/` | Get all enrollments | +| GET | `/enrollments/{id}` | Get enrollment by ID | +| GET | `/enrollments/student/{id}` | Get student's enrollments | +| GET | `/enrollments/course/{id}` | Get course enrollments | +| DELETE | `/enrollments/{id}` | Delete enrollment | + +### Grades + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/grades/` | Create grade for enrollment | +| GET | `/grades/` | Get all grades | +| GET | `/grades/{id}` | Get grade by ID | +| GET | `/grades/enrollment/{id}` | Get grade by enrollment | +| PUT | `/grades/{id}` | Update grade | +| DELETE | `/grades/{id}` | Delete grade | + +## Request/Response Examples + +### Create Student + +**Request:** +```bash +curl -X POST http://localhost:8000/students/ \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "email": "john@example.com" + }' +``` + +**Response:** +```json +{ + "id": 1, + "name": "John Doe", + "email": "john@example.com" +} +``` + +### Create Course + +**Request:** +```bash +curl -X POST http://localhost:8000/courses/ \ + -H "Content-Type: application/json" \ + -d '{ + "course_name": "Data Structures", + "course_code": "CS101", + "credits": 4 + }' +``` + +**Response:** +```json +{ + "id": 1, + "course_name": "Data Structures", + "course_code": "CS101", + "credits": 4 +} +``` + +### Create Enrollment + +**Request:** +```bash +curl -X POST http://localhost:8000/enrollments/ \ + -H "Content-Type: application/json" \ + -d '{ + "student_id": 1, + "course_id": 1, + "enrollment_date": "2024-01-15" + }' +``` + +**Response:** +```json +{ + "id": 1, + "student_id": 1, + "course_id": 1, + "enrollment_date": "2024-01-15" +} +``` + +### Create Grade + +**Request:** +```bash +curl -X POST http://localhost:8000/grades/ \ + -H "Content-Type: application/json" \ + -d '{ + "enrollment_id": 1, + "marks": 95 + }' +``` + +**Response:** +```json +{ + "id": 1, + "enrollment_id": 1, + "marks": 95.0, + "final_grade": "A" +} +``` + +## Grade Calculation + +The system automatically calculates letter grades based on marks: + +| Marks Range | Letter Grade | +|-------------|--------------| +| 90 - 100 | A | +| 80 - 89 | B | +| 70 - 79 | C | +| 60 - 69 | D | +| 0 - 59 | F | + +## Business Rules + +### Students +- Email must be unique +- Email format is validated + +### Courses +- Course code must be unique +- Credits must be a positive integer + +### Enrollments +- Student must exist +- Course must exist +- One student can only enroll in a course once (no duplicates) + +### Grades +- Enrollment must exist +- Marks must be between 0 and 100 +- One grade per enrollment (no duplicates) +- Final grade is automatically calculated + +## Demo Script for Evaluation + +Follow these steps to demonstrate the complete API functionality: + +### Step 1: Create Students + +```bash +# Create first student +curl -X POST http://localhost:8000/students/ \ + -H "Content-Type: application/json" \ + -d '{"name": "Alice Johnson", "email": "alice@example.com"}' + +# Create second student +curl -X POST http://localhost:8000/students/ \ + -H "Content-Type: application/json" \ + -d '{"name": "Bob Smith", "email": "bob@example.com"}' +``` + +### Step 2: Create Courses + +```bash +# Create first course +curl -X POST http://localhost:8000/courses/ \ + -H "Content-Type: application/json" \ + -d '{"course_name": "Data Structures", "course_code": "CS101", "credits": 4}' + +# Create second course +curl -X POST http://localhost:8000/courses/ \ + -H "Content-Type: application/json" \ + -d '{"course_name": "Database Systems", "course_code": "CS201", "credits": 3}' +``` + +### Step 3: Enroll Students + +```bash +# Enroll Alice in CS101 +curl -X POST http://localhost:8000/enrollments/ \ + -H "Content-Type: application/json" \ + -d '{"student_id": 1, "course_id": 1, "enrollment_date": "2024-01-15"}' + +# Enroll Bob in CS101 +curl -X POST http://localhost:8000/enrollments/ \ + -H "Content-Type: application/json" \ + -d '{"student_id": 2, "course_id": 1, "enrollment_date": "2024-01-15"}' + +# Enroll Alice in CS201 +curl -X POST http://localhost:8000/enrollments/ \ + -H "Content-Type: application/json" \ + -d '{"student_id": 1, "course_id": 2, "enrollment_date": "2024-01-20"}' +``` + +### Step 4: Add Grades + +```bash +# Grade for Alice in CS101 (95 marks = A) +curl -X POST http://localhost:8000/grades/ \ + -H "Content-Type: application/json" \ + -d '{"enrollment_id": 1, "marks": 95}' + +# Grade for Bob in CS101 (75 marks = C) +curl -X POST http://localhost:8000/grades/ \ + -H "Content-Type: application/json" \ + -d '{"enrollment_id": 2, "marks": 75}' + +# Grade for Alice in CS201 (88 marks = B) +curl -X POST http://localhost:8000/grades/ \ + -H "Content-Type: application/json" \ + -d '{"enrollment_id": 3, "marks": 88}' +``` + +### Step 5: Query Data + +```bash +# Get all students +curl http://localhost:8000/students/ + +# Get Alice's enrollments +curl http://localhost:8000/enrollments/student/1 + +# Get CS101 enrollments (class roster) +curl http://localhost:8000/enrollments/course/1 + +# Get all grades +curl http://localhost:8000/grades/ +``` + +### Step 6: Update Grade + +```bash +# Update Bob's grade from 75 to 85 (C to B) +curl -X PUT http://localhost:8000/grades/2 \ + -H "Content-Type: application/json" \ + -d '{"marks": 85}' +``` + +## Testing with Postman + +### Setup Postman Collection + +1. Open Postman +2. Create a new collection named "Course Enrollment API" +3. Add requests for each endpoint +4. Set base URL: `http://localhost:8000` + +### Taking Screenshots + +1. Execute each request in Postman +2. Take screenshots showing: + - Request details (method, URL, body) + - Response (status code, body) +3. Save screenshots in the `screenshots/` folder with descriptive names: + - `01-create-student.png` + - `02-get-students.png` + - `03-create-course.png` + - etc. + +### Adding Screenshots to README + +Place screenshots in the `screenshots/` folder and reference them: + +```markdown +![Create Student](screenshots/01-create-student.png) +``` + +## Features Implemented + +✅ Complete CRUD operations for Students +✅ Complete CRUD operations for Courses +✅ Enrollment management with validation +✅ Automatic grade calculation (A-F) +✅ Duplicate prevention (emails, course codes, enrollments) +✅ Foreign key validation +✅ Marks validation (0-100 range) +✅ Comprehensive error handling +✅ Interactive API documentation (Swagger UI) +✅ Layered architecture (Router → Service → Repository) +✅ Database relationships (One-to-Many, Many-to-Many) + +## Development Notes + +### Code Organization + +- **Routers**: Handle HTTP requests/responses +- **Services**: Contain business logic and validation +- **Repositories**: Execute database queries +- **Models**: Define database schema +- **Schemas**: Define request/response formats + +### Error Handling + +The API returns appropriate HTTP status codes: +- `200`: Success +- `201`: Created +- `400`: Bad Request (validation error) +- `404`: Not Found +- `500`: Internal Server Error + +## Future Enhancements + +Potential features for future development: +- User authentication and authorization +- Soft delete functionality +- Audit logging +- Email notifications +- Report generation +- Bulk operations +- Advanced filtering and search +- Docker containerization +- Database migrations with Alembic + +## Author + +Arpit Tripathi + +## License + +This project is for educational purposes (OJT Project). diff --git a/app/database.py b/app/database.py index 0dc95cf..ba35415 100644 --- a/app/database.py +++ b/app/database.py @@ -1,21 +1,56 @@ +""" +Database configuration and session management. + +This module handles: +- Database connection setup using SQLAlchemy +- Session management for database operations +- Base class for ORM models +""" + from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker import os from dotenv import load_dotenv +# Load environment variables from .env file +# This includes DATABASE_URL with PostgreSQL connection details load_dotenv() +# Get database URL from environment variables +# Format: postgresql://username:password@host:port/database_name DATABASE_URL = os.getenv("DATABASE_URL") +# Create SQLAlchemy engine +# This manages the connection pool to the PostgreSQL database engine = create_engine(DATABASE_URL) + +# Create SessionLocal class for database sessions +# autocommit=False: Transactions must be explicitly committed +# autoflush=False: Changes are not automatically flushed to DB SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# Base class for all ORM models +# All model classes (Student, Course, etc.) inherit from this Base = declarative_base() def get_db(): + """ + Dependency function that provides database session to route handlers. + + This function is used with FastAPI's Depends() to inject a database session + into route handlers. It ensures the session is properly closed after use. + + Usage in routes: + def my_route(db: Session = Depends(get_db)): + # Use db here + + Yields: + Session: SQLAlchemy database session + """ db = SessionLocal() try: yield db finally: + # Always close the session, even if an error occurs db.close() diff --git a/app/main.py b/app/main.py index 0f8a0d7..27a0652 100644 --- a/app/main.py +++ b/app/main.py @@ -1,24 +1,42 @@ +""" +Main application file for Course Enrollment API. + +This file initializes the FastAPI application and sets up: +- Database connection and table creation +- API routers for different modules +- Health check endpoint +""" + from fastapi import FastAPI from app.database import engine, Base -from app.routers import students, courses, enrollments, grades +from app.routers import students_router, courses_router, enrollments_router, grades_router from app import models -# Create database tables +# Create all database tables on startup +# This reads the SQLAlchemy models and creates corresponding tables in PostgreSQL Base.metadata.create_all(bind=engine) +# Initialize FastAPI application with metadata app = FastAPI( title="Course Enrollment API", description="API for managing student course enrollments and grades", version="1.0.0" ) -# Health check endpoint +# Health check endpoint - used to verify API is running @app.get("/health") def health_check(): + """ + Health check endpoint to verify API status. + Returns a simple JSON response indicating the API is operational. + """ return {"status": "healthy", "message": "Course Enrollment API is running"} -# Include routers -app.include_router(students.router, prefix="/students", tags=["Students"]) -app.include_router(courses.router, prefix="/courses", tags=["Courses"]) -app.include_router(enrollments.router, prefix="/enrollments", tags=["Enrollments"]) -app.include_router(grades.router, prefix="/grades", tags=["Grades"]) +# Include routers for different modules +# Each router handles a specific domain (students, courses, enrollments, grades) +# prefix: URL prefix for all routes in this router +# tags: Used for grouping endpoints in Swagger UI documentation +app.include_router(students_router.router, prefix="/students", tags=["Students"]) +app.include_router(courses_router.router, prefix="/courses", tags=["Courses"]) +app.include_router(enrollments_router.router, prefix="/enrollments", tags=["Enrollments"]) +app.include_router(grades_router.router, prefix="/grades", tags=["Grades"]) diff --git a/app/models.py b/app/models.py index 3fe6032..d4fb034 100644 --- a/app/models.py +++ b/app/models.py @@ -1,27 +1,79 @@ +""" +SQLAlchemy ORM models for database tables. + +This module defines the database schema using SQLAlchemy ORM: +- Student: Stores student information +- Course: Stores course details +- Enrollment: Links students to courses (many-to-many relationship) +- Grade: Stores grades for each enrollment + +Relationships: +- One Student can have many Enrollments +- One Course can have many Enrollments +- One Enrollment belongs to one Student and one Course +- One Enrollment has one Grade +""" + from sqlalchemy import Column, Integer, String, Date, Float, ForeignKey from sqlalchemy.orm import relationship from app.database import Base class Student(Base): + """ + Student model representing students in the system. + + Attributes: + id: Primary key, auto-incremented + name: Student's full name + email: Student's email (must be unique) + enrollments: List of courses this student is enrolled in + """ __tablename__ = "students" id = Column(Integer, primary_key=True, index=True) name = Column(String, nullable=False) - email = Column(String, unique=True, nullable=False) + email = Column(String, unique=True, nullable=False) # Unique constraint ensures no duplicate emails + # Relationship: One student can have many enrollments enrollments = relationship("Enrollment", back_populates="student") class Course(Base): + """ + Course model representing courses offered. + + Attributes: + id: Primary key, auto-incremented + course_name: Full name of the course + course_code: Unique code identifying the course (e.g., CS101) + credits: Number of credits for this course + enrollments: List of students enrolled in this course + """ __tablename__ = "courses" id = Column(Integer, primary_key=True, index=True) course_name = Column(String, nullable=False) - course_code = Column(String, unique=True, nullable=False) + course_code = Column(String, unique=True, nullable=False) # Unique constraint ensures no duplicate codes credits = Column(Integer, nullable=False) + # Relationship: One course can have many enrollments enrollments = relationship("Enrollment", back_populates="course") class Enrollment(Base): + """ + Enrollment model linking students to courses. + + This is the junction table for the many-to-many relationship between + students and courses. Each enrollment represents one student taking one course. + + Attributes: + id: Primary key, auto-incremented + student_id: Foreign key to students table + course_id: Foreign key to courses table + enrollment_date: Date when student enrolled in the course + student: Reference to the Student object + course: Reference to the Course object + grade: Reference to the Grade object for this enrollment + """ __tablename__ = "enrollments" id = Column(Integer, primary_key=True, index=True) @@ -29,16 +81,31 @@ class Enrollment(Base): course_id = Column(Integer, ForeignKey("courses.id"), nullable=False) enrollment_date = Column(Date, nullable=False) + # Relationships: Links to Student and Course student = relationship("Student", back_populates="enrollments") course = relationship("Course", back_populates="enrollments") + # uselist=False means one-to-one relationship (one enrollment has one grade) grade = relationship("Grade", back_populates="enrollment", uselist=False) class Grade(Base): + """ + Grade model storing grades for enrollments. + + Each enrollment can have one grade record containing marks and calculated letter grade. + + Attributes: + id: Primary key, auto-incremented + enrollment_id: Foreign key to enrollments table + marks: Numerical marks (0-100) + final_grade: Calculated letter grade (A, B, C, D, F) + enrollment: Reference to the Enrollment object + """ __tablename__ = "grades" id = Column(Integer, primary_key=True, index=True) enrollment_id = Column(Integer, ForeignKey("enrollments.id"), nullable=False) marks = Column(Float, nullable=False) - final_grade = Column(String, nullable=True) + final_grade = Column(String, nullable=True) # Calculated automatically based on marks + # Relationship: Links back to Enrollment enrollment = relationship("Enrollment", back_populates="grade") diff --git a/app/repositories/courses.py b/app/repositories/courses.py deleted file mode 100644 index e809a0d..0000000 --- a/app/repositories/courses.py +++ /dev/null @@ -1,2 +0,0 @@ -# Course database queries will be implemented in Step 2 -pass diff --git a/app/repositories/courses_repository.py b/app/repositories/courses_repository.py new file mode 100644 index 0000000..e5e8551 --- /dev/null +++ b/app/repositories/courses_repository.py @@ -0,0 +1,111 @@ +""" +Course Repository - Database access layer for Course operations. + +This module handles all direct database interactions for courses. +Similar to students_repository, but for course-related queries. + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.models import Course +from app.schemas import CourseCreate + +def create_course(db: Session, course: CourseCreate): + """ + Create a new course record in the database. + + Args: + db: Database session from get_db() + course: CourseCreate schema with course details + + Returns: + Course: Created course object with generated id + """ + db_course = Course(**course.model_dump()) + db.add(db_course) + db.commit() + db.refresh(db_course) + return db_course + +def get_all_courses(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all courses with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List[Course]: List of course objects + """ + return db.query(Course).offset(skip).limit(limit).all() + +def get_course_by_id(db: Session, course_id: int): + """ + Find a course by its ID. + + Args: + db: Database session + course_id: Course's primary key + + Returns: + Course | None: Course object if found, None otherwise + """ + return db.query(Course).filter(Course.id == course_id).first() + +def get_course_by_code(db: Session, course_code: str): + """ + Find a course by its unique course code. + + Used for checking if course code already exists. + + Args: + db: Database session + course_code: Unique course identifier (e.g., CS101) + + Returns: + Course | None: Course object if found, None otherwise + """ + return db.query(Course).filter(Course.course_code == course_code).first() + +def update_course(db: Session, course_id: int, course: CourseCreate): + """ + Update an existing course's information. + + Args: + db: Database session + course_id: ID of course to update + course: CourseCreate schema with new data + + Returns: + Course | None: Updated course object if found, None otherwise + """ + db_course = db.query(Course).filter(Course.id == course_id).first() + if db_course: + db_course.course_name = course.course_name + db_course.course_code = course.course_code + db_course.credits = course.credits + db.commit() + db.refresh(db_course) + return db_course + +def delete_course(db: Session, course_id: int): + """ + Delete a course from the database (hard delete). + + Args: + db: Database session + course_id: ID of course to delete + + Returns: + bool: True if deleted successfully, False if not found + """ + db_course = db.query(Course).filter(Course.id == course_id).first() + if db_course: + db.delete(db_course) + db.commit() + return True + return False diff --git a/app/repositories/enrollments.py b/app/repositories/enrollments.py deleted file mode 100644 index d4c43bd..0000000 --- a/app/repositories/enrollments.py +++ /dev/null @@ -1,2 +0,0 @@ -# Enrollment database queries will be implemented in Step 3 -pass diff --git a/app/repositories/enrollments_repository.py b/app/repositories/enrollments_repository.py new file mode 100644 index 0000000..af48f63 --- /dev/null +++ b/app/repositories/enrollments_repository.py @@ -0,0 +1,121 @@ +""" +Enrollment Repository - Database access layer for Enrollment operations. + +This module handles database queries for enrollments (student-course relationships). +Enrollments link students to courses in a many-to-many relationship. + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.models import Enrollment +from app.schemas import EnrollmentCreate + +def create_enrollment(db: Session, enrollment: EnrollmentCreate): + """ + Create a new enrollment record linking a student to a course. + + Args: + db: Database session + enrollment: EnrollmentCreate schema with student_id, course_id, date + + Returns: + Enrollment: Created enrollment object with generated id + """ + db_enrollment = Enrollment(**enrollment.model_dump()) + db.add(db_enrollment) + db.commit() + db.refresh(db_enrollment) + return db_enrollment + +def get_all_enrollments(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all enrollments with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List[Enrollment]: List of enrollment objects + """ + return db.query(Enrollment).offset(skip).limit(limit).all() + +def get_enrollment_by_id(db: Session, enrollment_id: int): + """ + Find an enrollment by its ID. + + Args: + db: Database session + enrollment_id: Enrollment's primary key + + Returns: + Enrollment | None: Enrollment object if found, None otherwise + """ + return db.query(Enrollment).filter(Enrollment.id == enrollment_id).first() + +def get_enrollment_by_student_and_course(db: Session, student_id: int, course_id: int): + """ + Find an enrollment for a specific student-course combination. + + Used to check if a student is already enrolled in a course + (prevents duplicate enrollments). + + Args: + db: Database session + student_id: Student's ID + course_id: Course's ID + + Returns: + Enrollment | None: Enrollment if exists, None otherwise + """ + return db.query(Enrollment).filter( + Enrollment.student_id == student_id, + Enrollment.course_id == course_id + ).first() + +def get_enrollments_by_student(db: Session, student_id: int): + """ + Get all courses a student is enrolled in. + + Args: + db: Database session + student_id: Student's ID + + Returns: + List[Enrollment]: All enrollments for this student + """ + return db.query(Enrollment).filter(Enrollment.student_id == student_id).all() + +def get_enrollments_by_course(db: Session, course_id: int): + """ + Get all students enrolled in a specific course. + + Args: + db: Database session + course_id: Course's ID + + Returns: + List[Enrollment]: All enrollments for this course + """ + return db.query(Enrollment).filter(Enrollment.course_id == course_id).all() + +def delete_enrollment(db: Session, enrollment_id: int): + """ + Delete an enrollment (unenroll a student from a course). + + Args: + db: Database session + enrollment_id: ID of enrollment to delete + + Returns: + bool: True if deleted successfully, False if not found + """ + db_enrollment = db.query(Enrollment).filter(Enrollment.id == enrollment_id).first() + if db_enrollment: + db.delete(db_enrollment) + db.commit() + return True + return False diff --git a/app/repositories/grades.py b/app/repositories/grades.py deleted file mode 100644 index b4a5c5f..0000000 --- a/app/repositories/grades.py +++ /dev/null @@ -1,2 +0,0 @@ -# Grade database queries will be implemented in Step 3 -pass diff --git a/app/repositories/grades_repository.py b/app/repositories/grades_repository.py new file mode 100644 index 0000000..ae847e5 --- /dev/null +++ b/app/repositories/grades_repository.py @@ -0,0 +1,112 @@ +""" +Grade Repository - Database access layer for Grade operations. + +This module handles database queries for grades. +Each grade is linked to an enrollment (one grade per enrollment). + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.models import Grade +from app.schemas import GradeCreate + +def create_grade(db: Session, grade: GradeCreate): + """ + Create a new grade record for an enrollment. + + Args: + db: Database session + grade: GradeCreate schema with enrollment_id, marks, final_grade + + Returns: + Grade: Created grade object with generated id + """ + db_grade = Grade(**grade.model_dump()) + db.add(db_grade) + db.commit() + db.refresh(db_grade) + return db_grade + +def get_all_grades(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all grades with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List[Grade]: List of grade objects + """ + return db.query(Grade).offset(skip).limit(limit).all() + +def get_grade_by_id(db: Session, grade_id: int): + """ + Find a grade by its ID. + + Args: + db: Database session + grade_id: Grade's primary key + + Returns: + Grade | None: Grade object if found, None otherwise + """ + return db.query(Grade).filter(Grade.id == grade_id).first() + +def get_grade_by_enrollment(db: Session, enrollment_id: int): + """ + Find the grade for a specific enrollment. + + Used to check if a grade already exists for an enrollment + (prevents duplicate grades). + + Args: + db: Database session + enrollment_id: Enrollment's ID + + Returns: + Grade | None: Grade if exists, None otherwise + """ + return db.query(Grade).filter(Grade.enrollment_id == enrollment_id).first() + +def update_grade(db: Session, grade_id: int, marks: float, final_grade: str): + """ + Update an existing grade's marks and letter grade. + + Args: + db: Database session + grade_id: ID of grade to update + marks: New numerical marks (0-100) + final_grade: New calculated letter grade (A, B, C, D, F) + + Returns: + Grade | None: Updated grade object if found, None otherwise + """ + db_grade = db.query(Grade).filter(Grade.id == grade_id).first() + if db_grade: + db_grade.marks = marks + db_grade.final_grade = final_grade + db.commit() + db.refresh(db_grade) + return db_grade + +def delete_grade(db: Session, grade_id: int): + """ + Delete a grade from the database. + + Args: + db: Database session + grade_id: ID of grade to delete + + Returns: + bool: True if deleted successfully, False if not found + """ + db_grade = db.query(Grade).filter(Grade.id == grade_id).first() + if db_grade: + db.delete(db_grade) + db.commit() + return True + return False diff --git a/app/repositories/students.py b/app/repositories/students.py deleted file mode 100644 index b478f99..0000000 --- a/app/repositories/students.py +++ /dev/null @@ -1,2 +0,0 @@ -# Student database queries will be implemented in Step 2 -pass diff --git a/app/repositories/students_repository.py b/app/repositories/students_repository.py new file mode 100644 index 0000000..e57c8ec --- /dev/null +++ b/app/repositories/students_repository.py @@ -0,0 +1,130 @@ +""" +Student Repository - Database access layer for Student operations. + +This module handles all direct database interactions for students. +It uses SQLAlchemy ORM to query and manipulate student records. + +Layer Architecture: +Router -> Service -> Repository -> Database + +The repository layer: +- Executes SQL queries through SQLAlchemy ORM +- Returns raw database objects +- Does NOT contain business logic or validation +""" + +from sqlalchemy.orm import Session +from app.models import Student +from app.schemas import StudentCreate + +def create_student(db: Session, student: StudentCreate): + """ + Create a new student record in the database. + + Args: + db: Database session from get_db() + student: StudentCreate schema with name and email + + Returns: + Student: Created student object with generated id + + Process: + 1. Convert Pydantic schema to dict + 2. Create Student ORM object + 3. Add to session + 4. Commit transaction + 5. Refresh to get generated id + """ + db_student = Student(**student.model_dump()) + db.add(db_student) + db.commit() + db.refresh(db_student) + return db_student + +def get_all_students(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all students with pagination. + + Args: + db: Database session + skip: Number of records to skip (for pagination) + limit: Maximum number of records to return + + Returns: + List[Student]: List of student objects + """ + return db.query(Student).offset(skip).limit(limit).all() + +def get_student_by_id(db: Session, student_id: int): + """ + Find a student by their ID. + + Args: + db: Database session + student_id: Student's primary key + + Returns: + Student | None: Student object if found, None otherwise + """ + return db.query(Student).filter(Student.id == student_id).first() + +def get_student_by_email(db: Session, email: str): + """ + Find a student by their email address. + + Used for checking if email already exists before creating/updating. + + Args: + db: Database session + email: Student's email address + + Returns: + Student | None: Student object if found, None otherwise + """ + return db.query(Student).filter(Student.email == email).first() + +def update_student(db: Session, student_id: int, student: StudentCreate): + """ + Update an existing student's information. + + Args: + db: Database session + student_id: ID of student to update + student: StudentCreate schema with new data + + Returns: + Student | None: Updated student object if found, None otherwise + + Process: + 1. Find student by ID + 2. Update fields + 3. Commit changes + 4. Refresh to get updated data + """ + db_student = db.query(Student).filter(Student.id == student_id).first() + if db_student: + db_student.name = student.name + db_student.email = student.email + db.commit() + db.refresh(db_student) + return db_student + +def delete_student(db: Session, student_id: int): + """ + Delete a student from the database (hard delete). + + Args: + db: Database session + student_id: ID of student to delete + + Returns: + bool: True if deleted successfully, False if not found + + Note: This is a hard delete - the record is permanently removed. + """ + db_student = db.query(Student).filter(Student.id == student_id).first() + if db_student: + db.delete(db_student) + db.commit() + return True + return False diff --git a/app/routers/courses.py b/app/routers/courses.py deleted file mode 100644 index e53b544..0000000 --- a/app/routers/courses.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/") -def get_courses(): - return {"message": "Course routes - coming in Step 2"} diff --git a/app/routers/courses_router.py b/app/routers/courses_router.py new file mode 100644 index 0000000..732e519 --- /dev/null +++ b/app/routers/courses_router.py @@ -0,0 +1,44 @@ +""" +Course Router - API endpoints for Course operations. + +Endpoints: +- POST /courses/ Create a new course +- GET /courses/ Get all courses +- GET /courses/{id} Get a specific course +- PUT /courses/{id} Update a course +- DELETE /courses/{id} Delete a course +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.schemas import CourseCreate, CourseResponse +from app.services import courses_service as course_service + +router = APIRouter() + +@router.post("/", response_model=CourseResponse, status_code=201) +def create_course(course: CourseCreate, db: Session = Depends(get_db)): + """Create a new course.""" + return course_service.create_course(db, course) + +@router.get("/", response_model=List[CourseResponse]) +def get_all_courses(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all courses with pagination.""" + return course_service.get_all_courses(db, skip, limit) + +@router.get("/{course_id}", response_model=CourseResponse) +def get_course(course_id: int, db: Session = Depends(get_db)): + """Get a specific course by ID.""" + return course_service.get_course_by_id(db, course_id) + +@router.put("/{course_id}", response_model=CourseResponse) +def update_course(course_id: int, course: CourseCreate, db: Session = Depends(get_db)): + """Update an existing course.""" + return course_service.update_course(db, course_id, course) + +@router.delete("/{course_id}") +def delete_course(course_id: int, db: Session = Depends(get_db)): + """Delete a course.""" + return course_service.delete_course(db, course_id) diff --git a/app/routers/enrollments.py b/app/routers/enrollments.py deleted file mode 100644 index 897796a..0000000 --- a/app/routers/enrollments.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/") -def get_enrollments(): - return {"message": "Enrollment routes - coming in Step 3"} diff --git a/app/routers/enrollments_router.py b/app/routers/enrollments_router.py new file mode 100644 index 0000000..d213e9e --- /dev/null +++ b/app/routers/enrollments_router.py @@ -0,0 +1,54 @@ +""" +Enrollment Router - API endpoints for Enrollment operations. + +Endpoints: +- POST /enrollments/ Create a new enrollment +- GET /enrollments/ Get all enrollments +- GET /enrollments/{id} Get a specific enrollment +- GET /enrollments/student/{id} Get all enrollments for a student +- GET /enrollments/course/{id} Get all enrollments for a course +- DELETE /enrollments/{id} Delete an enrollment +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.schemas import EnrollmentCreate, EnrollmentResponse +from app.services import enrollments_service as enrollment_service + +router = APIRouter() + +@router.post("/", response_model=EnrollmentResponse, status_code=201) +def create_enrollment(enrollment: EnrollmentCreate, db: Session = Depends(get_db)): + """ + Create a new enrollment (enroll a student in a course). + + Validates that student and course exist, and prevents duplicate enrollments. + """ + return enrollment_service.create_enrollment(db, enrollment) + +@router.get("/", response_model=List[EnrollmentResponse]) +def get_all_enrollments(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all enrollments with pagination.""" + return enrollment_service.get_all_enrollments(db, skip, limit) + +@router.get("/{enrollment_id}", response_model=EnrollmentResponse) +def get_enrollment(enrollment_id: int, db: Session = Depends(get_db)): + """Get a specific enrollment by ID.""" + return enrollment_service.get_enrollment_by_id(db, enrollment_id) + +@router.get("/student/{student_id}", response_model=List[EnrollmentResponse]) +def get_enrollments_by_student(student_id: int, db: Session = Depends(get_db)): + """Get all courses a student is enrolled in.""" + return enrollment_service.get_enrollments_by_student(db, student_id) + +@router.get("/course/{course_id}", response_model=List[EnrollmentResponse]) +def get_enrollments_by_course(course_id: int, db: Session = Depends(get_db)): + """Get all students enrolled in a course (class roster).""" + return enrollment_service.get_enrollments_by_course(db, course_id) + +@router.delete("/{enrollment_id}") +def delete_enrollment(enrollment_id: int, db: Session = Depends(get_db)): + """Delete an enrollment (unenroll a student from a course).""" + return enrollment_service.delete_enrollment(db, enrollment_id) diff --git a/app/routers/grades.py b/app/routers/grades.py deleted file mode 100644 index 88a068c..0000000 --- a/app/routers/grades.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/") -def get_grades(): - return {"message": "Grade routes - coming in Step 3"} diff --git a/app/routers/grades_router.py b/app/routers/grades_router.py new file mode 100644 index 0000000..25c460e --- /dev/null +++ b/app/routers/grades_router.py @@ -0,0 +1,64 @@ +""" +Grade Router - API endpoints for Grade operations. + +Endpoints: +- POST /grades/ Create a new grade +- GET /grades/ Get all grades +- GET /grades/{id} Get a specific grade +- GET /grades/enrollment/{id} Get grade for an enrollment +- PUT /grades/{id} Update a grade +- DELETE /grades/{id} Delete a grade +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.schemas import GradeCreate, GradeResponse +from app.services import grades_service as grade_service +from pydantic import BaseModel + +router = APIRouter() + +class GradeUpdate(BaseModel): + """Schema for updating grade marks.""" + marks: float + +@router.post("/", response_model=GradeResponse, status_code=201) +def create_grade(grade: GradeCreate, db: Session = Depends(get_db)): + """ + Create a new grade for an enrollment. + + Automatically calculates final_grade (A, B, C, D, F) based on marks. + Validates marks are between 0-100. + """ + return grade_service.create_grade(db, grade) + +@router.get("/", response_model=List[GradeResponse]) +def get_all_grades(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all grades with pagination.""" + return grade_service.get_all_grades(db, skip, limit) + +@router.get("/{grade_id}", response_model=GradeResponse) +def get_grade(grade_id: int, db: Session = Depends(get_db)): + """Get a specific grade by ID.""" + return grade_service.get_grade_by_id(db, grade_id) + +@router.get("/enrollment/{enrollment_id}", response_model=GradeResponse) +def get_grade_by_enrollment(enrollment_id: int, db: Session = Depends(get_db)): + """Get the grade for a specific enrollment (student's grade in a course).""" + return grade_service.get_grade_by_enrollment(db, enrollment_id) + +@router.put("/{grade_id}", response_model=GradeResponse) +def update_grade(grade_id: int, grade_update: GradeUpdate, db: Session = Depends(get_db)): + """ + Update a grade's marks. + + Automatically recalculates final_grade based on new marks. + """ + return grade_service.update_grade(db, grade_id, grade_update.marks) + +@router.delete("/{grade_id}") +def delete_grade(grade_id: int, db: Session = Depends(get_db)): + """Delete a grade.""" + return grade_service.delete_grade(db, grade_id) diff --git a/app/routers/students.py b/app/routers/students.py deleted file mode 100644 index 189097a..0000000 --- a/app/routers/students.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/") -def get_students(): - return {"message": "Student routes - coming in Step 2"} diff --git a/app/routers/students_router.py b/app/routers/students_router.py new file mode 100644 index 0000000..f3ffb98 --- /dev/null +++ b/app/routers/students_router.py @@ -0,0 +1,122 @@ +""" +Student Router - API endpoints for Student operations. + +This module defines the REST API endpoints for student management. +It handles HTTP requests and responses, delegates business logic to the service layer. + +Layer Architecture: +Router (this file) -> Service -> Repository -> Database + +Endpoints: +- POST /students/ Create a new student +- GET /students/ Get all students (with pagination) +- GET /students/{id} Get a specific student by ID +- PUT /students/{id} Update a student +- DELETE /students/{id} Delete a student +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.database import get_db +from app.schemas import StudentCreate, StudentResponse +from app.services import students_service as student_service + +# Create router instance +# This will be included in main.py with prefix="/students" +router = APIRouter() + +@router.post("/", response_model=StudentResponse, status_code=201) +def create_student(student: StudentCreate, db: Session = Depends(get_db)): + """ + Create a new student. + + Request Body: + { + "name": "John Doe", + "email": "john@example.com" + } + + Response: 201 Created + { + "id": 1, + "name": "John Doe", + "email": "john@example.com" + } + + Errors: + - 400: Email already registered + """ + return student_service.create_student(db, student) + +@router.get("/", response_model=List[StudentResponse]) +def get_all_students(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """ + Get all students with pagination. + + Query Parameters: + - skip: Number of records to skip (default: 0) + - limit: Maximum records to return (default: 100) + + Response: 200 OK + [ + {"id": 1, "name": "John Doe", "email": "john@example.com"}, + {"id": 2, "name": "Jane Smith", "email": "jane@example.com"} + ] + """ + return student_service.get_all_students(db, skip, limit) + +@router.get("/{student_id}", response_model=StudentResponse) +def get_student(student_id: int, db: Session = Depends(get_db)): + """ + Get a specific student by ID. + + Path Parameter: + - student_id: Student's ID + + Response: 200 OK + {"id": 1, "name": "John Doe", "email": "john@example.com"} + + Errors: + - 404: Student not found + """ + return student_service.get_student_by_id(db, student_id) + +@router.put("/{student_id}", response_model=StudentResponse) +def update_student(student_id: int, student: StudentCreate, db: Session = Depends(get_db)): + """ + Update an existing student. + + Path Parameter: + - student_id: Student's ID + + Request Body: + { + "name": "John Doe Updated", + "email": "john.new@example.com" + } + + Response: 200 OK + {"id": 1, "name": "John Doe Updated", "email": "john.new@example.com"} + + Errors: + - 404: Student not found + - 400: Email already registered by another student + """ + return student_service.update_student(db, student_id, student) + +@router.delete("/{student_id}") +def delete_student(student_id: int, db: Session = Depends(get_db)): + """ + Delete a student. + + Path Parameter: + - student_id: Student's ID + + Response: 200 OK + {"message": "Student deleted successfully"} + + Errors: + - 404: Student not found + """ + return student_service.delete_student(db, student_id) diff --git a/app/schemas.py b/app/schemas.py index 2b7b23c..3d93fc6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,11 +1,19 @@ -from pydantic import BaseModel, EmailStr +""" +Pydantic Schemas - Request and Response models. + +These schemas define the structure of data for API requests and responses. +They provide automatic validation and serialization. +""" + +from pydantic import BaseModel from datetime import date from typing import Optional # Student schemas class StudentBase(BaseModel): + """Base schema for student with common fields.""" name: str - email: EmailStr + email: str # Using simple string for flexibility class StudentCreate(StudentBase): pass diff --git a/app/services/courses.py b/app/services/courses.py deleted file mode 100644 index 930435e..0000000 --- a/app/services/courses.py +++ /dev/null @@ -1,2 +0,0 @@ -# Course business logic will be implemented in Step 2 -pass diff --git a/app/services/courses_service.py b/app/services/courses_service.py new file mode 100644 index 0000000..5b80ca3 --- /dev/null +++ b/app/services/courses_service.py @@ -0,0 +1,131 @@ +""" +Course Service - Business logic layer for Course operations. + +Similar to students_service, but handles course-specific business rules. + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.schemas import CourseCreate +from app.repositories import courses_repository as course_repo +from fastapi import HTTPException + +def create_course(db: Session, course: CourseCreate): + """ + Create a new course with course code validation. + + Business Rules: + - Course code must be unique across all courses + + Args: + db: Database session + course: CourseCreate schema with course details + + Returns: + Course: Created course object + + Raises: + HTTPException 400: If course code already exists + """ + # Check if course code already exists + existing_course = course_repo.get_course_by_code(db, course.course_code) + if existing_course: + raise HTTPException(status_code=400, detail="Course code already exists") + return course_repo.create_course(db, course) + +def get_all_courses(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all courses with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum records to return + + Returns: + List[Course]: List of all courses + """ + return course_repo.get_all_courses(db, skip, limit) + +def get_course_by_id(db: Session, course_id: int): + """ + Get a course by ID with existence validation. + + Business Rules: + - Course must exist + + Args: + db: Database session + course_id: Course's ID + + Returns: + Course: Course object + + Raises: + HTTPException 404: If course not found + """ + course = course_repo.get_course_by_id(db, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + return course + +def update_course(db: Session, course_id: int, course: CourseCreate): + """ + Update a course with validation. + + Business Rules: + - Course must exist + - New course code must be unique (not used by another course) + + Args: + db: Database session + course_id: ID of course to update + course: CourseCreate schema with new data + + Returns: + Course: Updated course object + + Raises: + HTTPException 404: If course not found + HTTPException 400: If new course code already taken + """ + # Check if course exists + existing_course = course_repo.get_course_by_id(db, course_id) + if not existing_course: + raise HTTPException(status_code=404, detail="Course not found") + + # Check if new course code is already taken by another course + code_check = course_repo.get_course_by_code(db, course.course_code) + if code_check and code_check.id != course_id: + raise HTTPException(status_code=400, detail="Course code already exists") + + return course_repo.update_course(db, course_id, course) + +def delete_course(db: Session, course_id: int): + """ + Delete a course with existence validation. + + Business Rules: + - Course must exist + + Args: + db: Database session + course_id: ID of course to delete + + Returns: + dict: Success message + + Raises: + HTTPException 404: If course not found + HTTPException 500: If deletion fails + """ + course = course_repo.get_course_by_id(db, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + success = course_repo.delete_course(db, course_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete course") + return {"message": "Course deleted successfully"} diff --git a/app/services/enrollments.py b/app/services/enrollments.py deleted file mode 100644 index 6c00a65..0000000 --- a/app/services/enrollments.py +++ /dev/null @@ -1,2 +0,0 @@ -# Enrollment business logic will be implemented in Step 3 -pass diff --git a/app/services/enrollments_service.py b/app/services/enrollments_service.py new file mode 100644 index 0000000..42d4333 --- /dev/null +++ b/app/services/enrollments_service.py @@ -0,0 +1,179 @@ +""" +Enrollment Service - Business logic layer for Enrollment operations. + +This service handles the complex business rules for enrollments: +- Validates that both student and course exist +- Prevents duplicate enrollments +- Manages the relationship between students and courses + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.schemas import EnrollmentCreate +from app.repositories import enrollments_repository as enrollment_repo +from app.repositories import students_repository as student_repo +from app.repositories import courses_repository as course_repo +from fastapi import HTTPException + +def create_enrollment(db: Session, enrollment: EnrollmentCreate): + """ + Create a new enrollment with comprehensive validation. + + Business Rules: + 1. Student must exist + 2. Course must exist + 3. Student cannot be enrolled in the same course twice (no duplicates) + + Args: + db: Database session + enrollment: EnrollmentCreate schema with student_id, course_id, date + + Returns: + Enrollment: Created enrollment object + + Raises: + HTTPException 404: If student or course not found + HTTPException 400: If student already enrolled in this course + + Flow: + 1. Validate student exists (call students_repository) + 2. Validate course exists (call courses_repository) + 3. Check for duplicate enrollment (call enrollments_repository) + 4. If all validations pass, create enrollment + 5. Return created enrollment + """ + # Check if student exists + student = student_repo.get_student_by_id(db, enrollment.student_id) + if not student: + raise HTTPException(status_code=404, detail="Student not found") + + # Check if course exists + course = course_repo.get_course_by_id(db, enrollment.course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # Check for duplicate enrollment (student already enrolled in this course) + existing_enrollment = enrollment_repo.get_enrollment_by_student_and_course( + db, enrollment.student_id, enrollment.course_id + ) + if existing_enrollment: + raise HTTPException( + status_code=400, + detail="Student is already enrolled in this course" + ) + + return enrollment_repo.create_enrollment(db, enrollment) + +def get_all_enrollments(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all enrollments with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum records to return + + Returns: + List[Enrollment]: List of all enrollments + """ + return enrollment_repo.get_all_enrollments(db, skip, limit) + +def get_enrollment_by_id(db: Session, enrollment_id: int): + """ + Get an enrollment by ID with existence validation. + + Args: + db: Database session + enrollment_id: Enrollment's ID + + Returns: + Enrollment: Enrollment object + + Raises: + HTTPException 404: If enrollment not found + """ + enrollment = enrollment_repo.get_enrollment_by_id(db, enrollment_id) + if not enrollment: + raise HTTPException(status_code=404, detail="Enrollment not found") + return enrollment + +def get_enrollments_by_student(db: Session, student_id: int): + """ + Get all courses a student is enrolled in. + + Business Rules: + - Student must exist + + Args: + db: Database session + student_id: Student's ID + + Returns: + List[Enrollment]: All enrollments for this student + + Raises: + HTTPException 404: If student not found + + Use Case: + View all courses a specific student is taking + """ + # Check if student exists + student = student_repo.get_student_by_id(db, student_id) + if not student: + raise HTTPException(status_code=404, detail="Student not found") + return enrollment_repo.get_enrollments_by_student(db, student_id) + +def get_enrollments_by_course(db: Session, course_id: int): + """ + Get all students enrolled in a specific course. + + Business Rules: + - Course must exist + + Args: + db: Database session + course_id: Course's ID + + Returns: + List[Enrollment]: All enrollments for this course + + Raises: + HTTPException 404: If course not found + + Use Case: + View all students taking a specific course (class roster) + """ + # Check if course exists + course = course_repo.get_course_by_id(db, course_id) + if not course: + raise HTTPException(status_code=404, detail="Course not found") + return enrollment_repo.get_enrollments_by_course(db, course_id) + +def delete_enrollment(db: Session, enrollment_id: int): + """ + Delete an enrollment (unenroll a student from a course). + + Business Rules: + - Enrollment must exist + + Args: + db: Database session + enrollment_id: ID of enrollment to delete + + Returns: + dict: Success message + + Raises: + HTTPException 404: If enrollment not found + HTTPException 500: If deletion fails + """ + enrollment = enrollment_repo.get_enrollment_by_id(db, enrollment_id) + if not enrollment: + raise HTTPException(status_code=404, detail="Enrollment not found") + + success = enrollment_repo.delete_enrollment(db, enrollment_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete enrollment") + return {"message": "Enrollment deleted successfully"} diff --git a/app/services/grades.py b/app/services/grades.py deleted file mode 100644 index 0821227..0000000 --- a/app/services/grades.py +++ /dev/null @@ -1,2 +0,0 @@ -# Grade calculation logic will be implemented in Step 3 -pass diff --git a/app/services/grades_service.py b/app/services/grades_service.py new file mode 100644 index 0000000..71f86ac --- /dev/null +++ b/app/services/grades_service.py @@ -0,0 +1,241 @@ +""" +Grade Service - Business logic layer for Grade operations. + +This service handles: +- Grade calculation based on marks +- Marks validation (0-100 range) +- Preventing duplicate grades for same enrollment +- Automatic final_grade calculation + +Layer Architecture: +Router -> Service -> Repository -> Database +""" + +from sqlalchemy.orm import Session +from app.schemas import GradeCreate +from app.repositories import grades_repository as grade_repo +from app.repositories import enrollments_repository as enrollment_repo +from fastapi import HTTPException + +def calculate_final_grade(marks: float) -> str: + """ + Calculate letter grade based on numerical marks. + + Grading Scale: + - 90-100: A (Excellent) + - 80-89: B (Good) + - 70-79: C (Average) + - 60-69: D (Below Average) + - 0-59: F (Fail) + + Args: + marks: Numerical marks (0-100) + + Returns: + str: Letter grade (A, B, C, D, or F) + + Example: + calculate_final_grade(95) -> "A" + calculate_final_grade(75) -> "C" + calculate_final_grade(55) -> "F" + """ + if marks >= 90: + return "A" + elif marks >= 80: + return "B" + elif marks >= 70: + return "C" + elif marks >= 60: + return "D" + else: + return "F" + +def create_grade(db: Session, grade: GradeCreate): + """ + Create a new grade with validation and automatic calculation. + + Business Rules: + 1. Marks must be between 0 and 100 + 2. Enrollment must exist + 3. Only one grade per enrollment (no duplicates) + 4. Final grade is automatically calculated from marks + + Args: + db: Database session + grade: GradeCreate schema with enrollment_id and marks + + Returns: + Grade: Created grade object with calculated final_grade + + Raises: + HTTPException 400: If marks out of range or grade already exists + HTTPException 404: If enrollment not found + + Flow: + 1. Validate marks are in 0-100 range + 2. Check if enrollment exists + 3. Check if grade already exists for this enrollment + 4. Calculate final_grade from marks + 5. Create grade with calculated final_grade + 6. Return created grade + """ + # Validate marks range (0-100) + if grade.marks < 0 or grade.marks > 100: + raise HTTPException(status_code=400, detail="Marks must be between 0 and 100") + + # Check if enrollment exists + enrollment = enrollment_repo.get_enrollment_by_id(db, grade.enrollment_id) + if not enrollment: + raise HTTPException(status_code=404, detail="Enrollment not found") + + # Check if grade already exists for this enrollment (prevent duplicates) + existing_grade = grade_repo.get_grade_by_enrollment(db, grade.enrollment_id) + if existing_grade: + raise HTTPException( + status_code=400, + detail="Grade already exists for this enrollment. Use update instead." + ) + + # Calculate final grade based on marks + final_grade = calculate_final_grade(grade.marks) + + # Create grade with calculated final_grade + grade_data = grade.model_dump() + grade_data['final_grade'] = final_grade + db_grade = grade_repo.create_grade(db, GradeCreate(**grade_data)) + + return db_grade + +def get_all_grades(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all grades with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum records to return + + Returns: + List[Grade]: List of all grades + """ + return grade_repo.get_all_grades(db, skip, limit) + +def get_grade_by_id(db: Session, grade_id: int): + """ + Get a grade by ID with existence validation. + + Args: + db: Database session + grade_id: Grade's ID + + Returns: + Grade: Grade object + + Raises: + HTTPException 404: If grade not found + """ + grade = grade_repo.get_grade_by_id(db, grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + return grade + +def get_grade_by_enrollment(db: Session, enrollment_id: int): + """ + Get the grade for a specific enrollment. + + Business Rules: + - Enrollment must exist + - Grade must exist for this enrollment + + Args: + db: Database session + enrollment_id: Enrollment's ID + + Returns: + Grade: Grade object for this enrollment + + Raises: + HTTPException 404: If enrollment or grade not found + + Use Case: + View a student's grade for a specific course + """ + # Check if enrollment exists + enrollment = enrollment_repo.get_enrollment_by_id(db, enrollment_id) + if not enrollment: + raise HTTPException(status_code=404, detail="Enrollment not found") + + # Get grade for this enrollment + grade = grade_repo.get_grade_by_enrollment(db, enrollment_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found for this enrollment") + return grade + +def update_grade(db: Session, grade_id: int, marks: float): + """ + Update a grade's marks and recalculate final_grade. + + Business Rules: + 1. Marks must be between 0 and 100 + 2. Grade must exist + 3. Final grade is automatically recalculated + + Args: + db: Database session + grade_id: ID of grade to update + marks: New numerical marks + + Returns: + Grade: Updated grade object with recalculated final_grade + + Raises: + HTTPException 400: If marks out of range + HTTPException 404: If grade not found + + Flow: + 1. Validate marks are in 0-100 range + 2. Check if grade exists + 3. Recalculate final_grade from new marks + 4. Update grade with new marks and final_grade + 5. Return updated grade + """ + # Validate marks range + if marks < 0 or marks > 100: + raise HTTPException(status_code=400, detail="Marks must be between 0 and 100") + + # Check if grade exists + grade = grade_repo.get_grade_by_id(db, grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + + # Calculate new final grade + final_grade = calculate_final_grade(marks) + + return grade_repo.update_grade(db, grade_id, marks, final_grade) + +def delete_grade(db: Session, grade_id: int): + """ + Delete a grade. + + Business Rules: + - Grade must exist + + Args: + db: Database session + grade_id: ID of grade to delete + + Returns: + dict: Success message + + Raises: + HTTPException 404: If grade not found + HTTPException 500: If deletion fails + """ + grade = grade_repo.get_grade_by_id(db, grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + + success = grade_repo.delete_grade(db, grade_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete grade") + return {"message": "Grade deleted successfully"} diff --git a/app/services/students.py b/app/services/students.py deleted file mode 100644 index e5f5d11..0000000 --- a/app/services/students.py +++ /dev/null @@ -1,2 +0,0 @@ -# Student business logic will be implemented in Step 2 -pass diff --git a/app/services/students_service.py b/app/services/students_service.py new file mode 100644 index 0000000..4f8f209 --- /dev/null +++ b/app/services/students_service.py @@ -0,0 +1,162 @@ +""" +Student Service - Business logic layer for Student operations. + +This module contains business logic and validation for student operations. +It sits between the router (API layer) and repository (database layer). + +Layer Architecture: +Router (API endpoints) -> Service (business logic) -> Repository (database queries) + +The service layer: +- Validates business rules (e.g., email uniqueness) +- Handles error cases and raises appropriate HTTP exceptions +- Calls repository functions for database operations +- Does NOT directly interact with the database +""" + +from sqlalchemy.orm import Session +from app.schemas import StudentCreate +from app.repositories import students_repository as student_repo +from fastapi import HTTPException + +def create_student(db: Session, student: StudentCreate): + """ + Create a new student with email validation. + + Business Rules: + - Email must be unique across all students + + Args: + db: Database session + student: StudentCreate schema with name and email + + Returns: + Student: Created student object + + Raises: + HTTPException 400: If email already exists + + Flow: + 1. Check if email already exists (call repository) + 2. If exists, raise 400 error + 3. If not, create student (call repository) + 4. Return created student + """ + # Check if email already exists + existing_student = student_repo.get_student_by_email(db, student.email) + if existing_student: + raise HTTPException(status_code=400, detail="Email already registered") + return student_repo.create_student(db, student) + +def get_all_students(db: Session, skip: int = 0, limit: int = 100): + """ + Retrieve all students with pagination. + + No business logic needed here, just pass through to repository. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum records to return + + Returns: + List[Student]: List of all students + """ + return student_repo.get_all_students(db, skip, limit) + +def get_student_by_id(db: Session, student_id: int): + """ + Get a student by ID with existence validation. + + Business Rules: + - Student must exist + + Args: + db: Database session + student_id: Student's ID + + Returns: + Student: Student object + + Raises: + HTTPException 404: If student not found + + Flow: + 1. Try to find student (call repository) + 2. If not found, raise 404 error + 3. If found, return student + """ + student = student_repo.get_student_by_id(db, student_id) + if not student: + raise HTTPException(status_code=404, detail="Student not found") + return student + +def update_student(db: Session, student_id: int, student: StudentCreate): + """ + Update a student with validation. + + Business Rules: + - Student must exist + - New email must be unique (not used by another student) + + Args: + db: Database session + student_id: ID of student to update + student: StudentCreate schema with new data + + Returns: + Student: Updated student object + + Raises: + HTTPException 404: If student not found + HTTPException 400: If new email already taken by another student + + Flow: + 1. Check if student exists + 2. Check if new email is taken by another student + 3. If validations pass, update student + 4. Return updated student + """ + # Check if student exists + existing_student = student_repo.get_student_by_id(db, student_id) + if not existing_student: + raise HTTPException(status_code=404, detail="Student not found") + + # Check if new email is already taken by another student + email_check = student_repo.get_student_by_email(db, student.email) + if email_check and email_check.id != student_id: + raise HTTPException(status_code=400, detail="Email already registered") + + return student_repo.update_student(db, student_id, student) + +def delete_student(db: Session, student_id: int): + """ + Delete a student with existence validation. + + Business Rules: + - Student must exist + + Args: + db: Database session + student_id: ID of student to delete + + Returns: + dict: Success message + + Raises: + HTTPException 404: If student not found + HTTPException 500: If deletion fails + + Flow: + 1. Check if student exists + 2. Try to delete student + 3. Return success message + """ + student = student_repo.get_student_by_id(db, student_id) + if not student: + raise HTTPException(status_code=404, detail="Student not found") + + success = student_repo.delete_student(db, student_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete student") + return {"message": "Student deleted successfully"} diff --git a/screenshots/.gitkeep b/screenshots/.gitkeep new file mode 100644 index 0000000..53868cc --- /dev/null +++ b/screenshots/.gitkeep @@ -0,0 +1,2 @@ +# Screenshots folder +Add your Postman API testing screenshots here.