diff --git a/backend/app/migrations/versions/43d4892b1544_add_feedback_column_application.py b/backend/app/migrations/versions/43d4892b1544_add_feedback_column_application.py new file mode 100644 index 0000000..a22c0b9 --- /dev/null +++ b/backend/app/migrations/versions/43d4892b1544_add_feedback_column_application.py @@ -0,0 +1,33 @@ +"""add feedback column application + +Revision ID: 43d4892b1544 +Revises: 1722e1790137 +Create Date: 2025-07-14 18:38:01.464920 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "43d4892b1544" +down_revision: Union[str, Sequence[str], None] = "1722e1790137" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("applications", sa.Column("feedback", sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("applications", "feedback") + # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py b/backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py index 4a56db9..9a9851e 100644 --- a/backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py +++ b/backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py @@ -43,36 +43,24 @@ def upgrade() -> None: type_=sa.DateTime(), existing_nullable=True, ) - op.alter_column( - "projects", "is_public", existing_type=sa.BOOLEAN(), nullable=True - ) - op.alter_column( - "projects", "ceo_id", existing_type=sa.INTEGER(), nullable=True - ) + op.alter_column("projects", "is_public", existing_type=sa.BOOLEAN(), nullable=True) + op.alter_column("projects", "ceo_id", existing_type=sa.INTEGER(), nullable=True) op.alter_column( "projects", "is_opensource", existing_type=sa.BOOLEAN(), nullable=True ) - op.alter_column( - "projects", "is_dead", existing_type=sa.BOOLEAN(), nullable=True - ) + op.alter_column("projects", "is_dead", existing_type=sa.BOOLEAN(), nullable=True) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "projects", "is_dead", existing_type=sa.BOOLEAN(), nullable=False - ) + op.alter_column("projects", "is_dead", existing_type=sa.BOOLEAN(), nullable=False) op.alter_column( "projects", "is_opensource", existing_type=sa.BOOLEAN(), nullable=False ) - op.alter_column( - "projects", "ceo_id", existing_type=sa.INTEGER(), nullable=False - ) - op.alter_column( - "projects", "is_public", existing_type=sa.BOOLEAN(), nullable=False - ) + op.alter_column("projects", "ceo_id", existing_type=sa.INTEGER(), nullable=False) + op.alter_column("projects", "is_public", existing_type=sa.BOOLEAN(), nullable=False) op.alter_column( "projects", "created_at", diff --git a/backend/app/models/applications.py b/backend/app/models/applications.py index c8bed2a..d15f491 100644 --- a/backend/app/models/applications.py +++ b/backend/app/models/applications.py @@ -6,6 +6,7 @@ func, Boolean, ForeignKey, + Text, PrimaryKeyConstraint, ) from dependencies.database import base @@ -18,6 +19,7 @@ class Application(base): user_id = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime, default=func.current_timestamp()) is_approved = Column(Boolean, nullable=True) + feedback = Column(Text, nullable=True) __table_args__ = ( Index("ix_applications_project_user", "project_id", "user_id", unique=True), diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index d01fd13..8eb3722 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -2,7 +2,7 @@ from pydantic_core._pydantic_core import ValidationError from dependencies.database import DBSessionDep from models import Project, Application, ProjectMember -from sqlalchemy import select, update +from sqlalchemy import select, update, delete from routes.api.project.exceptions import ApplicationNotFoundError from routes.api.project.schemas import ( @@ -15,7 +15,6 @@ from fastapi import Depends - class ProjectsDataAccess: def __init__(self, db_session: DBSessionDep): self.db_session = db_session @@ -46,11 +45,7 @@ async def get_project_by_title(self, title: str) -> ProjectSchema | None: async def get_all_projects(self) -> list[ProjectSchema]: res = await self.db_session.execute(select(Project)) projects = res.scalars().all() - return ( - [ProjectSchema.model_validate(project) for project in projects] - if projects - else [] - ) + return [ProjectSchema.model_validate(project) for project in projects] async def create_project( self, @@ -73,11 +68,7 @@ async def get_project_applications( query = query.where(Application.is_approved.is_(None)) res = await self.db_session.execute(query) applications = res.scalars().all() - return ( - [ApplicationSchema.model_validate(app) for app in applications] - if applications - else [] - ) + return [ApplicationSchema.model_validate(app) for app in applications] async def get_application_by_user_and_project_id( self, project_id: int, user_id: int @@ -110,7 +101,9 @@ async def approve_application( Application.project_id == project_id, Application.user_id == approve_schema.user_id, ) - .values(is_approved=approve_schema.is_approved) + .values( + is_approved=approve_schema.is_approved, feedback=approve_schema.feedback + ) .returning(Application) ) res = await self.db_session.execute(query) @@ -131,5 +124,25 @@ async def add_user_to_project( await self.db_session.flush() return ProjectMemberSchema.model_validate(project_member) + async def delete_project(self, project_id: int) -> bool: + query = delete(Project).where(Project.id == project_id) + res = await self.db_session.execute(query) + return res.rowcount > 0 + + async def get_user_applications(self, user_id: int) -> list[ApplicationSchema]: + query = select(Application).where(Application.user_id == user_id) + res = await self.db_session.execute(query) + applications = res.scalars().all() + return [ + ApplicationSchema.model_validate(application) + for application in applications + ] + + async def delete_application(self, project_id: int, user_id: int) -> bool: + query = delete(Application).where( + Application.user_id == user_id, Application.project_id == project_id + ) + res = await self.db_session.execute(query) + return res.rowcount > 0 ProjectsDataAccessDep = Annotated[ProjectsDataAccess, Depends(ProjectsDataAccess)] diff --git a/backend/app/routes/api/project/exceptions.py b/backend/app/routes/api/project/exceptions.py index 5c0ccbf..30dbb97 100644 --- a/backend/app/routes/api/project/exceptions.py +++ b/backend/app/routes/api/project/exceptions.py @@ -1,4 +1,4 @@ class ApplicationNotFoundError(Exception): def __init__(self, message="No application found."): self.message = message - super().__init__(self.message) \ No newline at end of file + super().__init__(self.message) diff --git a/backend/app/routes/api/project/router.py b/backend/app/routes/api/project/router.py index 550f538..46c3c73 100644 --- a/backend/app/routes/api/project/router.py +++ b/backend/app/routes/api/project/router.py @@ -7,6 +7,7 @@ ApplicationSchema, ApproveApplicationSchema, ProjectMemberSchema, + ActionResponse, ) from routes.api.project.service import ProjectServiceDep @@ -30,6 +31,13 @@ async def create_project( return await service.create_project(project_data, user) +@router.get("/all-applications") +async def get_user_applications( + user: AuthUserDep, service: ProjectServiceDep +) -> list[ApplicationSchema]: + return await service.get_user_applications(user) + + @router.get("/{project_id}") async def get_project_by_id( project_id: int, @@ -74,3 +82,19 @@ async def approve_application( user: AuthUserDep, ) -> ProjectMemberSchema: return await service.approve_application(project_id, user, approve_schema) + + +@router.delete("/{project_id}/applications/cancel") +async def delete_application( + project_id: int, service: ProjectServiceDep, user: AuthUserDep +) -> ActionResponse: + return await service.delete_application(project_id, user) + + +@router.delete("/{project_id}") +async def delete_project( + project_id: int, + service: ProjectServiceDep, + user: AuthUserDep, +) -> ActionResponse: + return await service.delete_project(project_id, user) diff --git a/backend/app/routes/api/project/schemas.py b/backend/app/routes/api/project/schemas.py index 4bb3820..943a642 100644 --- a/backend/app/routes/api/project/schemas.py +++ b/backend/app/routes/api/project/schemas.py @@ -20,11 +20,16 @@ class Config: from_attributes = True +class ActionResponse(BaseModel): + message : str + success: bool + class ApplicationSchema(BaseModel): project_id: int user_id: int is_approved: Optional[bool] created_at: datetime + feedback: Optional[str] class Config: from_attributes = True @@ -33,6 +38,7 @@ class Config: class ApproveApplicationSchema(BaseModel): is_approved: bool user_id: int + feedback: Optional[str] class ProjectMemberSchema(BaseModel): diff --git a/backend/app/routes/api/project/service.py b/backend/app/routes/api/project/service.py index 70dc1df..bf999bb 100644 --- a/backend/app/routes/api/project/service.py +++ b/backend/app/routes/api/project/service.py @@ -1,6 +1,9 @@ +from email.message import Message from typing import Annotated from fastapi import Depends from fastapi.exceptions import HTTPException +from pyexpat.errors import messages + from models.users import UserRole from routes.api.project.data_access import ProjectsDataAccessDep from routes.api.project.exceptions import ApplicationNotFoundError @@ -9,6 +12,7 @@ NewProjectSchema, ApplicationSchema, ApproveApplicationSchema, + ActionResponse, ) from schemas.user import UserInDB @@ -133,5 +137,36 @@ async def approve_application( application.user_id, ) + async def delete_project(self, project_id: int, user: UserInDB) -> ActionResponse: + project = await self.data_access.get_project_by_id(project_id) + if project is None: + raise HTTPException( + status_code=404, detail=f"Project with ID {project_id} not found." + ) + if project.ceo_id != user.id: + raise HTTPException( + status_code=403, detail=f"Only the project CEO can delete." + ) + res = await self.data_access.delete_project(project_id) + msg = "Project was successfully deleted" if res else "Project not found" + return ActionResponse(message=msg, success=res) + + async def get_user_applications(self, user: UserInDB) -> list[ApplicationSchema]: + return await self.data_access.get_user_applications(user.id) + + async def delete_application(self, project_id, user: UserInDB) -> ActionResponse: + application = await self.data_access.get_application_by_user_and_project_id( + project_id, user.id + ) + if application is None: + raise HTTPException( + status_code=404, + detail=f"Application not found in project {project_id}.", + ) + if application.is_approved: + raise HTTPException(status_code=400, detail="Application was approved") + res = await self.data_access.detele_application(project_id, user.id) + msg = "Application was successfully deleted" if res else "Failed to delete application" + return ActionResponse(message=msg, success=res) ProjectServiceDep = Annotated[ProjectService, Depends(ProjectService)]