From d40622f566866167900fb41b057cdb4f3a0d9eb1 Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:25:41 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Add=20applications=20and=20proj?= =?UTF-8?q?ect=20members=20functionality=20with=20schema=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data_access/auth.py | 2 +- backend/app/dependencies/auth.py | 6 +- backend/app/dependencies/database.py | 1 + .../4a5a08b74a58_add_user_role_field.py | 4 +- ...9_add_applications_project_member_edit_.py | 86 ++++++++++++++ .../9486dd2901de_remove_level_user_field.py | 2 +- .../app/migrations/versions/d48f4d45faf0_.py | 2 +- backend/app/models/__init__.py | 4 + backend/app/models/applications.py | 25 +++++ backend/app/models/project_member.py | 23 ++++ backend/app/models/projects.py | 10 +- backend/app/models/users.py | 2 +- backend/app/routes/api/project/data_access.py | 105 ++++++++++++++++-- backend/app/routes/api/project/router.py | 55 ++++++++- backend/app/routes/api/project/schemas.py | 32 ++++++ backend/app/routes/api/project/service.py | 104 ++++++++++++++++- backend/app/routes/api/user/data_access.py | 1 - backend/app/routes/api/user/router.py | 14 ++- backend/app/routes/api/user/service.py | 3 + backend/app/schemas/user.py | 3 + backend/app/services/auth.py | 2 +- 21 files changed, 452 insertions(+), 34 deletions(-) create mode 100644 backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py create mode 100644 backend/app/models/applications.py create mode 100644 backend/app/models/project_member.py diff --git a/backend/app/data_access/auth.py b/backend/app/data_access/auth.py index 7662f1f..1d6a156 100644 --- a/backend/app/data_access/auth.py +++ b/backend/app/data_access/auth.py @@ -4,7 +4,7 @@ from sqlalchemy import select, insert from fastapi import Depends from schemas.user import UserInDB -from models.users import User +from models import User class UserDataAccess: diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index 9585864..5bb0358 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -2,7 +2,7 @@ from fastapi.security import OAuth2PasswordBearer from typing import Annotated from services.auth import AuthServiceDep -from schemas.user import User +from schemas.user import UserInDB oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") @@ -10,9 +10,9 @@ async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], auth_service: AuthServiceDep, -) -> User: +) -> UserInDB: token_data = auth_service.decode_token(token) return await auth_service.get_user_by_username(token_data.username) -AuthUserDep = Annotated[User, Depends(get_current_user)] +AuthUserDep = Annotated[UserInDB, Depends(get_current_user)] diff --git a/backend/app/dependencies/database.py b/backend/app/dependencies/database.py index 1cfa742..415fffe 100644 --- a/backend/app/dependencies/database.py +++ b/backend/app/dependencies/database.py @@ -26,6 +26,7 @@ async def get_db() -> AsyncSession: except Exception as e: await session.rollback() raise e + await session.commit() DBSessionDep = Annotated[AsyncSession, Depends(get_db)] diff --git a/backend/app/migrations/versions/4a5a08b74a58_add_user_role_field.py b/backend/app/migrations/versions/4a5a08b74a58_add_user_role_field.py index c89013c..5ea41c7 100644 --- a/backend/app/migrations/versions/4a5a08b74a58_add_user_role_field.py +++ b/backend/app/migrations/versions/4a5a08b74a58_add_user_role_field.py @@ -30,13 +30,13 @@ def upgrade() -> None: name="user_role_enum", ) user_role.create(op.get_bind(), checkfirst=True) - op.add_column("users", sa.Column("level", sa.Integer(), nullable=False)) + op.add_column("users", sa.Column("level", sa.Integer(), nullable=True)) op.add_column( "users", sa.Column( "role", user_role, - nullable=False, + nullable=True, ), ) # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py b/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py new file mode 100644 index 0000000..4f0e10b --- /dev/null +++ b/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py @@ -0,0 +1,86 @@ +"""add applications, project_member, edit projects + +Revision ID: 843428e10d39 +Revises: 9486dd2901de +Create Date: 2025-07-09 13:50:32.127176 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "843428e10d39" +down_revision: Union[str, Sequence[str], None] = "9486dd2901de" +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.create_table( + "applications", + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=True), + sa.Column("is_approved", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("project_id", "user_id"), + ) + op.create_index( + "ix_applications_project_user", + "applications", + ["project_id", "user_id"], + unique=True, + ) + op.create_table( + "project_members", + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=True), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("project_id", "user_id"), + ) + op.create_index( + "ix_project_members_project_id_user_id", + "project_members", + ["project_id", "user_id"], + unique=False, + ) + op.add_column("projects", sa.Column("ceo_id", sa.Integer(), nullable=False)) + op.add_column("projects", sa.Column("is_opensource", sa.Boolean(), nullable=False)) + op.add_column("projects", sa.Column("is_dead", sa.Boolean(), nullable=False)) + op.create_foreign_key(None, "projects", "users", ["ceo_id"], ["id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "projects", type_="foreignkey") + op.drop_column("projects", "is_dead") + op.drop_column("projects", "is_opensource") + op.drop_column("projects", "ceo_id") + op.drop_index("ix_project_members_project_id_user_id", table_name="project_members") + op.drop_table("project_members") + op.drop_index("ix_applications_project_user", table_name="applications") + op.drop_table("applications") + # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/9486dd2901de_remove_level_user_field.py b/backend/app/migrations/versions/9486dd2901de_remove_level_user_field.py index 4bab8f1..ad3f519 100644 --- a/backend/app/migrations/versions/9486dd2901de_remove_level_user_field.py +++ b/backend/app/migrations/versions/9486dd2901de_remove_level_user_field.py @@ -31,6 +31,6 @@ def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column( "users", - sa.Column("level", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("level", sa.INTEGER(), autoincrement=False, nullable=True), ) # ### end Alembic commands ### diff --git a/backend/app/migrations/versions/d48f4d45faf0_.py b/backend/app/migrations/versions/d48f4d45faf0_.py index a4cdc70..0cfe449 100644 --- a/backend/app/migrations/versions/d48f4d45faf0_.py +++ b/backend/app/migrations/versions/d48f4d45faf0_.py @@ -24,7 +24,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( "projects", - sa.Column("id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), sa.Column("title", sa.String(), nullable=False), sa.Column("description", sa.String(), nullable=True), sa.Column("created_at", sa.Date(), nullable=True), diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 03ca7b3..6b06227 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,11 @@ from .projects import Project from .users import User +from .project_member import ProjectMember +from .applications import Application __all__ = [ "Project", "User", + "ProjectMember", + "Application", ] diff --git a/backend/app/models/applications.py b/backend/app/models/applications.py new file mode 100644 index 0000000..6252069 --- /dev/null +++ b/backend/app/models/applications.py @@ -0,0 +1,25 @@ +from sqlalchemy import ( + Column, + Integer, + Index, + Date, + func, + Boolean, + ForeignKey, + PrimaryKeyConstraint, +) +from dependencies.database import base + + +class Application(base): + __tablename__ = "applications" + + project_id = Column(Integer, ForeignKey("projects.id")) + user_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(Date, default=func.current_date()) + is_approved = Column(Boolean, nullable=True) + + __table_args__ = ( + Index("ix_applications_project_user", "project_id", "user_id", unique=True), + PrimaryKeyConstraint("project_id", "user_id"), + ) diff --git a/backend/app/models/project_member.py b/backend/app/models/project_member.py new file mode 100644 index 0000000..f0c556f --- /dev/null +++ b/backend/app/models/project_member.py @@ -0,0 +1,23 @@ +from sqlalchemy import ( + Column, + Integer, + Index, + Date, + func, + ForeignKey, + PrimaryKeyConstraint, +) +from dependencies.database import base + + +class ProjectMember(base): + __tablename__ = "project_members" + + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(Date, default=func.current_date()) + + __table_args__ = ( + PrimaryKeyConstraint("project_id", "user_id"), + Index("ix_project_members_project_id_user_id", "project_id", "user_id"), + ) diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py index 92e85b3..e6a9fee 100644 --- a/backend/app/models/projects.py +++ b/backend/app/models/projects.py @@ -1,13 +1,19 @@ -from sqlalchemy import Column, Integer, String, Date, func, Boolean +from sqlalchemy import Column, Integer, String, Date, func, Boolean, ForeignKey from dependencies.database import base class Project(base): __tablename__ = "projects" - id = Column(Integer, primary_key=True, index=True) + id = Column(Integer, primary_key=True, index=True, autoincrement=True) title = Column(String, unique=True, nullable=False) description = Column(String) created_at = Column(Date, default=func.current_date()) is_public = Column(Boolean, nullable=False) status = Column(String) + ceo_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_opensource = Column(Boolean, nullable=False, default=True) + is_dead = Column(Boolean, nullable=False, default=False) + + def __repr__(self): + return f"" diff --git a/backend/app/models/users.py b/backend/app/models/users.py index 8e5cbef..ae41a37 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -19,6 +19,6 @@ class User(base): hashed_password = Column(String, nullable=False) role = Column( PgEnum(UserRole, name="user_role_enum", create_type=True), - nullable=False, + nullable=True, default=UserRole.VIEWER, ) diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index bc651cc..398fdee 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -1,20 +1,33 @@ from typing import Annotated from pydantic_core._pydantic_core import ValidationError from dependencies.database import DBSessionDep -from models.projects import Project -from sqlalchemy import select -from routes.api.project.schemas import ProjectSchema +from models import Project, Application, ProjectMember +from sqlalchemy import select, update +from routes.api.project.schemas import ( + ProjectSchema, + ApplicationSchema, + NewProjectSchema, + ApproveApplicationSchema, + ProjectMemberSchema, +) from fastapi import Depends +class ProjectNotFoundError(Exception): + pass + + class ProjectsDataClass: def __init__(self, db_session: DBSessionDep): self.db_session = db_session - async def get_project_by_id(self, project_id: int) -> ProjectSchema | None: - res = await self.db_session.execute( - select(Project).where(Project.id == project_id) - ) + async def get_project_by_id( + self, project_id: int, ceo_id: int = None + ) -> ProjectSchema | None: + query = select(Project).where(Project.id == project_id) + if ceo_id is not None: + query = query.where(Project.ceo_id == ceo_id) + res = await self.db_session.execute(query) project = res.scalars().first() try: return ProjectSchema.model_validate(project) @@ -40,11 +53,85 @@ async def get_all_projects(self) -> list[ProjectSchema]: else [] ) - async def create_project(self, project_data: ProjectSchema) -> ProjectSchema: + async def create_project( + self, + project_data: NewProjectSchema, + ceo_id: int, + ) -> ProjectSchema: project = Project(**project_data.model_dump()) + project.ceo_id = ceo_id + await self.db_session.flush() self.db_session.add(project) - await self.db_session.commit() + await self.db_session.flush() return ProjectSchema.model_validate(project) + async def get_project_applications( + self, + project_id: int, + only_new: bool = False, + ) -> list[ApplicationSchema]: + query = select(Application).where(Application.project_id == project_id) + if only_new: + 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 [] + ) + + async def get_application_by_user_and_project_id( + self, project_id: int, user_id: int + ) -> ApplicationSchema | None: + query = select(Application).where( + Application.project_id == project_id, Application.user_id == user_id + ) + res = await self.db_session.execute(query) + application = res.scalars().first() + if application: + return ApplicationSchema.model_validate(application) + return None + + async def apply_to_project( + self, + project_id: int, + user_id: int, + ) -> ApplicationSchema: + application = Application(project_id=project_id, user_id=user_id) + self.db_session.add(application) + await self.db_session.flush() + return ApplicationSchema.model_validate(application) + + async def approve_application( + self, project_id: int, approve_schema: ApproveApplicationSchema + ): + query = ( + update(Application) + .where( + Application.project_id == project_id, + Application.user_id == approve_schema.user_id, + ) + .values(is_approved=approve_schema.is_approved) + .returning(Application) + ) + res = await self.db_session.execute(query) + application = res.scalars().first() + if not application: + raise ProjectNotFoundError( + f"Application for user {approve_schema.user_id} not found in project {project_id}." + ) + return ApplicationSchema.model_validate(application) + + async def add_user_to_project( + self, + project_id: int, + user_id: int, + ) -> ProjectMemberSchema: + project_member = ProjectMember(project_id=project_id, user_id=user_id) + self.db_session.add(project_member) + await self.db_session.flush() + return ProjectMemberSchema.model_validate(project_member) + ProjectsDataAccessDep = Annotated[ProjectsDataClass, Depends(ProjectsDataClass)] diff --git a/backend/app/routes/api/project/router.py b/backend/app/routes/api/project/router.py index 24894d3..550f538 100644 --- a/backend/app/routes/api/project/router.py +++ b/backend/app/routes/api/project/router.py @@ -1,7 +1,13 @@ from fastapi import APIRouter from dependencies.auth import AuthUserDep -from routes.api.project.schemas import ProjectSchema, NewProjectSchema +from routes.api.project.schemas import ( + ProjectSchema, + NewProjectSchema, + ApplicationSchema, + ApproveApplicationSchema, + ProjectMemberSchema, +) from routes.api.project.service import ProjectServiceDep router = APIRouter(prefix="/projects", tags=["projects"]) @@ -15,6 +21,15 @@ async def get_projects( return await service.get_projects() +@router.post("/") +async def create_project( + project_data: NewProjectSchema, + service: ProjectServiceDep, + user: AuthUserDep, +) -> ProjectSchema: + return await service.create_project(project_data, user) + + @router.get("/{project_id}") async def get_project_by_id( project_id: int, @@ -24,10 +39,38 @@ async def get_project_by_id( return await service.get_project_by_id(project_id) -@router.post("/") -async def create_project( - project_data: NewProjectSchema, +@router.post("/{project_id}/apply") +async def apply_to_project( + project_id: int, service: ProjectServiceDep, user: AuthUserDep, -) -> ProjectSchema: - return await service.create_project(project_data) +) -> ApplicationSchema: + return await service.apply_to_project(project_id, user.id) + + +@router.get("/{project_id}/applications") +async def get_project_applications( + project_id: int, + service: ProjectServiceDep, + user: AuthUserDep, +) -> list[ApplicationSchema]: + return await service.get_project_applications(project_id, user.id) + + +@router.get("/{project_id}/new-applications") +async def get_project_new_applications( + project_id: int, + service: ProjectServiceDep, + user: AuthUserDep, +) -> list[ApplicationSchema]: + return await service.get_project_applications(project_id, user.id, True) + + +@router.patch("/{project_id}/applications/approve") +async def approve_application( + project_id: int, + approve_schema: ApproveApplicationSchema, + service: ProjectServiceDep, + user: AuthUserDep, +) -> ProjectMemberSchema: + return await service.approve_application(project_id, user, approve_schema) diff --git a/backend/app/routes/api/project/schemas.py b/backend/app/routes/api/project/schemas.py index 029ca9f..7164394 100644 --- a/backend/app/routes/api/project/schemas.py +++ b/backend/app/routes/api/project/schemas.py @@ -1,4 +1,5 @@ from pydantic import BaseModel +from datetime import datetime from typing import Optional @@ -6,10 +7,41 @@ class NewProjectSchema(BaseModel): title: str description: Optional[str] is_public: bool + is_opensource: bool + is_dead: bool class ProjectSchema(NewProjectSchema): id: int + created_at: datetime + ceo_id: int + + class Config: + from_attributes = True + + +class ApplicationSchema(BaseModel): + project_id: int + user_id: int + is_approved: Optional[bool] + created_at: datetime + + class Config: + from_attributes = True + + +class ApplyProjectSchema(BaseModel): + project_id: int + + +class ApproveApplicationSchema(BaseModel): + is_approved: bool + user_id: int + + +class ProjectMemberSchema(BaseModel): + project_id: int + user_id: int class Config: from_attributes = True diff --git a/backend/app/routes/api/project/service.py b/backend/app/routes/api/project/service.py index 329242a..de72a8a 100644 --- a/backend/app/routes/api/project/service.py +++ b/backend/app/routes/api/project/service.py @@ -1,8 +1,15 @@ from typing import Annotated from fastapi import Depends from fastapi.exceptions import HTTPException +from models.users import UserRole from routes.api.project.data_access import ProjectsDataAccessDep -from routes.api.project.schemas import ProjectSchema, NewProjectSchema +from routes.api.project.schemas import ( + ProjectSchema, + NewProjectSchema, + ApplicationSchema, + ApproveApplicationSchema, +) +from schemas.user import UserInDB class ProjectService: @@ -20,14 +27,105 @@ async def get_project_by_id(self, project_id: int) -> ProjectSchema: async def get_projects(self) -> list[ProjectSchema]: return await self.data_access.get_all_projects() - async def create_project(self, project_data: NewProjectSchema) -> ProjectSchema: + async def create_project( + self, + project_data: NewProjectSchema, + user: UserInDB, + ) -> ProjectSchema: + if user.role != UserRole.FOUNDER: + raise HTTPException( + status_code=403, + detail="Only founders can create projects.", + ) project = await self.data_access.get_project_by_title(project_data.title) if project: raise HTTPException( status_code=400, detail=f"Project with title '{project_data.title}' already exists.", ) - return await self.data_access.create_project(project_data) + return await self.data_access.create_project(project_data, user.id) + + async def get_project_applications( + self, + project_id: int, + user_id: int = None, + only_new: bool = False, + ) -> list[ApplicationSchema]: + project = await self.data_access.get_project_by_id(project_id, user_id) + if project is None: + raise HTTPException( + status_code=404, + detail=f"Project with ID {project_id} not found or you do not have access.", + ) + return await self.data_access.get_project_applications(project_id, only_new) + + async def apply_to_project( + self, + project_id: int, + user_id: int, + ) -> ApplicationSchema: + 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 or you do not have access.", + ) + if project.ceo_id == user_id: + raise HTTPException( + status_code=403, + detail="You cannot apply to your own project.", + ) + application = await self.data_access.get_application_by_user_and_project_id( + project_id, user_id + ) + if application: + raise HTTPException( + status_code=400, + detail=f"You have already applied to project {project_id}.", + ) + return await self.data_access.apply_to_project(project_id, user_id) + + async def approve_application( + self, + project_id: int, + user: UserInDB, + approve_schema: ApproveApplicationSchema, + ) -> ApplicationSchema: + 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="Only the project CEO can approve applications.", + ) + application = await self.data_access.get_application_by_user_and_project_id( + project_id, + approve_schema.user_id, + ) + if application is None: + raise HTTPException( + status_code=404, + detail=f"Application not found in project {project_id}.", + ) + + if application.user_id == user.id: + raise HTTPException( + status_code=403, + detail="You cannot approve your own application.", + ) + await self.data_access.approve_application( + project_id, + approve_schema, + ) + + return await self.data_access.add_user_to_project( + project_id, + application.user_id, + ) ProjectServiceDep = Annotated[ProjectService, Depends(ProjectService)] diff --git a/backend/app/routes/api/user/data_access.py b/backend/app/routes/api/user/data_access.py index 195a5c7..80d8b7c 100644 --- a/backend/app/routes/api/user/data_access.py +++ b/backend/app/routes/api/user/data_access.py @@ -20,7 +20,6 @@ async def set_user_role(self, username: str, role: UserRole) -> None: await self.db_session.execute( update(User).where(User.username == username).values(role=role) ) - await self.db_session.commit() UserDataAccessDep = Annotated[UserDataAccess, Depends(UserDataAccess)] diff --git a/backend/app/routes/api/user/router.py b/backend/app/routes/api/user/router.py index 026e78f..1fb84fb 100644 --- a/backend/app/routes/api/user/router.py +++ b/backend/app/routes/api/user/router.py @@ -2,7 +2,7 @@ from dependencies.auth import get_current_user from routes.api.user.service import UserServiceDep from routes.api.user.schemas import SetUserRoleSchema - +from schemas.user import User router = APIRouter(prefix="/user", tags=["user"]) @@ -12,5 +12,13 @@ async def set_user_role( user_role: SetUserRoleSchema, service: UserServiceDep, current_user=Depends(get_current_user), -): - await service.set_user_role(user_role, current_user) +) -> dict[str, str]: + return await service.set_user_role(user_role, current_user) + + +@router.post("/get-me") +async def get_me( + service: UserServiceDep, + current_user=Depends(get_current_user), +) -> User: + return current_user diff --git a/backend/app/routes/api/user/service.py b/backend/app/routes/api/user/service.py index 200acc7..e4a6ad8 100644 --- a/backend/app/routes/api/user/service.py +++ b/backend/app/routes/api/user/service.py @@ -24,6 +24,9 @@ async def set_user_role(self, user_role: SetUserRoleSchema, current_user: User): detail="You cannot set the same role as your own.", ) await self.user_repository.set_user_role(user.username, user_role.role) + return { + "message": f"User '{user.username}' role set to {user_role.role.value}." + } UserServiceDep = Annotated[UserService, Depends(UserService)] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index e455cac..991a94c 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,3 +1,5 @@ +from typing import Optional + from models.users import UserRole from pydantic import BaseModel @@ -14,6 +16,7 @@ class UserRegister(BaseModel): class UserInDB(User): hashed_password: str + id: Optional[int] = None class Config: from_attributes = True diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 7e9ee90..a35dcd4 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -89,7 +89,7 @@ async def register(self, user_data: UserRegister) -> Token: access_token = self._create_token(user_data.username) return Token(access_token=access_token, token_type="bearer") - async def get_user_by_username(self, username: str) -> User: + async def get_user_by_username(self, username: str) -> UserInDB: user = await self.user_data_access.get_user_by_username(username) if not user: raise HTTPException(status_code=401, detail="User not found") From ace60df3836b983c5717c22aa1066fce66c95205 Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:36:00 +0300 Subject: [PATCH 2/7] :bug: fix tests --- .../api/project/__tests__/test_router.py | 18 +++++++++++++++-- .../api/project/__tests__/test_service.py | 20 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/api/project/__tests__/test_router.py b/backend/app/routes/api/project/__tests__/test_router.py index c4d8d4e..aa5f44e 100644 --- a/backend/app/routes/api/project/__tests__/test_router.py +++ b/backend/app/routes/api/project/__tests__/test_router.py @@ -24,10 +24,24 @@ def mock_user(): def sample_projects(): return [ ProjectSchema( - id=1, title="Project 1", description="Description 1", is_public=True + id=1, + title="Project 1", + description="Description 1", + is_public=True, + is_dead=False, + is_opensource=True, + ceo_id=1, + created_at="2023-10-01T00:00:00Z", ), ProjectSchema( - id=2, title="Project 2", description="Description 2", is_public=False + id=2, + title="Project 2", + description="Description 2", + is_public=True, + is_dead=False, + is_opensource=True, + ceo_id=1, + created_at="2023-10-01T00:00:00Z", ), ] diff --git a/backend/app/routes/api/project/__tests__/test_service.py b/backend/app/routes/api/project/__tests__/test_service.py index 273e057..8f7483d 100644 --- a/backend/app/routes/api/project/__tests__/test_service.py +++ b/backend/app/routes/api/project/__tests__/test_service.py @@ -26,10 +26,26 @@ def user(): def sample_projects(): return [ ProjectSchema( - id=1, title="Project 1", description="Description 1", is_public=True + id=1, + title="Project 1", + description="Description 1", + is_public=True, + is_dead=False, + is_opensource=True, + status="active", + ceo_id=1, + created_at="2023-10-01T00:00:00Z", ), ProjectSchema( - id=2, title="Project 2", description="Description 2", is_public=False + id=2, + title="Project 2", + description="Description 2", + is_public=True, + is_dead=False, + is_opensource=True, + status="active", + ceo_id=1, + created_at="2023-10-01T00:00:00Z", ), ] From 3d4bb757636f0c2a3630ae04aafeab0496562b2f Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:44:59 +0300 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Update=20project=20model=20and?= =?UTF-8?q?=20data=20access=20layer=20to=20allow=20nullable=20fields=20for?= =?UTF-8?q?=20ceo=5Fid,=20is=5Fopensource,=20and=20is=5Fdead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../843428e10d39_add_applications_project_member_edit_.py | 6 +++--- backend/app/models/projects.py | 8 ++++---- backend/app/routes/api/project/data_access.py | 5 ++--- backend/app/routes/api/project/schemas.py | 4 ---- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py b/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py index 4f0e10b..6e09ff1 100644 --- a/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py +++ b/backend/app/migrations/versions/843428e10d39_add_applications_project_member_edit_.py @@ -65,9 +65,9 @@ def upgrade() -> None: ["project_id", "user_id"], unique=False, ) - op.add_column("projects", sa.Column("ceo_id", sa.Integer(), nullable=False)) - op.add_column("projects", sa.Column("is_opensource", sa.Boolean(), nullable=False)) - op.add_column("projects", sa.Column("is_dead", sa.Boolean(), nullable=False)) + op.add_column("projects", sa.Column("ceo_id", sa.Integer(), nullable=True)) + op.add_column("projects", sa.Column("is_opensource", sa.Boolean(), nullable=True)) + op.add_column("projects", sa.Column("is_dead", sa.Boolean(), nullable=True)) op.create_foreign_key(None, "projects", "users", ["ceo_id"], ["id"]) # ### end Alembic commands ### diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py index e6a9fee..3890a34 100644 --- a/backend/app/models/projects.py +++ b/backend/app/models/projects.py @@ -9,11 +9,11 @@ class Project(base): title = Column(String, unique=True, nullable=False) description = Column(String) created_at = Column(Date, default=func.current_date()) - is_public = Column(Boolean, nullable=False) + is_public = Column(Boolean, nullable=True, default=True) status = Column(String) - ceo_id = Column(Integer, ForeignKey("users.id"), nullable=False) - is_opensource = Column(Boolean, nullable=False, default=True) - is_dead = Column(Boolean, nullable=False, default=False) + ceo_id = Column(Integer, ForeignKey("users.id"), nullable=True) + is_opensource = Column(Boolean, nullable=True, default=True) + is_dead = Column(Boolean, nullable=True, default=False) def __repr__(self): return f"" diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index 398fdee..cab6cd4 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -17,7 +17,7 @@ class ProjectNotFoundError(Exception): pass -class ProjectsDataClass: +class ProjectsDataAccess: def __init__(self, db_session: DBSessionDep): self.db_session = db_session @@ -60,7 +60,6 @@ async def create_project( ) -> ProjectSchema: project = Project(**project_data.model_dump()) project.ceo_id = ceo_id - await self.db_session.flush() self.db_session.add(project) await self.db_session.flush() return ProjectSchema.model_validate(project) @@ -134,4 +133,4 @@ async def add_user_to_project( return ProjectMemberSchema.model_validate(project_member) -ProjectsDataAccessDep = Annotated[ProjectsDataClass, Depends(ProjectsDataClass)] +ProjectsDataAccessDep = Annotated[ProjectsDataAccess, Depends(ProjectsDataAccess)] diff --git a/backend/app/routes/api/project/schemas.py b/backend/app/routes/api/project/schemas.py index 7164394..6aa9b58 100644 --- a/backend/app/routes/api/project/schemas.py +++ b/backend/app/routes/api/project/schemas.py @@ -30,10 +30,6 @@ class Config: from_attributes = True -class ApplyProjectSchema(BaseModel): - project_id: int - - class ApproveApplicationSchema(BaseModel): is_approved: bool user_id: int From c1af600e7e23f4bd1377470f35c50f93dd315dd4 Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:52:32 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20Refactor=20project=20exception?= =?UTF-8?q?=20handling=20by=20moving=20ProjectNotFoundError=20to=20a=20sep?= =?UTF-8?q?arate=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes/api/project/data_access.py | 5 ++--- backend/app/routes/api/project/exceptions.py | 4 ++++ backend/app/routes/api/project/service.py | 16 +++++++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 backend/app/routes/api/project/exceptions.py diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index cab6cd4..afa5a9d 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -3,6 +3,8 @@ from dependencies.database import DBSessionDep from models import Project, Application, ProjectMember from sqlalchemy import select, update + +from routes.api.project.exceptions import ProjectNotFoundError from routes.api.project.schemas import ( ProjectSchema, ApplicationSchema, @@ -13,9 +15,6 @@ from fastapi import Depends -class ProjectNotFoundError(Exception): - pass - class ProjectsDataAccess: def __init__(self, db_session: DBSessionDep): diff --git a/backend/app/routes/api/project/exceptions.py b/backend/app/routes/api/project/exceptions.py new file mode 100644 index 0000000..39568e8 --- /dev/null +++ b/backend/app/routes/api/project/exceptions.py @@ -0,0 +1,4 @@ +class ProjectNotFoundError(Exception): + def __init__(self, message="No project found."): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/backend/app/routes/api/project/service.py b/backend/app/routes/api/project/service.py index de72a8a..0c1412c 100644 --- a/backend/app/routes/api/project/service.py +++ b/backend/app/routes/api/project/service.py @@ -3,6 +3,7 @@ from fastapi.exceptions import HTTPException from models.users import UserRole from routes.api.project.data_access import ProjectsDataAccessDep +from routes.api.project.exceptions import ProjectNotFoundError from routes.api.project.schemas import ( ProjectSchema, NewProjectSchema, @@ -117,11 +118,16 @@ async def approve_application( status_code=403, detail="You cannot approve your own application.", ) - await self.data_access.approve_application( - project_id, - approve_schema, - ) - + try: + await self.data_access.approve_application( + project_id, + approve_schema, + ) + except ProjectNotFoundError: + raise HTTPException( + status_code=404, + detail=f"Application for user {approve_schema.user_id} not found in project {project_id}.", + ) return await self.data_access.add_user_to_project( project_id, application.user_id, From 903a14fe14bbc1dafcf981a67a38825564194aba Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:54:17 +0300 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20Rename=20ProjectNotFoundError?= =?UTF-8?q?=20to=20ApplicationNotFoundError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes/api/project/data_access.py | 2 +- backend/app/routes/api/project/exceptions.py | 4 ++-- backend/app/routes/api/project/service.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index afa5a9d..c0d7e61 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -116,7 +116,7 @@ async def approve_application( res = await self.db_session.execute(query) application = res.scalars().first() if not application: - raise ProjectNotFoundError( + raise ApplicationNotFoundError( f"Application for user {approve_schema.user_id} not found in project {project_id}." ) return ApplicationSchema.model_validate(application) diff --git a/backend/app/routes/api/project/exceptions.py b/backend/app/routes/api/project/exceptions.py index 39568e8..5c0ccbf 100644 --- a/backend/app/routes/api/project/exceptions.py +++ b/backend/app/routes/api/project/exceptions.py @@ -1,4 +1,4 @@ -class ProjectNotFoundError(Exception): - def __init__(self, message="No project found."): +class ApplicationNotFoundError(Exception): + def __init__(self, message="No application found."): self.message = message super().__init__(self.message) \ No newline at end of file diff --git a/backend/app/routes/api/project/service.py b/backend/app/routes/api/project/service.py index 0c1412c..70dc1df 100644 --- a/backend/app/routes/api/project/service.py +++ b/backend/app/routes/api/project/service.py @@ -3,7 +3,7 @@ from fastapi.exceptions import HTTPException from models.users import UserRole from routes.api.project.data_access import ProjectsDataAccessDep -from routes.api.project.exceptions import ProjectNotFoundError +from routes.api.project.exceptions import ApplicationNotFoundError from routes.api.project.schemas import ( ProjectSchema, NewProjectSchema, @@ -123,7 +123,7 @@ async def approve_application( project_id, approve_schema, ) - except ProjectNotFoundError: + except ApplicationNotFoundError: raise HTTPException( status_code=404, detail=f"Application for user {approve_schema.user_id} not found in project {project_id}.", From 8672ab2c57ef99192e01d2126c0a57d0d1b493d8 Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:57:28 +0300 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20Change=20date=20fields=20to=20d?= =?UTF-8?q?atetime=20format=20in=20applications,=20project=20members,=20an?= =?UTF-8?q?d=20projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...f57b2c75_change_date_to_datetime_format.py | 97 +++++++++++++++++++ backend/app/models/applications.py | 4 +- backend/app/models/project_member.py | 4 +- backend/app/models/projects.py | 4 +- 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py 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 new file mode 100644 index 0000000..4a56db9 --- /dev/null +++ b/backend/app/migrations/versions/6e2af57b2c75_change_date_to_datetime_format.py @@ -0,0 +1,97 @@ +"""change date to datetime format + +Revision ID: 6e2af57b2c75 +Revises: 843428e10d39 +Create Date: 2025-07-09 16:56:58.620295 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "6e2af57b2c75" +down_revision: Union[str, Sequence[str], None] = "843428e10d39" +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.alter_column( + "applications", + "created_at", + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=True, + ) + op.alter_column( + "project_members", + "created_at", + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=True, + ) + op.alter_column( + "projects", + "created_at", + existing_type=sa.DATE(), + 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_opensource", 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_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", + "created_at", + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=True, + ) + op.alter_column( + "project_members", + "created_at", + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=True, + ) + op.alter_column( + "applications", + "created_at", + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=True, + ) + # ### end Alembic commands ### diff --git a/backend/app/models/applications.py b/backend/app/models/applications.py index 6252069..c8bed2a 100644 --- a/backend/app/models/applications.py +++ b/backend/app/models/applications.py @@ -2,7 +2,7 @@ Column, Integer, Index, - Date, + DateTime, func, Boolean, ForeignKey, @@ -16,7 +16,7 @@ class Application(base): project_id = Column(Integer, ForeignKey("projects.id")) user_id = Column(Integer, ForeignKey("users.id")) - created_at = Column(Date, default=func.current_date()) + created_at = Column(DateTime, default=func.current_timestamp()) is_approved = Column(Boolean, nullable=True) __table_args__ = ( diff --git a/backend/app/models/project_member.py b/backend/app/models/project_member.py index f0c556f..2f75d84 100644 --- a/backend/app/models/project_member.py +++ b/backend/app/models/project_member.py @@ -2,7 +2,7 @@ Column, Integer, Index, - Date, + DateTime, func, ForeignKey, PrimaryKeyConstraint, @@ -15,7 +15,7 @@ class ProjectMember(base): project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(Date, default=func.current_date()) + created_at = Column(DateTime, default=func.current_timestamp()) __table_args__ = ( PrimaryKeyConstraint("project_id", "user_id"), diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py index 3890a34..c8a77aa 100644 --- a/backend/app/models/projects.py +++ b/backend/app/models/projects.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Date, func, Boolean, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, func, Boolean, ForeignKey from dependencies.database import base @@ -8,7 +8,7 @@ class Project(base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) title = Column(String, unique=True, nullable=False) description = Column(String) - created_at = Column(Date, default=func.current_date()) + created_at = Column(DateTime, default=func.current_timestamp()) is_public = Column(Boolean, nullable=True, default=True) status = Column(String) ceo_id = Column(Integer, ForeignKey("users.id"), nullable=True) From db07f6158b47d74af09e2e6cfe83e32099690a0d Mon Sep 17 00:00:00 2001 From: Andukov Almaz Date: Wed, 9 Jul 2025 16:58:37 +0300 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20Update=20import=20to=20use=20Ap?= =?UTF-8?q?plicationNotFoundError=20in=20data=5Faccess.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes/api/project/data_access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index c0d7e61..d01fd13 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -4,7 +4,7 @@ from models import Project, Application, ProjectMember from sqlalchemy import select, update -from routes.api.project.exceptions import ProjectNotFoundError +from routes.api.project.exceptions import ApplicationNotFoundError from routes.api.project.schemas import ( ProjectSchema, ApplicationSchema,