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/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/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..6e09ff1 --- /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=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 ### + + +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..c8bed2a --- /dev/null +++ b/backend/app/models/applications.py @@ -0,0 +1,25 @@ +from sqlalchemy import ( + Column, + Integer, + Index, + DateTime, + 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(DateTime, default=func.current_timestamp()) + 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..2f75d84 --- /dev/null +++ b/backend/app/models/project_member.py @@ -0,0 +1,23 @@ +from sqlalchemy import ( + Column, + Integer, + Index, + DateTime, + 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(DateTime, default=func.current_timestamp()) + + __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..c8a77aa 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, DateTime, 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) + 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) + 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/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/__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", ), ] diff --git a/backend/app/routes/api/project/data_access.py b/backend/app/routes/api/project/data_access.py index bc651cc..d01fd13 100644 --- a/backend/app/routes/api/project/data_access.py +++ b/backend/app/routes/api/project/data_access.py @@ -1,20 +1,32 @@ 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.exceptions import ApplicationNotFoundError +from routes.api.project.schemas import ( + ProjectSchema, + ApplicationSchema, + NewProjectSchema, + ApproveApplicationSchema, + ProjectMemberSchema, +) from fastapi import Depends -class ProjectsDataClass: + +class ProjectsDataAccess: 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 +52,84 @@ 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 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 ApplicationNotFoundError( + 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)] +ProjectsDataAccessDep = Annotated[ProjectsDataAccess, Depends(ProjectsDataAccess)] diff --git a/backend/app/routes/api/project/exceptions.py b/backend/app/routes/api/project/exceptions.py new file mode 100644 index 0000000..5c0ccbf --- /dev/null +++ b/backend/app/routes/api/project/exceptions.py @@ -0,0 +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 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..6aa9b58 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,37 @@ 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 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..70dc1df 100644 --- a/backend/app/routes/api/project/service.py +++ b/backend/app/routes/api/project/service.py @@ -1,8 +1,16 @@ 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.exceptions import ApplicationNotFoundError +from routes.api.project.schemas import ( + ProjectSchema, + NewProjectSchema, + ApplicationSchema, + ApproveApplicationSchema, +) +from schemas.user import UserInDB class ProjectService: @@ -20,14 +28,110 @@ 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.", + ) + try: + await self.data_access.approve_application( + project_id, + approve_schema, + ) + except ApplicationNotFoundError: + 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, + ) 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")