Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/app/models/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class Project(base):
ceo_id = Column(Integer, ForeignKey("users.id"), nullable=True, server_default="1")
is_opensource = Column(Boolean, nullable=True, default=True, server_default="TRUE")
is_dead = Column(Boolean, nullable=True, default=False, server_default="FALSE")
tags = relationship("Tag", secondary="project_tags", back_populates="projects")
tags = relationship(
"Tag", secondary="project_tags", back_populates="projects", lazy="selectin"
)

def __repr__(self):
return f"<Project({', '.join(f'{k}={getattr(self, k)!r}' for k in self.__table__.columns.keys())})>"
10 changes: 7 additions & 3 deletions backend/app/routes/api/project/__tests__/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ def mock_data_access():
data_access = AsyncMock()
return data_access

@pytest.fixture
def mock_ai_agent():
ai_agent = AsyncMock()
return ai_agent

@pytest.fixture
def project_service(mock_data_access):
return ProjectService(data_access=mock_data_access)
def project_service(mock_data_access, mock_ai_agent):
return ProjectService(data_access=mock_data_access, ai_agent=mock_ai_agent)


@pytest.fixture
def user():
return User(username="testuser", role="VIEWER")
return User(username="testuser", role="VIEWER", id=1)


@pytest.fixture
Expand Down
16 changes: 16 additions & 0 deletions backend/app/routes/api/project/data_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async def create_project(
project.ceo_id = ceo_id
self.db_session.add(project)
await self.db_session.flush()
await self.db_session.refresh(project, ["tags"])
return ProjectSchema.model_validate(project)

async def get_project_applications(
Expand Down Expand Up @@ -159,5 +160,20 @@ async def delete_application(self, project_id: int, user_id: int) -> bool:
res = await self.db_session.execute(query)
return res.rowcount > 0

async def update_project(
self, project_id: int, brief_description: str, description: str | None = None
) -> ProjectSchema:
query = (
update(Project)
.where(Project.id == project_id)
.values(brief_description=brief_description, description=description)
.returning(Project)
)
res = await self.db_session.execute(query)
project = res.scalars().first()
if not project:
raise ValueError(f"Project with ID {project_id} not found.")
return ProjectSchema.model_validate(project)


ProjectsDataAccessDep = Annotated[ProjectsDataAccess, Depends(ProjectsDataAccess)]
21 changes: 21 additions & 0 deletions backend/app/routes/api/project/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
ApproveApplicationSchema,
ProjectMemberSchema,
ActionResponse,
UpdateProjectRequest,
EnhanceDescriptionRequest,
EnhanceDescriptionResponse,
)
from routes.api.project.service import ProjectServiceDep

Expand Down Expand Up @@ -97,3 +100,21 @@ async def delete_project(
user: AuthUserDep,
) -> ActionResponse:
return await service.delete_project(project_id, user)


@router.post("/{project_id}/update")
async def update_project(
project_id: int,
data: UpdateProjectRequest,
service: ProjectServiceDep,
user: AuthUserDep,
) -> ProjectSchema:
return await service.update_project(project_id, data, user)

@router.post('/enhance-description')
async def enhance_project_description(
data: EnhanceDescriptionRequest,
service: ProjectServiceDep,
user: AuthUserDep,
) -> EnhanceDescriptionResponse:
return await service.enhance_project_description(data)
12 changes: 12 additions & 0 deletions backend/app/routes/api/project/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,15 @@ class ProjectMemberSchema(BaseModel):

class Config:
from_attributes = True


class UpdateProjectRequest(BaseModel):
brief_description: str
description: Optional[str] = None


class EnhanceDescriptionRequest(BaseModel):
project_description: str

class EnhanceDescriptionResponse(BaseModel):
enhanced_description: str
38 changes: 34 additions & 4 deletions backend/app/routes/api/project/service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
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
Expand All @@ -13,13 +11,18 @@
ApplicationSchema,
ApproveApplicationSchema,
ActionResponse,
UpdateProjectRequest,
EnhanceDescriptionResponse,
EnhanceDescriptionRequest,
)
from schemas.user import UserInDB
from services.ai_agent import AiAgentDep


