diff --git a/backend/alembic/versions/ecbdc859aca6_update_project_scheme.py b/backend/alembic/versions/ecbdc859aca6_update_project_scheme.py new file mode 100644 index 00000000..7fe04b17 --- /dev/null +++ b/backend/alembic/versions/ecbdc859aca6_update_project_scheme.py @@ -0,0 +1,68 @@ +"""update project scheme + +Revision ID: ecbdc859aca6 +Revises: 29a17fa183de +Create Date: 2024-03-12 16:43:51.794097 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ecbdc859aca6' +down_revision: Union[str, None] = '29a17fa183de' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('project', 'id', + existing_type=sa.BIGINT(), + type_=sa.Integer(), + existing_nullable=False, + autoincrement=True) + op.alter_column('project', 'deadline', + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=False) + op.alter_column('project', 'subject_id', + existing_type=sa.BIGINT(), + type_=sa.Integer(), + existing_nullable=True) + op.alter_column('project', 'enroll_deadline', + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=True) + op.drop_constraint('project_subject_id_fkey', 'project', type_='foreignkey') + op.create_foreign_key(None, 'project', 'subject', [ + 'subject_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'project', type_='foreignkey') #type: ignore + op.create_foreign_key('project_subject_id_fkey', 'project', 'subject', [ + 'subject_id'], ['id'], ondelete='SET NULL') + op.alter_column('project', 'enroll_deadline', + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=True) + op.alter_column('project', 'subject_id', + existing_type=sa.Integer(), + type_=sa.BIGINT(), + existing_nullable=True) + op.alter_column('project', 'deadline', + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=False) + op.alter_column('project', 'id', + existing_type=sa.Integer(), + type_=sa.BIGINT(), + existing_nullable=False, + autoincrement=True) + # ### end Alembic commands ### diff --git a/backend/src/database.py b/backend/src/database.py index 21bbb78e..02b1c5c6 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -1,12 +1,17 @@ from sqlalchemy import MetaData, create_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker from src import config SQLALCHEMY_DATABASE_URL = config.CONFIG.database_uri +# TODO: migrate full codebase to async engine = create_engine(SQLALCHEMY_DATABASE_URL) +async_engine = create_async_engine(SQLALCHEMY_DATABASE_URL[:len( + "postgresql")] + "+asyncpg" + SQLALCHEMY_DATABASE_URL[len("postgresql"):]) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +AsyncSessionLocal = async_sessionmaker(async_engine, autoflush=False) Base = declarative_base() diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index b1c37e2d..e3a24eba 100644 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -1,4 +1,4 @@ -from .database import SessionLocal +from .database import SessionLocal, AsyncSessionLocal def get_db(): @@ -8,3 +8,12 @@ def get_db(): yield db finally: db.close() + + +async def get_async_db(): + """Creates new async database session per request, which is closed afterwards""" + db = AsyncSessionLocal() + try: + yield db + finally: + await db.close() diff --git a/backend/src/main.py b/backend/src/main.py index 548a3ce3..6e649a70 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -25,7 +25,6 @@ app.include_router(user_router) app.include_router(project_router) app.include_router(auth_router) -app.include_router(project_router) @app.get("/api") diff --git a/backend/src/project/dependencies.py b/backend/src/project/dependencies.py index 3168583c..ecbc71d2 100644 --- a/backend/src/project/dependencies.py +++ b/backend/src/project/dependencies.py @@ -3,19 +3,9 @@ from src.dependencies import get_db from src.user.dependencies import get_authenticated_user from src.user.schemas import User -from src.subject.schemas import Subject -from src.subject.schemas import SubjectList - from ..auth.exceptions import NotAuthorized -from ..subject.service import get_subjects, get_teachers - - -async def retrieve_subjects( - user: User = Depends(get_authenticated_user), db: Session = Depends(get_db) -) -> SubjectList: - teacher_subjects, student_subjects = await get_subjects(db, user.uid) - return SubjectList(as_teacher=teacher_subjects, as_student=student_subjects) +from ..subject.service import get_teachers async def user_permission_validation( diff --git a/backend/src/project/exceptions.py b/backend/src/project/exceptions.py index 6ecc8164..ed3c4d5c 100644 --- a/backend/src/project/exceptions.py +++ b/backend/src/project/exceptions.py @@ -1,23 +1,5 @@ -# exceptions.py - from fastapi import HTTPException -def NoProjectsFoundException(subject_id: int): - return HTTPException(status_code=404, detail=f"No projects found for subject {subject_id}") - - -def UnauthorizedToCreateProjectException(): - return HTTPException(status_code=403, detail="User is not authorized to create projects for this subject") - - def ProjectNotFoundException(): return HTTPException(status_code=404, detail="Project not found") - - -def UnauthorizedToDeleteProjectException(): - return HTTPException(status_code=403, detail="User is not authorized to delete this project") - - -def UnauthorizedToUpdateProjectException(): - return HTTPException(status_code=403, detail="User is not authorized to update projects for this subject") diff --git a/backend/src/project/models.py b/backend/src/project/models.py index 902e433e..237b12d9 100644 --- a/backend/src/project/models.py +++ b/backend/src/project/models.py @@ -8,13 +8,14 @@ class Project(Base): __tablename__ = 'project' id: Mapped[int] = mapped_column(primary_key=True) - deadline: Mapped[datetime] = mapped_column(nullable=False) + deadline: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) name: Mapped[str] = mapped_column(nullable=False) subject_id: Mapped[int] = mapped_column(ForeignKey( 'subject.id', ondelete="CASCADE"), nullable=True) description: Mapped[str] = mapped_column(nullable=True) - enroll_deadline: Mapped[datetime] = mapped_column(nullable=True) + enroll_deadline: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=True) __table_args__ = ( CheckConstraint('deadline >= CURRENT_DATE', name='deadline_check'), diff --git a/backend/src/project/router.py b/backend/src/project/router.py index 3fcf3551..bccad258 100644 --- a/backend/src/project/router.py +++ b/backend/src/project/router.py @@ -1,15 +1,11 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession -from src.user.models import User -from src.dependencies import get_db -from src.user.dependencies import get_authenticated_user +from src.subject.dependencies import user_permission_validation +from src.dependencies import get_async_db, get_db -from .exceptions import UnauthorizedToCreateProjectException, ProjectNotFoundException, \ - UnauthorizedToUpdateProjectException, NoProjectsFoundException +from .exceptions import ProjectNotFoundException from .schemas import ProjectCreate, ProjectResponse, ProjectUpdate from .service import create_project, get_project, delete_project, update_project, get_projects_for_subject -from ..subject.service import is_teacher_of_subject -from . import exceptions router = APIRouter( prefix="/api/subjects/{subject_id}/projects", @@ -21,33 +17,29 @@ @router.get("/", response_model=list[ProjectResponse]) async def list_projects_for_subject( subject_id: int, - user: User = Depends(get_authenticated_user), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_async_db) ): projects = await get_projects_for_subject(db, subject_id) - if not projects: - raise NoProjectsFoundException(subject_id) return projects -@router.post("/", response_model=ProjectResponse) +@router.post("/", + response_model=ProjectResponse, + dependencies=[Depends(user_permission_validation)], + status_code=201) async def create_project_for_subject( subject_id: int, project_in: ProjectCreate, - user: User = Depends(get_authenticated_user), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_async_db) ): - if not await is_teacher_of_subject(db, user.id, subject_id): - raise UnauthorizedToCreateProjectException() - - project = await create_project(db=db, project_in=project_in, user_id=user.id) + project = await create_project(db, project_in, subject_id) return project @router.get("/{project_id}", response_model=ProjectResponse) async def get_project_for_subject( project_id: int, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_async_db) ): project = await get_project(db, project_id) if not project: @@ -55,33 +47,21 @@ async def get_project_for_subject( return project -@router.delete("/{project_id}") +@router.delete("/{project_id}", dependencies=[Depends(user_permission_validation)]) async def delete_project_for_subject( - subject_id: int, project_id: int, - user: User = Depends(get_authenticated_user), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_async_db) ): - if not await is_teacher_of_subject(db, user.id, subject_id): - raise UnauthorizedToUpdateProjectException() - await delete_project(db, project_id) return {"message": "Project deleted successfully"} -@router.patch("/{project_id}", response_model=ProjectResponse) +@router.patch("/{project_id}", + response_model=ProjectResponse, + dependencies=[Depends(user_permission_validation)]) async def patch_project_for_subject( - subject_id: int, project_id: int, project_update: ProjectUpdate, - user: User = Depends(get_authenticated_user), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_async_db) ): - # Check if the user is authorized to update the project - if not await is_teacher_of_subject(db, user.id, subject_id): - raise UnauthorizedToUpdateProjectException() - - updated_project = await update_project(db, project_id, project_update) - if not updated_project: - raise ProjectNotFoundException() - return updated_project + return await update_project(db, project_id, project_update) diff --git a/backend/src/project/schemas.py b/backend/src/project/schemas.py index 0d8cfce2..d7a09e2a 100644 --- a/backend/src/project/schemas.py +++ b/backend/src/project/schemas.py @@ -1,42 +1,38 @@ -from datetime import datetime, date +from datetime import datetime, date, timezone from typing import Optional -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, validator, ConfigDict, field_validator class ProjectCreate(BaseModel): name: str = Field(..., min_length=1) deadline: datetime - subject_id: int description: str # Check if deadline is not in the past - @validator('deadline', pre=True, always=True) - def validate_deadline(cls, value): - if value < date.today(): + @field_validator('deadline') + def validate_deadline(cls, value: datetime) -> datetime: + if value < datetime.now(value.tzinfo): raise ValueError('The deadline cannot be in the past') return value class ProjectResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int name: str deadline: datetime - subject_id: int description: str - class Config: - orm_mode = True - class ProjectUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1) deadline: Optional[datetime] = None - subject_id: Optional[int] = None description: Optional[str] = None - @validator('deadline', pre=True, always=True) - def validate_deadline(cls, value): - if value is not None and value < datetime.now(): + @field_validator('deadline') + def validate_deadline(cls, value: datetime) -> datetime: + if value is not None and value < datetime.now(value.tzinfo): raise ValueError('The deadline cannot be in the past') return value diff --git a/backend/src/project/service.py b/backend/src/project/service.py index b9b7475c..a2329002 100644 --- a/backend/src/project/service.py +++ b/backend/src/project/service.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Sequence from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from . import models @@ -7,11 +7,11 @@ from .schemas import ProjectCreate, ProjectUpdate -async def create_project(db: AsyncSession, project_in: ProjectCreate, user_id: str) -> Project: +async def create_project(db: AsyncSession, project_in: ProjectCreate, subject_id: int) -> Project: new_project = Project( name=project_in.name, deadline=project_in.deadline, - subject_id=project_in.subject_id, + subject_id=subject_id, description=project_in.description ) db.add(new_project) @@ -25,10 +25,10 @@ async def get_project(db: AsyncSession, project_id: int) -> models.Project: return result.scalars().first() -async def get_projects_for_subject(db: AsyncSession, subject_id: int) -> List[models.Project]: - result = await db.execute(select(models.Project).filter(models.Project.subject_id == subject_id)) +async def get_projects_for_subject(db: AsyncSession, subject_id: int) -> Sequence[models.Project]: + result = await db.execute(select(models.Project).filter_by(subject_id=subject_id)) projects = result.scalars().all() - return list(projects) # Explicitly convert to list + return projects async def delete_project(db: AsyncSession, project_id: int): @@ -43,16 +43,12 @@ async def update_project(db: AsyncSession, project_id: int, project_update: Proj result = await db.execute(select(Project).filter(Project.id == project_id)) project = result.scalars().first() if not project: - # Handle the case where the project doesn't exist raise ProjectNotFoundException() - # Update fields only if they're provided in the update payload if project_update.name is not None: project.name = project_update.name if project_update.deadline is not None: project.deadline = project_update.deadline - if project_update.subject_id is not None: - project.subject_id = project_update.subject_id if project_update.description is not None: project.description = project_update.description diff --git a/backend/src/subject/service.py b/backend/src/subject/service.py index c17efc8b..b42ad276 100644 --- a/backend/src/subject/service.py +++ b/backend/src/subject/service.py @@ -51,11 +51,3 @@ async def delete_subject(db: Session, subject_id: int): """Remove a subject""" db.query(models.Subject).filter_by(id=subject_id).delete() db.commit() - - -async def is_teacher_of_subject(db: AsyncSession, user_id: str, subject_id: int) -> bool: - """Check if a user is a teacher of the subject.""" - query = select(models.TeacherSubject).filter_by( - uid=user_id, subject_id=subject_id) - result = await db.execute(query) - return result.scalars().first() is not None