diff --git a/README.md b/README.md index e8acfba..edf20f5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,152 @@ -# FastAPI Application +# School Portal API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive school management system built with Python FastAPI, supporting multiple user roles and complete student lifecycle management. + +## Features + +### User Roles +- **Admin**: Full system access, user management, class management +- **Teacher**: Student management, grade tracking, attendance marking, notifications +- **Student**: View grades, attendance, and notifications +- **Parent**: View child's grades, attendance, and notifications + +### Core Functionality +- **Authentication**: JWT-based login and registration for all user roles +- **Student Management**: Create, update, and delete student profiles +- **Class Management**: Assign students to classes and teachers to classes +- **Subject & Grade Tracking**: Teachers record grades per subject, students/parents view them +- **Attendance**: Daily attendance marking and viewing +- **Notifications**: Announcements and messages to students and parents + +## Project Structure + +``` +schoolportalapi/ +├── app/ +│ ├── api/ +│ │ └── v1/ +│ │ ├── endpoints/ +│ │ │ ├── auth.py # Authentication endpoints +│ │ │ ├── users.py # User management +│ │ │ ├── classes.py # Class management +│ │ │ ├── subjects.py # Subject management +│ │ │ ├── grades.py # Grade tracking +│ │ │ ├── attendance.py # Attendance management +│ │ │ └── notifications.py # Notification system +│ │ └── api.py # API router +│ ├── core/ +│ │ ├── config.py # Configuration settings +│ │ └── security.py # JWT and password utilities +│ ├── db/ +│ │ ├── base.py # SQLAlchemy base +│ │ └── session.py # Database session +│ ├── models/ # SQLAlchemy models +│ │ ├── user.py +│ │ ├── class_model.py +│ │ ├── subject.py +│ │ ├── grade.py +│ │ ├── attendance.py +│ │ └── notification.py +│ ├── schemas/ # Pydantic schemas +│ │ ├── user.py +│ │ ├── class_schema.py +│ │ ├── subject.py +│ │ ├── grade.py +│ │ ├── attendance.py +│ │ ├── notification.py +│ │ └── token.py +│ └── services/ # Business logic layer +│ ├── base.py +│ ├── user.py +│ ├── class_service.py +│ ├── subject.py +│ ├── grade.py +│ ├── attendance.py +│ └── notification.py +├── alembic/ # Database migrations +│ ├── versions/ +│ └── env.py +├── main.py # FastAPI application entry point +├── requirements.txt # Python dependencies +└── alembic.ini # Alembic configuration +``` + +## Setup Instructions + +### Prerequisites +- Python 3.8+ +- pip + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd schoolportalapi + ``` + +2. **Create virtual environment** + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Set environment variables** + Create a `.env` file or set the following environment variables: + ```bash + # Required Environment Variables + SECRET_KEY=your-secret-key-change-in-production + DATABASE_URL=sqlite:////app/storage/db/db.sqlite + FIRST_SUPERUSER_EMAIL=admin@schoolportal.com + FIRST_SUPERUSER_PASSWORD=admin123 + ``` + +5. **Create storage directory** + ```bash + mkdir -p /app/storage/db + ``` + +6. **Run database migrations** + ```bash + alembic upgrade head + ``` + +7. **Start the application** + ```bash + uvicorn main:app --reload --host 0.0.0.0 --port 8000 + ``` + +## API Documentation + +Once the application is running, access the interactive API documentation: + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **OpenAPI JSON**: http://localhost:8000/openapi.json + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `SECRET_KEY` | JWT secret key | `your-secret-key-change-in-production` | +| `DATABASE_URL` | Database connection URL | `sqlite:////app/storage/db/db.sqlite` | +| `FIRST_SUPERUSER_EMAIL` | Initial admin email | `admin@schoolportal.com` | +| `FIRST_SUPERUSER_PASSWORD` | Initial admin password | `admin123` | + +## Security Features + +- JWT-based authentication +- Password hashing using bcrypt +- Role-based access control +- CORS configuration for cross-origin requests +- Input validation using Pydantic schemas + +## Health Check + +The application provides a health check endpoint: +- `GET /health` - Returns application health status \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..017f263 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..114ac43 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from app.db.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..30bbf8d --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = '${up_revision}' +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..74dedda --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,147 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table('classes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('grade_level', sa.String(), nullable=False), + sa.Column('academic_year', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_classes_id'), 'classes', ['id'], unique=False) + + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('first_name', sa.String(), nullable=False), + sa.Column('last_name', sa.String(), nullable=False), + sa.Column('role', sa.Enum('admin', 'teacher', 'student', 'parent', name='userrole'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('class_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['class_id'], ['classes.id'], ), + sa.ForeignKeyConstraint(['parent_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + + op.create_table('subjects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('class_id', sa.Integer(), nullable=False), + sa.Column('teacher_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['class_id'], ['classes.id'], ), + sa.ForeignKeyConstraint(['teacher_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_index(op.f('ix_subjects_id'), 'subjects', ['id'], unique=False) + + op.create_table('teacher_classes', + sa.Column('teacher_id', sa.Integer(), nullable=False), + sa.Column('class_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['class_id'], ['classes.id'], ), + sa.ForeignKeyConstraint(['teacher_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('teacher_id', 'class_id') + ) + + op.create_table('attendance', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('student_id', sa.Integer(), nullable=False), + sa.Column('class_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('is_present', sa.Boolean(), nullable=True), + sa.Column('remarks', sa.String(), nullable=True), + sa.Column('marked_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['class_id'], ['classes.id'], ), + sa.ForeignKeyConstraint(['marked_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['student_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_attendance_id'), 'attendance', ['id'], unique=False) + + op.create_table('grades', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('student_id', sa.Integer(), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Float(), nullable=False), + sa.Column('max_score', sa.Float(), nullable=False), + sa.Column('grade_type', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('graded_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['student_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['subject_id'], ['subjects.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_grades_id'), 'grades', ['id'], unique=False) + + op.create_table('notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('notification_type', sa.String(), nullable=False), + sa.Column('priority', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False) + + op.create_table('notification_recipients', + sa.Column('notification_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=True), + sa.Column('read_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['notification_id'], ['notifications.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('notification_id', 'user_id') + ) + + +def downgrade() -> None: + op.drop_table('notification_recipients') + op.drop_index(op.f('ix_notifications_id'), table_name='notifications') + op.drop_table('notifications') + op.drop_index(op.f('ix_grades_id'), table_name='grades') + op.drop_table('grades') + op.drop_index(op.f('ix_attendance_id'), table_name='attendance') + op.drop_table('attendance') + op.drop_table('teacher_classes') + op.drop_index(op.f('ix_subjects_id'), table_name='subjects') + op.drop_table('subjects') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_classes_id'), table_name='classes') + op.drop_table('classes') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..aacc1f6 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,62 @@ +from typing import Generator +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.db.session import SessionLocal +from app.core import security +from app.models.user import User, UserRole +from app.services.user import user_service + +security_scheme = HTTPBearer() + +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + +def get_current_user( + db: Session = Depends(get_db), + credentials: HTTPAuthorizationCredentials = Depends(security_scheme) +) -> User: + token = credentials.credentials + user_id = security.verify_token(token) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + user = user_service.get(db, id=int(user_id)) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +def get_current_admin_user( + current_user: User = Depends(get_current_user), +) -> User: + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + +def get_current_teacher_or_admin( + current_user: User = Depends(get_current_user), +) -> User: + if current_user.role not in [UserRole.TEACHER, UserRole.ADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..d5f5312 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from app.api.v1.endpoints import auth, users, classes, subjects, grades, attendance, notifications + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(classes.router, prefix="/classes", tags=["classes"]) +api_router.include_router(subjects.router, prefix="/subjects", tags=["subjects"]) +api_router.include_router(grades.router, prefix="/grades", tags=["grades"]) +api_router.include_router(attendance.router, prefix="/attendance", tags=["attendance"]) +api_router.include_router(notifications.router, prefix="/notifications", tags=["notifications"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/attendance.py b/app/api/v1/endpoints/attendance.py new file mode 100644 index 0000000..5ff454f --- /dev/null +++ b/app/api/v1/endpoints/attendance.py @@ -0,0 +1,83 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import date +from app.api import deps +from app.models.user import User, UserRole +from app.schemas.attendance import Attendance, AttendanceCreate, AttendanceUpdate +from app.services.attendance import attendance_service + +router = APIRouter() + +@router.get("/", response_model=List[Attendance]) +def read_attendance( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + student_id: int = None, + class_id: int = None, + date: date = None, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + if current_user.role == UserRole.STUDENT: + attendance = attendance_service.get_by_student(db, student_id=current_user.id) + elif current_user.role == UserRole.PARENT: + if not student_id: + raise HTTPException(status_code=400, detail="Parent must specify student_id") + attendance = attendance_service.get_by_student(db, student_id=student_id) + elif student_id and date: + attendance = [attendance_service.get_by_student_and_date(db, student_id=student_id, date=date)] + elif student_id: + attendance = attendance_service.get_by_student(db, student_id=student_id) + elif class_id: + attendance = attendance_service.get_by_class(db, class_id=class_id) + elif date: + attendance = attendance_service.get_by_date(db, date=date) + else: + attendance = attendance_service.get_multi(db, skip=skip, limit=limit) + + return [a for a in attendance if a is not None] + +@router.post("/", response_model=Attendance) +def create_attendance( + *, + db: Session = Depends(deps.get_db), + attendance_in: AttendanceCreate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + attendance = attendance_service.create_with_teacher( + db, obj_in=attendance_in, teacher_id=current_user.id + ) + return attendance + +@router.put("/{attendance_id}", response_model=Attendance) +def update_attendance( + *, + db: Session = Depends(deps.get_db), + attendance_id: int, + attendance_in: AttendanceUpdate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + attendance = attendance_service.get(db, id=attendance_id) + if not attendance: + raise HTTPException(status_code=404, detail="Attendance not found") + attendance = attendance_service.update(db, db_obj=attendance, obj_in=attendance_in) + return attendance + +@router.get("/{attendance_id}", response_model=Attendance) +def read_attendance_record( + *, + db: Session = Depends(deps.get_db), + attendance_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + attendance = attendance_service.get(db, id=attendance_id) + if not attendance: + raise HTTPException(status_code=404, detail="Attendance not found") + + if current_user.role == UserRole.STUDENT and attendance.student_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + elif current_user.role == UserRole.PARENT and attendance.student.parent_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return attendance \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..3347c70 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,60 @@ +from datetime import timedelta +from typing import Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.api import deps +from app.core import security +from app.core.config import settings +from app.schemas.token import Token +from app.schemas.user import User, UserCreate +from app.services.user import user_service + +router = APIRouter() + +@router.post("/login", response_model=Token) +def login_for_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + user = user_service.authenticate( + db, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif not user_service.is_active(user): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + +@router.post("/register", response_model=User) +def register_user( + *, + db: Session = Depends(deps.get_db), + user_in: UserCreate, +) -> Any: + user = user_service.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + user = user_service.create(db, obj_in=user_in) + return user + +@router.get("/me", response_model=User) +def read_users_me( + db: Session = Depends(deps.get_db), + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + return current_user \ No newline at end of file diff --git a/app/api/v1/endpoints/classes.py b/app/api/v1/endpoints/classes.py new file mode 100644 index 0000000..c610db4 --- /dev/null +++ b/app/api/v1/endpoints/classes.py @@ -0,0 +1,68 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.api import deps +from app.models.user import User +from app.schemas.class_schema import Class, ClassCreate, ClassUpdate +from app.services.class_service import class_service + +router = APIRouter() + +@router.get("/", response_model=List[Class]) +def read_classes( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + classes = class_service.get_multi(db, skip=skip, limit=limit) + return classes + +@router.post("/", response_model=Class) +def create_class( + *, + db: Session = Depends(deps.get_db), + class_in: ClassCreate, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + class_obj = class_service.create(db, obj_in=class_in) + return class_obj + +@router.put("/{class_id}", response_model=Class) +def update_class( + *, + db: Session = Depends(deps.get_db), + class_id: int, + class_in: ClassUpdate, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + class_obj = class_service.get(db, id=class_id) + if not class_obj: + raise HTTPException(status_code=404, detail="Class not found") + class_obj = class_service.update(db, db_obj=class_obj, obj_in=class_in) + return class_obj + +@router.get("/{class_id}", response_model=Class) +def read_class( + *, + db: Session = Depends(deps.get_db), + class_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + class_obj = class_service.get(db, id=class_id) + if not class_obj: + raise HTTPException(status_code=404, detail="Class not found") + return class_obj + +@router.delete("/{class_id}", response_model=Class) +def delete_class( + *, + db: Session = Depends(deps.get_db), + class_id: int, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + class_obj = class_service.get(db, id=class_id) + if not class_obj: + raise HTTPException(status_code=404, detail="Class not found") + class_obj = class_service.remove(db, id=class_id) + return class_obj \ No newline at end of file diff --git a/app/api/v1/endpoints/grades.py b/app/api/v1/endpoints/grades.py new file mode 100644 index 0000000..233b5f0 --- /dev/null +++ b/app/api/v1/endpoints/grades.py @@ -0,0 +1,90 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.api import deps +from app.models.user import User, UserRole +from app.schemas.grade import Grade, GradeCreate, GradeUpdate +from app.services.grade import grade_service + +router = APIRouter() + +@router.get("/", response_model=List[Grade]) +def read_grades( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + student_id: int = None, + subject_id: int = None, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + if current_user.role == UserRole.STUDENT: + grades = grade_service.get_by_student(db, student_id=current_user.id) + elif current_user.role == UserRole.PARENT: + if not student_id: + raise HTTPException(status_code=400, detail="Parent must specify student_id") + grades = grade_service.get_by_student(db, student_id=student_id) + elif student_id and subject_id: + grades = grade_service.get_by_student_and_subject(db, student_id=student_id, subject_id=subject_id) + elif student_id: + grades = grade_service.get_by_student(db, student_id=student_id) + elif subject_id: + grades = grade_service.get_by_subject(db, subject_id=subject_id) + else: + grades = grade_service.get_multi(db, skip=skip, limit=limit) + + return grades + +@router.post("/", response_model=Grade) +def create_grade( + *, + db: Session = Depends(deps.get_db), + grade_in: GradeCreate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + grade = grade_service.create(db, obj_in=grade_in) + return grade + +@router.put("/{grade_id}", response_model=Grade) +def update_grade( + *, + db: Session = Depends(deps.get_db), + grade_id: int, + grade_in: GradeUpdate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + grade = grade_service.get(db, id=grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + grade = grade_service.update(db, db_obj=grade, obj_in=grade_in) + return grade + +@router.get("/{grade_id}", response_model=Grade) +def read_grade( + *, + db: Session = Depends(deps.get_db), + grade_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + grade = grade_service.get(db, id=grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + + if current_user.role == UserRole.STUDENT and grade.student_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + elif current_user.role == UserRole.PARENT and grade.student.parent_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return grade + +@router.delete("/{grade_id}", response_model=Grade) +def delete_grade( + *, + db: Session = Depends(deps.get_db), + grade_id: int, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + grade = grade_service.get(db, id=grade_id) + if not grade: + raise HTTPException(status_code=404, detail="Grade not found") + grade = grade_service.remove(db, id=grade_id) + return grade \ No newline at end of file diff --git a/app/api/v1/endpoints/notifications.py b/app/api/v1/endpoints/notifications.py new file mode 100644 index 0000000..dc3d50b --- /dev/null +++ b/app/api/v1/endpoints/notifications.py @@ -0,0 +1,82 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.api import deps +from app.models.user import User, UserRole +from app.schemas.notification import Notification, NotificationCreate, NotificationUpdate +from app.services.notification import notification_service + +router = APIRouter() + +@router.get("/", response_model=List[Notification]) +def read_notifications( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + if current_user.role in [UserRole.STUDENT, UserRole.PARENT]: + notifications = notification_service.get_by_recipient(db, recipient_id=current_user.id) + else: + notifications = notification_service.get_multi(db, skip=skip, limit=limit) + + return notifications + +@router.post("/", response_model=Notification) +def create_notification( + *, + db: Session = Depends(deps.get_db), + notification_in: NotificationCreate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + notification = notification_service.create_with_recipients( + db, obj_in=notification_in, sender_id=current_user.id + ) + return notification + +@router.put("/{notification_id}", response_model=Notification) +def update_notification( + *, + db: Session = Depends(deps.get_db), + notification_id: int, + notification_in: NotificationUpdate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + notification = notification_service.get(db, id=notification_id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + if notification.sender_id != current_user.id and current_user.role != UserRole.ADMIN: + raise HTTPException(status_code=403, detail="Not enough permissions") + + notification = notification_service.update(db, db_obj=notification, obj_in=notification_in) + return notification + +@router.get("/{notification_id}", response_model=Notification) +def read_notification( + *, + db: Session = Depends(deps.get_db), + notification_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + notification = notification_service.get(db, id=notification_id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + return notification + +@router.delete("/{notification_id}", response_model=Notification) +def delete_notification( + *, + db: Session = Depends(deps.get_db), + notification_id: int, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + notification = notification_service.get(db, id=notification_id) + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + if notification.sender_id != current_user.id and current_user.role != UserRole.ADMIN: + raise HTTPException(status_code=403, detail="Not enough permissions") + + notification = notification_service.remove(db, id=notification_id) + return notification \ No newline at end of file diff --git a/app/api/v1/endpoints/subjects.py b/app/api/v1/endpoints/subjects.py new file mode 100644 index 0000000..86e96a4 --- /dev/null +++ b/app/api/v1/endpoints/subjects.py @@ -0,0 +1,68 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.api import deps +from app.models.user import User +from app.schemas.subject import Subject, SubjectCreate, SubjectUpdate +from app.services.subject import subject_service + +router = APIRouter() + +@router.get("/", response_model=List[Subject]) +def read_subjects( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + subjects = subject_service.get_multi(db, skip=skip, limit=limit) + return subjects + +@router.post("/", response_model=Subject) +def create_subject( + *, + db: Session = Depends(deps.get_db), + subject_in: SubjectCreate, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + subject = subject_service.create(db, obj_in=subject_in) + return subject + +@router.put("/{subject_id}", response_model=Subject) +def update_subject( + *, + db: Session = Depends(deps.get_db), + subject_id: int, + subject_in: SubjectUpdate, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + subject = subject_service.get(db, id=subject_id) + if not subject: + raise HTTPException(status_code=404, detail="Subject not found") + subject = subject_service.update(db, db_obj=subject, obj_in=subject_in) + return subject + +@router.get("/{subject_id}", response_model=Subject) +def read_subject( + *, + db: Session = Depends(deps.get_db), + subject_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + subject = subject_service.get(db, id=subject_id) + if not subject: + raise HTTPException(status_code=404, detail="Subject not found") + return subject + +@router.delete("/{subject_id}", response_model=Subject) +def delete_subject( + *, + db: Session = Depends(deps.get_db), + subject_id: int, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + subject = subject_service.get(db, id=subject_id) + if not subject: + raise HTTPException(status_code=404, detail="Subject not found") + subject = subject_service.remove(db, id=subject_id) + return subject \ No newline at end of file diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..77dea06 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -0,0 +1,115 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.api import deps +from app.models.user import User, UserRole +from app.schemas.user import User as UserSchema, UserCreate, UserUpdate +from app.services.user import user_service + +router = APIRouter() + +@router.get("/", response_model=List[UserSchema]) +def read_users( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + users = user_service.get_multi(db, skip=skip, limit=limit) + return users + +@router.post("/", response_model=UserSchema) +def create_user( + *, + db: Session = Depends(deps.get_db), + user_in: UserCreate, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + user = user_service.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system", + ) + user = user_service.create(db, obj_in=user_in) + return user + +@router.put("/{user_id}", response_model=UserSchema) +def update_user( + *, + db: Session = Depends(deps.get_db), + user_id: int, + user_in: UserUpdate, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + user = user_service.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + + if current_user.role == UserRole.TEACHER and user_in.role and user_in.role != UserRole.STUDENT: + raise HTTPException( + status_code=403, + detail="Teachers can only modify student profiles" + ) + + user = user_service.update(db, db_obj=user, obj_in=user_in) + return user + +@router.get("/{user_id}", response_model=UserSchema) +def read_user( + *, + db: Session = Depends(deps.get_db), + user_id: int, + current_user: User = Depends(deps.get_current_active_user), +) -> Any: + user = user_service.get(db, id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if current_user.role not in [UserRole.ADMIN, UserRole.TEACHER] and current_user.id != user_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return user + +@router.delete("/{user_id}", response_model=UserSchema) +def delete_user( + *, + db: Session = Depends(deps.get_db), + user_id: int, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + user = user_service.get(db, id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if current_user.role == UserRole.TEACHER and user.role != UserRole.STUDENT: + raise HTTPException( + status_code=403, + detail="Teachers can only delete student profiles" + ) + + user = user_service.remove(db, id=user_id) + return user + +@router.get("/students/", response_model=List[UserSchema]) +def read_students( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_teacher_or_admin), +) -> Any: + students = user_service.get_students(db, skip=skip, limit=limit) + return students + +@router.get("/teachers/", response_model=List[UserSchema]) +def read_teachers( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(deps.get_current_admin_user), +) -> Any: + teachers = user_service.get_teachers(db, skip=skip, limit=limit) + return teachers \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..8895b77 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,20 @@ +import os +from pydantic import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "School Portal API" + VERSION: str = "1.0.0" + API_V1_STR: str = "/api/v1" + + SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days + + DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////app/storage/db/db.sqlite") + + FIRST_SUPERUSER_EMAIL: str = os.getenv("FIRST_SUPERUSER_EMAIL", "admin@schoolportal.com") + FIRST_SUPERUSER_PASSWORD: str = os.getenv("FIRST_SUPERUSER_PASSWORD", "admin123") + + class Config: + case_sensitive = True + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..bd2017e --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta +from typing import Optional, Union +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + +def create_access_token( + subject: Union[str, int], expires_delta: Optional[timedelta] = None +): + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +def verify_token(token: str) -> Optional[str]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + return None + return user_id + except JWTError: + return None \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..7c2377a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..357d183 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,15 @@ +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +DB_DIR = Path("/app/storage/db") +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..80e2dc8 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,8 @@ +from .user import User +from .class_model import Class +from .subject import Subject +from .grade import Grade +from .attendance import Attendance +from .notification import Notification + +__all__ = ["User", "Class", "Subject", "Grade", "Attendance", "Notification"] \ No newline at end of file diff --git a/app/models/attendance.py b/app/models/attendance.py new file mode 100644 index 0000000..09828cf --- /dev/null +++ b/app/models/attendance.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Date, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + +class Attendance(Base): + __tablename__ = "attendance" + + id = Column(Integer, primary_key=True, index=True) + student_id = Column(Integer, ForeignKey("users.id"), nullable=False) + class_id = Column(Integer, ForeignKey("classes.id"), nullable=False) + date = Column(Date, nullable=False) + is_present = Column(Boolean, default=False) + remarks = Column(String) + marked_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + student = relationship("User", foreign_keys=[student_id], back_populates="attendance_records") + class_ref = relationship("Class", back_populates="attendance_records") + teacher = relationship("User", foreign_keys=[marked_by]) \ No newline at end of file diff --git a/app/models/class_model.py b/app/models/class_model.py new file mode 100644 index 0000000..4c8f496 --- /dev/null +++ b/app/models/class_model.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, DateTime, Table, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + +teacher_classes = Table( + "teacher_classes", + Base.metadata, + Column("teacher_id", Integer, ForeignKey("users.id"), primary_key=True), + Column("class_id", Integer, ForeignKey("classes.id"), primary_key=True), +) + +class Class(Base): + __tablename__ = "classes" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + grade_level = Column(String, nullable=False) + academic_year = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + students = relationship("User", back_populates="assigned_class") + teachers = relationship("User", secondary=teacher_classes, back_populates="taught_classes") + subjects = relationship("Subject", back_populates="class_ref") + attendance_records = relationship("Attendance", back_populates="class_ref") \ No newline at end of file diff --git a/app/models/grade.py b/app/models/grade.py new file mode 100644 index 0000000..a482ab5 --- /dev/null +++ b/app/models/grade.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + +class Grade(Base): + __tablename__ = "grades" + + id = Column(Integer, primary_key=True, index=True) + student_id = Column(Integer, ForeignKey("users.id"), nullable=False) + subject_id = Column(Integer, ForeignKey("subjects.id"), nullable=False) + score = Column(Float, nullable=False) + max_score = Column(Float, nullable=False, default=100.0) + grade_type = Column(String, nullable=False) # quiz, exam, assignment, etc. + description = Column(String) + graded_at = Column(DateTime(timezone=True), server_default=func.now()) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + student = relationship("User", back_populates="grades") + subject = relationship("Subject", back_populates="grades") \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..cc468c9 --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text, Table +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + +notification_recipients = Table( + "notification_recipients", + Base.metadata, + Column("notification_id", Integer, ForeignKey("notifications.id"), primary_key=True), + Column("user_id", Integer, ForeignKey("users.id"), primary_key=True), + Column("is_read", Boolean, default=False), + Column("read_at", DateTime(timezone=True)) +) + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + message = Column(Text, nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + notification_type = Column(String, nullable=False) # announcement, message, alert + priority = Column(String, default="normal") # low, normal, high, urgent + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + sender = relationship("User", foreign_keys=[sender_id], back_populates="sent_notifications") + recipients = relationship("User", secondary=notification_recipients, back_populates="received_notifications") \ No newline at end of file diff --git a/app/models/subject.py b/app/models/subject.py new file mode 100644 index 0000000..f7ea22c --- /dev/null +++ b/app/models/subject.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + +class Subject(Base): + __tablename__ = "subjects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + code = Column(String, unique=True, nullable=False) + description = Column(String) + class_id = Column(Integer, ForeignKey("classes.id"), nullable=False) + teacher_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + class_ref = relationship("Class", back_populates="subjects") + teacher = relationship("User") + grades = relationship("Grade", back_populates="subject") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..215f545 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,35 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum +from app.db.base import Base + +class UserRole(str, enum.Enum): + ADMIN = "admin" + TEACHER = "teacher" + STUDENT = "student" + PARENT = "parent" + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) + role = Column(Enum(UserRole), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + parent_id = Column(Integer, ForeignKey("users.id"), nullable=True) + class_id = Column(Integer, ForeignKey("classes.id"), nullable=True) + + parent = relationship("User", remote_side=[id], backref="children") + assigned_class = relationship("Class", back_populates="students") + taught_classes = relationship("Class", secondary="teacher_classes", back_populates="teachers") + grades = relationship("Grade", back_populates="student") + attendance_records = relationship("Attendance", back_populates="student") + sent_notifications = relationship("Notification", foreign_keys="Notification.sender_id", back_populates="sender") + received_notifications = relationship("Notification", secondary="notification_recipients", back_populates="recipients") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..be74b52 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,17 @@ +from .user import User, UserCreate, UserUpdate, UserInDB +from .class_schema import Class, ClassCreate, ClassUpdate +from .subject import Subject, SubjectCreate, SubjectUpdate +from .grade import Grade, GradeCreate, GradeUpdate +from .attendance import Attendance, AttendanceCreate, AttendanceUpdate +from .notification import Notification, NotificationCreate, NotificationUpdate +from .token import Token, TokenPayload + +__all__ = [ + "User", "UserCreate", "UserUpdate", "UserInDB", + "Class", "ClassCreate", "ClassUpdate", + "Subject", "SubjectCreate", "SubjectUpdate", + "Grade", "GradeCreate", "GradeUpdate", + "Attendance", "AttendanceCreate", "AttendanceUpdate", + "Notification", "NotificationCreate", "NotificationUpdate", + "Token", "TokenPayload" +] \ No newline at end of file diff --git a/app/schemas/attendance.py b/app/schemas/attendance.py new file mode 100644 index 0000000..13a75c9 --- /dev/null +++ b/app/schemas/attendance.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime, date + +class AttendanceBase(BaseModel): + student_id: int + class_id: int + date: date + is_present: bool = False + remarks: Optional[str] = None + +class AttendanceCreate(AttendanceBase): + pass + +class AttendanceUpdate(BaseModel): + is_present: Optional[bool] = None + remarks: Optional[str] = None + +class AttendanceInDBBase(AttendanceBase): + id: int + marked_by: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class Attendance(AttendanceInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/class_schema.py b/app/schemas/class_schema.py new file mode 100644 index 0000000..f42a07f --- /dev/null +++ b/app/schemas/class_schema.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +class ClassBase(BaseModel): + name: str + grade_level: str + academic_year: str + +class ClassCreate(ClassBase): + pass + +class ClassUpdate(BaseModel): + name: Optional[str] = None + grade_level: Optional[str] = None + academic_year: Optional[str] = None + +class ClassInDBBase(ClassBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class Class(ClassInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/grade.py b/app/schemas/grade.py new file mode 100644 index 0000000..3504395 --- /dev/null +++ b/app/schemas/grade.py @@ -0,0 +1,32 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +class GradeBase(BaseModel): + student_id: int + subject_id: int + score: float + max_score: float = 100.0 + grade_type: str + description: Optional[str] = None + +class GradeCreate(GradeBase): + pass + +class GradeUpdate(BaseModel): + score: Optional[float] = None + max_score: Optional[float] = None + grade_type: Optional[str] = None + description: Optional[str] = None + +class GradeInDBBase(GradeBase): + id: int + graded_at: datetime + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class Grade(GradeInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000..b78dab7 --- /dev/null +++ b/app/schemas/notification.py @@ -0,0 +1,30 @@ +from typing import Optional, List +from pydantic import BaseModel +from datetime import datetime + +class NotificationBase(BaseModel): + title: str + message: str + notification_type: str + priority: str = "normal" + +class NotificationCreate(NotificationBase): + recipient_ids: List[int] + +class NotificationUpdate(BaseModel): + title: Optional[str] = None + message: Optional[str] = None + notification_type: Optional[str] = None + priority: Optional[str] = None + +class NotificationInDBBase(NotificationBase): + id: int + sender_id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class Notification(NotificationInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/subject.py b/app/schemas/subject.py new file mode 100644 index 0000000..bd5fc5c --- /dev/null +++ b/app/schemas/subject.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +class SubjectBase(BaseModel): + name: str + code: str + description: Optional[str] = None + class_id: int + teacher_id: int + +class SubjectCreate(SubjectBase): + pass + +class SubjectUpdate(BaseModel): + name: Optional[str] = None + code: Optional[str] = None + description: Optional[str] = None + class_id: Optional[int] = None + teacher_id: Optional[int] = None + +class SubjectInDBBase(SubjectBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class Subject(SubjectInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..6a12e11 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import BaseModel + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenPayload(BaseModel): + sub: Optional[int] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..b98b841 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,40 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr +from datetime import datetime +from app.models.user import UserRole + +class UserBase(BaseModel): + email: EmailStr + first_name: str + last_name: str + role: UserRole + is_active: bool = True + parent_id: Optional[int] = None + class_id: Optional[int] = None + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + parent_id: Optional[int] = None + class_id: Optional[int] = None + password: Optional[str] = None + +class UserInDBBase(UserBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + orm_mode = True + +class User(UserInDBBase): + pass + +class UserInDB(UserInDBBase): + hashed_password: str \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/attendance.py b/app/services/attendance.py new file mode 100644 index 0000000..9851073 --- /dev/null +++ b/app/services/attendance.py @@ -0,0 +1,33 @@ +from typing import List +from sqlalchemy.orm import Session +from datetime import date +from app.models.attendance import Attendance +from app.schemas.attendance import AttendanceCreate, AttendanceUpdate +from app.services.base import CRUDBase + +class CRUDAttendance(CRUDBase[Attendance, AttendanceCreate, AttendanceUpdate]): + def get_by_student(self, db: Session, *, student_id: int) -> List[Attendance]: + return db.query(Attendance).filter(Attendance.student_id == student_id).all() + + def get_by_class(self, db: Session, *, class_id: int) -> List[Attendance]: + return db.query(Attendance).filter(Attendance.class_id == class_id).all() + + def get_by_date(self, db: Session, *, date: date) -> List[Attendance]: + return db.query(Attendance).filter(Attendance.date == date).all() + + def get_by_student_and_date(self, db: Session, *, student_id: int, date: date) -> Attendance: + return db.query(Attendance).filter( + Attendance.student_id == student_id, + Attendance.date == date + ).first() + + def create_with_teacher(self, db: Session, *, obj_in: AttendanceCreate, teacher_id: int) -> Attendance: + obj_in_data = obj_in.dict() + obj_in_data["marked_by"] = teacher_id + db_obj = Attendance(**obj_in_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + +attendance_service = CRUDAttendance(Attendance) \ No newline at end of file diff --git a/app/services/base.py b/app/services/base.py new file mode 100644 index 0000000..65bdbe1 --- /dev/null +++ b/app/services/base.py @@ -0,0 +1,55 @@ +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.orm import Session +from app.db.base import Base + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> ModelType: + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: int) -> ModelType: + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/app/services/class_service.py b/app/services/class_service.py new file mode 100644 index 0000000..73bc339 --- /dev/null +++ b/app/services/class_service.py @@ -0,0 +1,14 @@ +from typing import List +from sqlalchemy.orm import Session +from app.models.class_model import Class +from app.schemas.class_schema import ClassCreate, ClassUpdate +from app.services.base import CRUDBase + +class CRUDClass(CRUDBase[Class, ClassCreate, ClassUpdate]): + def get_by_grade_level(self, db: Session, *, grade_level: str) -> List[Class]: + return db.query(Class).filter(Class.grade_level == grade_level).all() + + def get_by_academic_year(self, db: Session, *, academic_year: str) -> List[Class]: + return db.query(Class).filter(Class.academic_year == academic_year).all() + +class_service = CRUDClass(Class) \ No newline at end of file diff --git a/app/services/grade.py b/app/services/grade.py new file mode 100644 index 0000000..20be1a5 --- /dev/null +++ b/app/services/grade.py @@ -0,0 +1,20 @@ +from typing import List +from sqlalchemy.orm import Session +from app.models.grade import Grade +from app.schemas.grade import GradeCreate, GradeUpdate +from app.services.base import CRUDBase + +class CRUDGrade(CRUDBase[Grade, GradeCreate, GradeUpdate]): + def get_by_student(self, db: Session, *, student_id: int) -> List[Grade]: + return db.query(Grade).filter(Grade.student_id == student_id).all() + + def get_by_subject(self, db: Session, *, subject_id: int) -> List[Grade]: + return db.query(Grade).filter(Grade.subject_id == subject_id).all() + + def get_by_student_and_subject(self, db: Session, *, student_id: int, subject_id: int) -> List[Grade]: + return db.query(Grade).filter( + Grade.student_id == student_id, + Grade.subject_id == subject_id + ).all() + +grade_service = CRUDGrade(Grade) \ No newline at end of file diff --git a/app/services/notification.py b/app/services/notification.py new file mode 100644 index 0000000..489d40d --- /dev/null +++ b/app/services/notification.py @@ -0,0 +1,35 @@ +from typing import List +from sqlalchemy.orm import Session +from app.models.notification import Notification +from app.schemas.notification import NotificationCreate, NotificationUpdate +from app.services.base import CRUDBase + +class CRUDNotification(CRUDBase[Notification, NotificationCreate, NotificationUpdate]): + def create_with_recipients(self, db: Session, *, obj_in: NotificationCreate, sender_id: int) -> Notification: + recipient_ids = obj_in.recipient_ids + obj_in_data = obj_in.dict(exclude={"recipient_ids"}) + obj_in_data["sender_id"] = sender_id + + db_obj = Notification(**obj_in_data) + db.add(db_obj) + db.flush() + + for recipient_id in recipient_ids: + db.execute( + "INSERT INTO notification_recipients (notification_id, user_id) VALUES (?, ?)", + (db_obj.id, recipient_id) + ) + + db.commit() + db.refresh(db_obj) + return db_obj + + def get_by_sender(self, db: Session, *, sender_id: int) -> List[Notification]: + return db.query(Notification).filter(Notification.sender_id == sender_id).all() + + def get_by_recipient(self, db: Session, *, recipient_id: int) -> List[Notification]: + return db.query(Notification).join( + Notification.recipients + ).filter_by(id=recipient_id).all() + +notification_service = CRUDNotification(Notification) \ No newline at end of file diff --git a/app/services/subject.py b/app/services/subject.py new file mode 100644 index 0000000..c7cb020 --- /dev/null +++ b/app/services/subject.py @@ -0,0 +1,14 @@ +from typing import List +from sqlalchemy.orm import Session +from app.models.subject import Subject +from app.schemas.subject import SubjectCreate, SubjectUpdate +from app.services.base import CRUDBase + +class CRUDSubject(CRUDBase[Subject, SubjectCreate, SubjectUpdate]): + def get_by_class(self, db: Session, *, class_id: int) -> List[Subject]: + return db.query(Subject).filter(Subject.class_id == class_id).all() + + def get_by_teacher(self, db: Session, *, teacher_id: int) -> List[Subject]: + return db.query(Subject).filter(Subject.teacher_id == teacher_id).all() + +subject_service = CRUDSubject(Subject) \ No newline at end of file diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..f016ce4 --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, Optional, Union, List +from sqlalchemy.orm import Session +from app.core.security import get_password_hash, verify_password +from app.models.user import User, UserRole +from app.schemas.user import UserCreate, UserUpdate +from app.services.base import CRUDBase + +class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): + def get_by_email(self, db: Session, *, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + def create(self, db: Session, *, obj_in: UserCreate) -> User: + db_obj = User( + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + first_name=obj_in.first_name, + last_name=obj_in.last_name, + role=obj_in.role, + is_active=obj_in.is_active, + parent_id=obj_in.parent_id, + class_id=obj_in.class_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + if "password" in update_data: + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + + return super().update(db, db_obj=db_obj, obj_in=update_data) + + def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: + user = self.get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + return user.is_active + + def get_students(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).filter(User.role == UserRole.STUDENT).offset(skip).limit(limit).all() + + def get_teachers(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).filter(User.role == UserRole.TEACHER).offset(skip).limit(limit).all() + + def get_parents(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).filter(User.role == UserRole.PARENT).offset(skip).limit(limit).all() + +user_service = CRUDUser(User) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7ea477f --- /dev/null +++ b/main.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + description="School Portal API - A comprehensive school management system", + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix="/api/v1") + +@app.get("/") +async def root(): + return { + "title": settings.PROJECT_NAME, + "version": settings.VERSION, + "description": "School Portal API - A comprehensive school management system", + "documentation": "/docs", + "health": "/health" + } + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "School Portal API"} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..126f72a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +pydantic[email]==2.5.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +ruff==0.1.6 \ No newline at end of file