class ProjectService:
def __init__(self, data_access: ProjectsDataAccessDep):
def __init__(self, data_access: ProjectsDataAccessDep, ai_agent: AiAgentDep):
self.data_access = data_access
self.ai_agent = ai_agent

async def get_project_by_id(self, project_id: int) -> ProjectSchema:
data = await self.data_access.get_project_by_id(project_id)
Expand Down Expand Up @@ -145,7 +148,7 @@ async def delete_project(self, project_id: int, user: UserInDB) -> ActionRespons
)
if project.ceo_id != user.id:
raise HTTPException(
status_code=403, detail=f"Only the project CEO can delete."
status_code=403, detail="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"
Expand Down Expand Up @@ -173,5 +176,32 @@ async def delete_application(self, project_id, user: UserInDB) -> ActionResponse
)
return ActionResponse(message=msg, success=res)

async def update_project(
self,
project_id: int,
data: UpdateProjectRequest,
user: UserInDB,
) -> ProjectSchema:
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 update the project."
)
return await self.data_access.update_project(
project_id, data.brief_description, data.description
)

async def enhance_project_description(
self, data: EnhanceDescriptionRequest,
) -> EnhanceDescriptionResponse:
res = await self.ai_agent.enhance_project_description(data.project_description)
return EnhanceDescriptionResponse(
enhanced_description=res
)


ProjectServiceDep = Annotated[ProjectService, Depends(ProjectService)]
13 changes: 13 additions & 0 deletions backend/app/routes/api/tag/data_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy.dialects.postgresql import insert
from typing import Annotated
from fastapi import Depends
from sqlalchemy import delete, select


class TagDataAccess:
Expand Down Expand Up @@ -34,5 +35,17 @@ async def add_tags_to_project(

return [TagSchema.model_validate(tag) for tag in tags]

async def remove_tags_from_project(self, project_id: int, tags: list[str]) -> int:
tags_subquery = select(Tag.id).where(Tag.name.in_(tags)).scalar_subquery()

delete_stmt = delete(ProjectTag).where(
ProjectTag.project_id == project_id,
ProjectTag.tag_id.in_(tags_subquery),
)

res = await self.db_session.execute(delete_stmt)

return res.rowcount


TagDataAccessDep = Annotated[TagDataAccess, Depends(TagDataAccess)]
8 changes: 8 additions & 0 deletions backend/app/routes/api/tag/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from schemas.agent_response import GeneratedTagsResponse
from dependencies.auth import AuthUserDep
from routes.api.tag.schemas import TagSchema
from schemas.generic import GenericResponse

router = APIRouter(prefix="/tags", tags=["tags"])

Expand All @@ -19,3 +20,10 @@ async def add_project_tags(
project_id: int, service: TagServiceDep, user: AuthUserDep, tags: list[TagSchema]
) -> list[TagSchema]:
return await service.add_project_tags(project_id, tags, user)


@router.delete("/remove/{project_id}")
async def remove_project_tags(
project_id: int, service: TagServiceDep, user: AuthUserDep, tags: list[TagSchema]
) -> GenericResponse:
return await service.remove_project_tags(project_id, tags, user)
23 changes: 23 additions & 0 deletions backend/app/routes/api/tag/service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from routes.api.project.data_access import ProjectsDataAccessDep
from schemas.generic import GenericResponse
from services.ai_agent import AiAgentDep
from schemas.agent_response import GeneratedTagsResponse
from fastapi import HTTPException, Depends
Expand Down Expand Up @@ -40,5 +41,27 @@ async def add_project_tags(

return await self.tag_data_access.add_tags_to_project(project_id, tags)

async def remove_project_tags(
self, project_id: int, tags: list[TagSchema], user: UserInDB
) -> GenericResponse:
project = await self.data_access.get_project_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
if user.id != project.ceo_id:
raise HTTPException(
status_code=403, detail="Only CEO can remove tags from the project"
)

res = await self.tag_data_access.remove_tags_from_project(
project_id, [tag.name for tag in tags]
)

return GenericResponse(
success=res > 0,
message=f"Removed {res} tags from the project"
if res > 0
else "No tags removed",
)


TagServiceDep = Annotated[TagService, Depends(TagService)]
4 changes: 4 additions & 0 deletions backend/app/routes/api/user/data_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ async def get_user_by_username(self, username: str) -> User | None:
)
return res.scalars().first()

async def get_user_by_id(self, user_id: int) -> User | None:
res = await self.db_session.execute(select(User).where(User.id == user_id))
return res.scalars().first()

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)
Expand Down
9 changes: 9 additions & 0 deletions backend/app/routes/api/user/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ async def get_me(
current_user=Depends(get_current_user),
) -> User:
return current_user


