diff --git a/main.py b/main.py index 280c2fe..ba3776f 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,26 @@ from routers.auth import router as auth_router from routers.me import router as me_router from database import init_db +app_description = """ +This API serves as the backend for a Kanban-style project management application. +It allows users to manage projects, tasks, and user assignments with proper authentication and authorization. + +## Stack +- FastAPI +- SQLAlchemy +- SQLite + +## Features +- User Authentication (JWT and Argon2 Password Hashing) +- Project Management (Create, Read, Update, Delete) +- Task Management within Projects +- User Assignments to Projects +- CORS Configuration for Frontend Integration + +## Source Code +The source code for this API can be found on [GitHub](https://github.com/a-mayb3/Kanban_clone_backend) or [my forgejo instance](https://git.vollex.cc/a-mayb3/Kanban_clone_backend). +""" + global_logger = logging.getLogger() global_logger.setLevel(logging.INFO) logging.basicConfig( @@ -32,7 +52,12 @@ async def lifespan(app: FastAPI): init_db() yield -app = FastAPI(lifespan=lifespan) +app = FastAPI( + lifespan=lifespan, + license_info={"name": "AGPL-3.0-or-later", "url": "https://www.gnu.org/licenses/agpl-3.0.en.html"}, + description=app_description, + title="Kanban Clone Backend API", + ) app.add_middleware( CORSMiddleware, @@ -57,10 +82,6 @@ def ping(): def source(): return {"url": "https://github.com/a-mayb3/Kanban_clone_backend"} -## TODO: Add root endpoint that gives basic info about the API -## TODO: Add more detailed error handling and logging -## TODO: Implement authentication and authorization mechanisms - if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/routers/auth.py b/routers/auth.py index b6c8586..1264294 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -18,7 +18,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours def create_access_token(data: dict, expires_delta: timedelta | None = None): """Create a JWT token""" - + to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -29,38 +29,63 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt +def verify_jwt_token(token: str): + """Verify and decode a JWT token""" + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + return user_id + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + def get_user_from_jwt(request: Request, db: db_dependency) -> models.User : """Helper function to check for valid JWT token in cookies""" - token = request.cookies.get("access_token") - if not token: + + get_token = request.cookies.get("access_token") + + if not get_token or get_token is None: raise HTTPException( status_code=401, detail="Not logged in" ) try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - user_id: str = str(payload.get("sub")) - if user_id is None: - request.cookies.clear() ## removing invalid auth cookie - raise HTTPException( - status_code=401, - detail="Not logged in" - ) + user_id: str = verify_jwt_token(get_token) ## verifying token validity + db_user = db.query(models.User).filter(models.User.id == int(user_id)).first() if db_user is None: request.cookies.clear() ## removing invalid auth cookie raise HTTPException( status_code=401, - detail="User not found" + detail="Could not verify credentials" ) return db_user - - except JWTError: + except HTTPException: request.cookies.clear() ## removing invalid auth cookie + raise + +def verify_user_password(user_id: int, password: str, db: db_dependency) -> None: + """Verify user's password""" + db_user = db.query(models.User).filter(models.User.id == user_id).first() + if db_user is None: raise HTTPException( status_code=401, - detail="Could not validate credentials" - ) + detail="Could not verify credentials") + + hashed_password = hash(password=password, salt=str(getattr(db_user,"password_salt")), variant="id") + if hashed_password != db_user.password_hash: + raise HTTPException( + status_code=401, + detail="Could not verify credentials") @router.post("/login") def login(user_data: user_schemas.UserLogin, request: Request, response: Response, db: db_dependency): @@ -78,8 +103,9 @@ def login(user_data: user_schemas.UserLogin, request: Request, response: Respons } } except HTTPException: - pass # Token invalid or expired, proceed to login + request.cookies.clear() ## removing invalid auth cookie + ## check if user exists db_user = db.query(models.User).filter(models.User.email == user_data.email).first() if db_user is None: raise HTTPException( @@ -87,16 +113,12 @@ def login(user_data: user_schemas.UserLogin, request: Request, response: Respons detail="Incorrect email or password" ) - if not verify_user_password(getattr(db_user, "id"), user_data.password, db): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" - ) + verify_user_password(getattr(db_user, "id"), user_data.password, db) access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( - data={"sub": str(db_user.id)}, expires_delta=access_token_expires - + data={"sub": str(db_user.id)}, + expires_delta=access_token_expires ) # Set JWT in httpOnly cookie @@ -118,30 +140,3 @@ def login(user_data: user_schemas.UserLogin, request: Request, response: Respons } } - -def verify_jwt_token(token: str): - """Verify and decode a JWT token""" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - user_id = payload.get("sub") - if user_id is None: - raise credentials_exception - return user_id - except JWTError: - raise credentials_exception - -def verify_user_password(user_id: int, password: str, db: db_dependency) -> bool: - """Verify user's password""" - db_user = db.query(models.User).filter(models.User.id == user_id).first() - if db_user is None: - return False - - hashed_password = hash(password=password, salt=str(getattr(db_user,"password_salt")), variant="id") - if hashed_password != db_user.password_hash: - return False - - return True \ No newline at end of file diff --git a/routers/me.py b/routers/me.py index f06bb9c..44f78fe 100644 --- a/routers/me.py +++ b/routers/me.py @@ -35,6 +35,18 @@ def delete_me(request: Request, db: db_dependency): """Delete current authenticated user""" user = auth.get_user_from_jwt(request, db) + + ## Remove user from all projects, delete projects with no users left + projects = user.projects[:] + for project in projects: + project.users.remove(user) + if len(project.users) == 0: + ## delete project if no users left + tasks = project.tasks[:] + for task in tasks: + db.delete(task) + db.delete(project) + db.delete(user) db.commit() ## Logout user by clearing cookie diff --git a/routers/projects.py b/routers/projects.py index 226b26e..874c483 100644 --- a/routers/projects.py +++ b/routers/projects.py @@ -1,26 +1,40 @@ from fastapi import APIRouter, HTTPException, Depends, Request from typing import List, Annotated -from httpx import request -from jose import JWTError, jwt - -from routers import auth from database import db_dependency -import schemas.tasks as tasks_schemas -import schemas.projects as projects_schemas -import schemas.users as users_schemas +from schemas.tasks import TaskBase, TaskCreate, TaskUpdate +from schemas.projects import ProjectBase, ProjectCreate, ProjectUpdate, ProjectAddUsers, ProjectRemoveUsers +from schemas.users import UserBase +from schemas.projects_users import ProjectUserBase +from schemas.projects_tasks import ProjectTaskBase, ProjectTaskCreate -from models import Project +from models import Project, Task, User from routers.auth import get_user_from_jwt + +def get_project_by_id_for_user(user: UserBase, project_id: int, db: db_dependency) -> ProjectBase: + """Get a project by ID and verify user has access""" + db_project = db.query(Project).filter(Project.id == project_id).first() + if db_project is None: + raise HTTPException(status_code=404, detail="Project not found") + if user not in db_project.users: + raise HTTPException(status_code=403, detail="Not authorized to access this project") + + return db_project + +def get_task_by_id_for_project(project: ProjectBase, task_id: int, db: db_dependency) -> TaskBase: + """ + Get a task by ID within a project + Supposes the user has already been verified to have access to the project + """ + db_task = db.query(Task).filter(Task.id == task_id, Task.project_id == getattr(project, "id")).first() + if db_task is None: + raise HTTPException(status_code=404, detail="Task not found in the specified project") + return db_task router = APIRouter(prefix="/projects", tags=["projects"]) -## -## GET endpoints -## - -@router.get("/", response_model=List[projects_schemas.ProjectBase], tags=["projects", "me"]) +@router.get("/", response_model=List[ProjectBase], tags=["projects", "me"]) def get_projects(db: db_dependency, request: Request): """Get a user's projects""" @@ -28,57 +42,65 @@ def get_projects(db: db_dependency, request: Request): user_id = getattr(user, "id") ## fetching projects for the user - projects = db.query(Project).join(Project.users).filter(getattr(users_schemas.UserBase, "id") == int(user_id)).all() + projects = db.query(Project).join(Project.users).filter(User.id == user_id).all() return projects -@router.get("/{project_id}", response_model=projects_schemas.ProjectBase) +@router.get("/{project_id}", response_model=ProjectBase) def get_project(project_id: int, request:Request, db: db_dependency): """Get a project by ID""" user = get_user_from_jwt(request, db) + project = get_project_by_id_for_user(user, project_id, db) + return project - db_project = db.query(projects_schemas.ProjectBase).filter(getattr(projects_schemas.ProjectBase, "id") == project_id).first() - if db_project is None: - raise HTTPException(status_code=404, detail="Project not found") - if user not in db_project.users: - raise HTTPException(status_code=403, detail="Not authorized to access this project") - - return db_project - -@router.get("/{project_id}/users", response_model=List[users_schemas.UserBase], tags=["users", "projects"]) +@router.get("/{project_id}/users", response_model=List[UserBase], tags=["users", "projects"]) def get_project_users(project_id: int, request:Request, db: db_dependency): """Get users from a specified project""" user = get_user_from_jwt(request, db) - db_project = db.query(projects_schemas.ProjectBase).filter(getattr(projects_schemas.ProjectBase, "id") == project_id).first() - if db_project is None: - raise HTTPException(status_code=404, detail="Project not found") - if user not in db_project.users: - raise HTTPException(status_code=403, detail="Not authorized to access this project's users") - + db_project = get_project_by_id_for_user(user, project_id, db) return db_project.users -@router.get("/{project_id}/tasks", response_model=List[tasks_schemas.TaskBase], tags=["tasks", "projects"]) + +@router.get("/{project_id}/tasks/{task_id}", response_model=TaskBase, tags=["tasks"]) +def get_project_task(project_id: int, task_id: int, db: db_dependency, request: Request): + """Get a specific task from a specified project""" + + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) + db_task = get_task_by_id_for_project(db_project, task_id, db) + + return db_task + +@router.get("/{project_id}/users/{user_id}", response_model=UserBase, tags=["users"]) +def get_project_user(project_id: int, user_id: int, db: db_dependency, request: Request): + """Get a specific user from a specified project""" + user = get_user_from_jwt(request, db) + + db_project : ProjectBase = get_project_by_id_for_user(user, project_id, db) + + db_user = db.query(UserBase).filter(getattr(UserBase, "id") == user_id).first() + if db_user is None or db_user not in db_project.users: + raise HTTPException(status_code=404, detail="User not found in the specified project") + return db_user + +@router.get("/{project_id}/tasks", response_model=List[TaskBase], tags=["tasks", "projects"]) def get_project_tasks(project_id: int, request:Request, db: db_dependency): """Get tasks from a specified project""" user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) + db_tasks = db.query(TaskBase).filter(getattr(TaskBase, "project_id") == project_id).all() + return db_tasks - db_project = db.query(projects_schemas.ProjectBase).filter(getattr(projects_schemas.ProjectBase, "id") == project_id).first() - if db_project is None: - raise HTTPException(status_code=404, detail="Project not found") - if user not in db_project.users: - raise HTTPException(status_code=403, detail="Not authorized to access this project's tasks") - - return db.query(tasks_schemas.TaskBase).filter(getattr(tasks_schemas.TaskBase, "project_id") == project_id).all() - -@router.post("/", response_model=projects_schemas.ProjectCreate) -def create_project(project: projects_schemas.ProjectCreate, request:Request, db: db_dependency): +@router.post("/", response_model=ProjectCreate) +def create_project(project: ProjectCreate, request:Request, db: db_dependency): """Create a new project""" user = get_user_from_jwt(request, db) - db_project = projects_schemas.ProjectCreate( + + db_project = ProjectCreate( name=project.name, description=project.description, tasks=[], @@ -93,159 +115,121 @@ def create_project(project: projects_schemas.ProjectCreate, request:Request, db: return db_project -# """Get tasks from a specified project""" -# @router.get("/{project_id}/tasks", response_model=List[tasks_schemas.TaskBase], tags=["tasks"]) -# def read_tasks_from_project(project_id: int, db: db_dependency): -# db_tasks = db.query(tasks.models.Task).filter(tasks.models.Task.project_id == project_id).all() -# return db_tasks -# """Get a specific task from a specified project""" -# @router.get("/{project_id}/tasks/{task_id}", response_model=tasks_schemas.TaskBase, tags=["tasks"]) -# def read_task_from_project(project_id: int, task_id: int, db: db_dependency): -# db_task = db.query(tasks.models.Task).filter(tasks.models.Task.project_id == project_id, tasks.models.Task.id == task_id).first() -# if db_task is None: -# raise HTTPException(status_code=404, detail="Task not found in the specified project") -# return db_task +@router.post("/{project_id}/tasks", response_model=ProjectTaskCreate, tags=["tasks"]) +def create_project_task(project_id: int, task: TaskCreate, db: db_dependency, request: Request): + """Create a new task in a specified project""" + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) -# """Get users from a specified project""" + db_task = ProjectTaskCreate( + title=task.title, + description=task.description, + status=task.status, + project=db_project + ) + + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task -# @router.get("/{project_id}/users", response_model=List[users_schemas.UserBase], tags=["users"]) -# def read_users_from_project(project_id: int, db: db_dependency): -# db_project = db.query(projects.models.Project).filter(projects.models.Project.id == project_id).first() -# if db_project is None: -# raise HTTPException(status_code=404, detail="Project not found") -# return db_project.users +@router.post("/{project_id}/users", response_model=ProjectAddUsers, tags=["users"]) +def add_project_user(project_id: int, user_data: ProjectAddUsers, db: db_dependency, request: Request): + """Add users to a specified project using their IDs""" + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) -# ## -# ## POST endpoints -# ## + for user_id in user_data.user_ids: + db_user = db.query(UserBase).filter(getattr(UserBase, "id") == user_id).first() + if db_user: + db_project.users.append(db_user) + db.commit() + db.refresh(db_project) + return db_project +@router.delete("/{project_id}/users/{user_id}", response_model=ProjectRemoveUsers, tags=["users"]) +def remove_user_from_project(project_id: int, user_id: int, db: db_dependency, request: Request): + """Remove a user from a specified project using their ID""" + user = get_user_from_jwt(request, db) -# """Create a new project""" + db_project = get_project_by_id_for_user(user, project_id, db) -# @router.post("/", response_model=projects.ProjectCreate) -# def create_project(project: projects.ProjectCreate, db: db_dependency): -# db_project = projects( -# name=project.name, -# description=project.description -# ) -# db.add(db_project) -# db.commit() -# db.refresh(db_project) -# return db_project + db_user = db.query(UserBase).filter(getattr(UserBase, "id") == user_id).first() + if db_user is None or db_user not in db_project.users: + raise HTTPException(status_code=404, detail="User not found in the specified project") + db_project.users.remove(db_user) + db.commit() + db.refresh(db_project) + return db_project -# """Create a new task in a specified project""" +@router.put("/{project_id}/tasks/{task_id}", response_model=TaskUpdate, tags=["tasks"]) +def update_project_task(project_id: int, task_id: int, task: TaskUpdate, db: db_dependency, request: Request): + """Update a task in a specified project""" + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) + db_task = get_task_by_id_for_project(db_project, task_id, db) -# @router.post("/{project_id}/tasks", response_model=tasks.TaskBase, tags=["tasks"]) -# def create_task_in_project(project_id: int, task: tasks.TaskBase, db: db_dependency): -# db_task = tasks.models.Task( -# title=task.title, -# description=task.description, -# status=task.status, -# project_id=project_id -# ) -# db.add(db_task) -# db.commit() -# db.refresh(db_task) -# return db_task + if task.title is not None: + db_task.title = task.title + if task.description is not None: + db_task.description = task.description + if task.status is not None: + db_task.status = task.status + db.commit() + db.refresh(db_task) + return db_task -# """Add users to a specified project using their IDs""" +@router.put("/{project_id}", response_model=ProjectUpdate) +def update_project(project_id: int, project: ProjectUpdate, db: db_dependency, request: Request): + """Update a project by ID""" + user = get_user_from_jwt(request, db) -# @router.post("/{project_id}/users", response_model=projects.ProjectAddUsers, tags=["users"]) -# def add_users_to_project(project_id: int, user_data: projects.ProjectAddUsers, db: db_dependency): -# db_project = db.query(projects.models.Project).filter(projects.models.Project.id == project_id).first() -# if db_project is None: -# raise HTTPException(status_code=404, detail="Project not found") -# for user_id in user_data.user_ids: -# db_user = db.query(users.models.User).filter(users.models.User.id == user_id).first() -# if db_user: -# db_project.users.append(db_user) -# db.commit() -# db.refresh(db_project) -# return db_project + db_project = get_project_by_id_for_user(user, project_id, db) + if project.name is not None: + db_project.name = project.name + if project.description is not None: + db_project.description = project.description -# ## -# ## PUT endpoints -# ## + db.commit() + db.refresh(db_project) + return db_project +@router.delete("/{project_id}", tags=["projects"]) +def delete_project(project_id: int, db: db_dependency, request: Request): + """Delete a project by ID""" + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) + + ## Remove dangling tasks and user associations + tasks: List[TaskBase] = db.query(TaskBase).filter(getattr(TaskBase, "project_id") == project_id).all() + for task in tasks: + db.delete(task) + db_project.tasks.remove(task) -# """Update a project by ID""" + users: List[ProjectUserBase] = db.query(ProjectUserBase).join(ProjectUserBase).filter(getattr(ProjectBase, "id") == project_id).all() + for proj_user in users: + db_project.users.remove(proj_user) + proj_user.projects.remove(db_project) -# @router.put("/{project_id}", response_model=projects.ProjectUpdate) -# def update_project(project_id: int, project: projects.ProjectUpdate, db: db_dependency): -# db_project = db.query(projects.models.Project).filter(projects.models.Project.id == project_id).first() -# if db_project is None: -# raise HTTPException(status_code=404, detail="Project not found") -# if project.name is not None: -# db_project.name = project.name -# if project.description is not None: -# db_project.description = project.description -# db.commit() -# db.refresh(db_project) -# return db_project + db.delete(db_project) + db.commit() + return {"detail": "Project deleted successfully"} +@router.delete("/{project_id}/tasks/{task_id}" , tags=["tasks"]) +def delete_project_task(project_id: int, task_id: int, db: db_dependency, request: Request): + """Delete a task from a specified project""" + user = get_user_from_jwt(request, db) + db_project = get_project_by_id_for_user(user, project_id, db) + db_task = get_task_by_id_for_project(db_project, task_id, db) + + db.delete(db_task) + db_project.tasks.remove(db_task) -# """Update a task in a specified project""" - -# @router.put("/{project_id}/tasks/{task_id}", response_model=tasks.TaskUpdate, tags=["tasks"]) -# def update_task_in_project(project_id: int, task_id: int, task: tasks.TaskUpdate, db: db_dependency): -# db_task = db.query(tasks.models.Task).filter(tasks.models.Task.project_id == project_id, tasks.models.Task.id == task_id).first() -# if db_task is None: -# raise HTTPException(status_code=404, detail="Task not found in the specified project") -# if task.title is not None: -# db_task.title = task.title -# if task.description is not None: -# db_task.description = task.description -# if task.status is not None: -# db_task.status = task.status -# db.commit() -# db.refresh(db_task) -# return db_task - -# ## -# ## DELETE endpoints -# ## - -# """Delete a project by ID""" - -# @router.delete("/{project_id}") -# def delete_project(project_id: int, db: db_dependency): -# db_project = db.query(projects.models.Project).filter(projects.models.Project.id == project_id).first() -# if db_project is None: -# raise HTTPException(status_code=404, detail="Project not found") -# db.delete(db_project) -# db.commit() -# return {"detail": "Project deleted successfully"} - - -# """Delete a task from a specified project""" - -# @router.delete("/{project_id}/tasks/{task_id}" , tags=["tasks"]) -# def delete_task_from_project(project_id: int, task_id: int, db: db_dependency): -# db_task = db.query(tasks.models.Task).filter(tasks.models.Task.project_id == project_id, tasks.models.Task.id == task_id).first() -# if db_task is None: -# raise HTTPException(status_code=404, detail="Task not found in the specified project") -# db.delete(db_task) -# db.commit() -# return {"detail": "Task deleted successfully"} - - -# """Remove users from a specified project using their IDs""" - -# @router.delete("/{project_id}/users/{user_id}", tags=["users"]) -# def remove_user_from_project(project_id: int, user_id: int, db: db_dependency): -# db_project = db.query(projects.models.Project).filter(projects.models.Project.id == project_id).first() -# if db_project is None: -# raise HTTPException(status_code=404, detail="Project not found") -# db_user = db.query(users.models.User).filter(users.models.User.id == user_id).first() -# if db_user is None or db_user not in db_project.users: -# raise HTTPException(status_code=404, detail="User not found in the specified project") -# db_project.users.remove(db_user) -# db.commit() -# db.refresh(db_project) -# return {"detail": "User removed from project successfully"} + db.commit() + return {"detail": "Task deleted successfully"} diff --git a/schemas/projects.py b/schemas/projects.py index 6522337..302d7f5 100644 --- a/schemas/projects.py +++ b/schemas/projects.py @@ -7,12 +7,14 @@ from schemas.users import UserBase class ProjectBase(BaseModel): model_config = ConfigDict(from_attributes=True) - id: int name: str description: str tasks: List[TaskBase] users: List[UserBase] +class ProjectFull(ProjectBase): + id: int + class ProjectCreate(BaseModel): name: str description: Optional[str] = None diff --git a/schemas/projects_tasks.py b/schemas/projects_tasks.py new file mode 100644 index 0000000..9f97c28 --- /dev/null +++ b/schemas/projects_tasks.py @@ -0,0 +1,11 @@ +import schemas.projects as project_schemas +import schemas.tasks as task_schemas +from pydantic import ConfigDict + +class ProjectTaskBase(task_schemas.TaskBase): + model_config = ConfigDict(from_attributes=True) + + project: project_schemas.ProjectBase + +class ProjectTaskCreate(task_schemas.TaskCreate): + project: project_schemas.ProjectBase \ No newline at end of file diff --git a/schemas/tasks.py b/schemas/tasks.py index ea3b711..12f7d81 100644 --- a/schemas/tasks.py +++ b/schemas/tasks.py @@ -17,6 +17,11 @@ class TaskBase(BaseModel): description: Optional[str] = None status: TaskStatus = TaskStatus.PENDING +class TaskCreate(BaseModel): + title: str + description: Optional[str] = None + status: TaskStatus = TaskStatus.PENDING + class TaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None