@router.get("/get-by-id/{user_id}")
async def get_user_by_id(
user_id: int,
service: UserServiceDep,
current_user=Depends(get_current_user),
) -> User:
return await service.get_user_by_id(user_id, current_user)
10 changes: 9 additions & 1 deletion backend/app/routes/api/user/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import Annotated
from fastapi import Depends, HTTPException

from models.users import UserRole
from routes.api.user.data_access import UserDataAccessDep, UserDataAccess
from routes.api.user.schemas import SetUserRoleSchema
from schemas.user import User
Expand All @@ -28,5 +27,14 @@ async def set_user_role(self, user_role: SetUserRoleSchema, current_user: User):
"message": f"User '{user.username}' role set to {user_role.role.value}."
}

async def get_user_by_id(self, user_id: int, current_user: User) -> User:
user = await self.user_repository.get_user_by_id(user_id)
if user is None:
raise HTTPException(
status_code=404,
detail=f"User with ID {user_id} not found.",
)
return user


UserServiceDep = Annotated[UserService, Depends(UserService)]
8 changes: 8 additions & 0 deletions backend/app/schemas/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import Optional

from pydantic import Field, BaseModel


class GenericResponse(BaseModel):
success: bool = Field(default=True)
message: Optional[str] = Field(default=None)
1 change: 1 addition & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


class User(BaseModel):
id: int
username: str
role: UserRole

Expand Down
15 changes: 14 additions & 1 deletion backend/app/services/ai_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,25 @@ async def get_tags_by_description(
tag_generator_agent = Agent(
self.model,
output_type=GeneratedTagsResponse,
system_prompt=f"You need to come up with a name for a tag based on the project description. You should provide 15 options. Write tags in English only",
system_prompt=f"You need to come up with a name for a tag based on the project description. Write tags in English only",
)

result = await tag_generator_agent.run(user_prompt=project_description)

return result.output

async def enhance_project_description(
self, project_description: str
) -> str:
description_enhancer_agent = Agent(
self.model,
output_type=str,
system_prompt=f"You need to enhance the given project description. Write in English only. Use markdown syntax for formatting.",
)

result = await description_enhancer_agent.run(user_prompt=project_description)

return result.output


AiAgentDep = Annotated[AiAgent, Depends(AiAgent)]
16 changes: 16 additions & 0 deletions frontend/jsrepo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/jsrepo@2.4.3/schemas/project-config.json",
"repos": ["@ieedan/shadcn-svelte-extras"],
"includeTests": false,
"includeDocs": false,
"watermark": true,
"formatter": "prettier",
"configFiles": {},
"paths": {
"*": "$lib/blocks",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"actions": "$lib/actions",
"utils": "$lib/utils"
}
}
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.8",
"@types/node": "^24.0.15",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"bits-ui": "^2.8.8",
Expand Down Expand Up @@ -65,6 +66,7 @@
"@tanstack/svelte-query": "^5.81.2",
"cobe": "^0.6.4",
"jwt-decode": "^4.0.0",
"openapi-fetch": "^0.14.0"
"openapi-fetch": "^0.14.0",
"svelte-markdown": "^0.4.1"
}
}
Loading
Loading