Compare commits

...

66 commits

Author SHA1 Message Date
5275838fc7
Merge pull request #2 from a-mayb3/devel
Finished development and testing for minimum viable release for assigment submission
2026-02-12 11:55:14 +01:00
95d37bd378 Added samsple frontends repos in docs description 2026-02-10 13:40:13 +01:00
8d7537b602 Fixed adding collaborator 2026-02-10 13:02:01 +01:00
0f0b27b2d9 change user addition to project to be based off of email address and not user id 2026-02-10 12:36:40 +01:00
1b84af0025 Fixed confusion in project creation between sqlalchemy and pydantic model 2026-02-10 11:47:36 +01:00
48b2bcfa70 Refactored me.py, minor changes 2026-02-10 11:37:59 +01:00
839728cf66 Made ProjectUser return full project info 2026-02-10 11:37:30 +01:00
ac21fceffc made get_projects() return full project info 2026-02-09 13:44:52 +01:00
a40296929a Fixed project creation endpoint 2026-02-06 11:55:14 +01:00
7b011fd887 removed TODO comments from main.py 2026-02-03 13:37:23 +01:00
b7d24a85e4 Added basic description to FastAPI docs 2026-02-03 13:35:10 +01:00
4a2675a6f3 Added AGPL-3.0-or-later license to FastAPI metadata 2026-02-03 13:30:40 +01:00
e6e285a2c1 fixed some routers/projects.py get endpoint where Schemas and models where confused between themselves. 2026-02-03 13:19:15 +01:00
b34662e877 refactored auth.py to use helper methods 2026-02-03 13:05:10 +01:00
a3fb4903ed Fixed similar to last oversight where deleting a user would let it still in projects assigned users 2026-02-03 12:34:56 +01:00
52f13f5023 fixed oversight where deleting a project would leave dangling tasks and user-to-project relations 2026-02-03 12:33:20 +01:00
e53fb3f773 Refactored routers/projects.py to cut down on per-endpoint LoCs using helper methods 2026-02-03 12:25:22 +01:00
2a9fdc31a6 Refactored routers/projects.py to make way more readable by changing imports 2026-02-03 11:51:30 +01:00
5f7746279b More consistent endpoint methods and ordered them 2026-02-03 11:41:53 +01:00
3580a4f79f Created more endpoints for project managment 2026-02-03 11:30:41 +01:00
7bd2325649 Created more endpoints for project managment 2026-02-03 11:26:17 +01:00
11899d985e Removed useless LoCs (unused imports, useless comments, etc.) 2026-02-03 11:25:37 +01:00
96d833a089 Created schema projects_tasks to avoid circular imports 2026-02-03 11:20:19 +01:00
8fb4ba71b9 More refactoring using get_user_from_jwt() 2026-02-03 10:51:09 +01:00
74a2174bb2 Created new schema projects_users.py to avoid circular imports 2026-02-03 10:47:24 +01:00
aadc5ba45f Added some tags to get_me() 2026-02-03 10:40:54 +01:00
e953e09fee refactored reused code blocks to use helper function in routers.auth 2026-02-03 10:24:00 +01:00
057797c07d
moved token cheking to auth module 2026-02-02 20:10:43 +01:00
eccb3b35b4
requiring client to be authenticated when searching for user info 2026-02-02 18:32:49 +01:00
dc0c06e1ac Started fixing GET /projects/ 2026-02-02 13:51:09 +01:00
407b22eaf5 Started project managment endpoints 2026-02-02 13:39:22 +01:00
f9631cfe87 Added cookie removal on invalid cookies 2026-02-02 13:08:37 +01:00
192b5f9fc5 transfered logout and added user-deletion 2026-02-02 13:02:59 +01:00
6285ebbd16 Added general exception handler 2026-02-02 11:33:46 +01:00
c7ab0f00c2 ignoring app.log 2026-02-02 11:27:54 +01:00
33c5c008aa started implementing logger 2026-02-02 11:23:09 +01:00
59ec938c77 added check for already logged in users 2026-02-02 11:22:47 +01:00
b909a23fa3 Started implementing auth 2026-02-02 11:09:27 +01:00
e557c25789 Added more .gitignore entries 2026-02-02 11:06:42 +01:00
6c87298003 added crypt and auth dependencies 2026-02-02 11:05:11 +01:00
8b5f35ddad Added pyargon2 to dependencies 2026-02-02 08:17:56 +01:00
68bdba08fe Created a bunch of endpoints but forgetting to commit accordingly :) 2026-01-27 12:58:50 +01:00
4e4f25de06 Added some project schemas for project managment 2026-01-27 12:58:14 +01:00
df70a95418 Added schemas for user creation and modification 2026-01-27 12:57:26 +01:00
73d54b6fe7 Added basic user operations 2026-01-27 12:55:56 +01:00
d689fbc453 Added password_hash to user table 2026-01-27 12:48:10 +01:00
57a6ec2676 Removed superfluous tasks routers since tasks depend on projects 2026-01-27 12:47:39 +01:00
e3be049870 Added some joke and info root endpoint and some TODOs 2026-01-27 12:46:45 +01:00
d76a50f5f0 ping pong :) 2026-01-27 11:16:17 +01:00
abd4d5e988 Started working on projects related endpoints 2026-01-27 11:15:00 +01:00
0b676688c2 renamed misc/ to schemas/ 2026-01-27 11:08:10 +01:00
88180c9bd7
updated requirements.txt 2026-01-26 18:22:34 +01:00
2c92627949
Removed inexistent imports 2026-01-26 18:20:31 +01:00
3d8edb8fa1
Removed superfluous response models 2026-01-26 18:05:51 +01:00
170446fcc2
moved pydantic models to misc/ 2026-01-26 17:57:00 +01:00
6cd7bf8da2
Connection to sqlite db file 2026-01-26 17:54:17 +01:00
ec9e0fad78
Starting working on endpoints 2026-01-26 17:53:50 +01:00
552ab862bb
Base pydantic models for user managment 2026-01-26 17:53:30 +01:00
05eb7e0e5c
Base pydantic models for project managment 2026-01-26 17:53:13 +01:00
b3899de769
Base pydantic models for task managment 2026-01-26 17:52:20 +01:00
41ca480363
Defined tables for sqlite db 2026-01-26 17:51:51 +01:00
19ad5e97cd
Ignoring .db sqlite files 2026-01-26 16:34:20 +01:00
8b91f3441f
Created basic classes for tasks, projects, and task statuses 2026-01-26 15:49:47 +01:00
d76caf491c
Added SQLAlchemy and psycopg2 to requirements.txt 2026-01-26 15:37:03 +01:00
45f1395303
requirements.txt 2026-01-26 15:15:56 +01:00
f08f748712
added fastapi to requirements.txt 2026-01-26 14:35:00 +01:00
14 changed files with 914 additions and 0 deletions

8
.gitignore vendored
View file

@ -1,3 +1,11 @@
*.db
log.app
.idea/
.vscode/
.env*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]

32
database.py Normal file
View file

@ -0,0 +1,32 @@
import sqlalchemy
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from pydantic import BaseModel, ConfigDict
from fastapi import Depends
from sqlalchemy.orm import Session
from typing import Annotated
URL_DATABASE = "sqlite:///./kanban_clone.db"
engine = create_engine(URL_DATABASE, echo=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def init_db() -> None:
# Import models so they are registered with SQLAlchemy metadata
import models # noqa: F401
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
db_dependency = Annotated[Session, Depends(get_db)]

153
main.py Normal file
View file

@ -0,0 +1,153 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from routers.projects import router as projects_router
from routers.users import router as users_router
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).
## Other projects
Here are some frontend implementations for this API:
- [KanbanCloneAngular](https://github.com/a-mayb3/KanbanCloneAngular) - Angular frontend
- [KanbanCloneAndroid](https://github.com/a-mayb3/KanbanCloneAndroid) - Android frontend
"""
global_logger = logging.getLogger()
global_logger.setLevel(logging.INFO)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
filename='app.log',
encoding='utf-8'
)
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
global_logger.addHandler(handler)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger = global_logger
# Place for startup and shutdown events if needed in the future
logger.info("Initializing database...")
init_db()
yield
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,
allow_origins=["http://localhost:3000", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(me_router)
app.include_router(projects_router)
"""ping pong :)"""
@app.get("/ping")
def ping():
return {"message": "pong"}
"""Gives project url"""
@app.get("/sources")
def source():
return {"url": "https://github.com/a-mayb3/Kanban_clone_backend"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
"""Custom HTTP exception handler"""
logger = global_logger
logger.error(f"HTTP error occurred: {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"message": exc.detail,
"type": "authentication_error" if exc.status_code == 401 else "authorization_error",
"status_code": exc.status_code
}
},
headers=exc.headers
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
"""Handle validation errors"""
logger = global_logger
logger.error(f"Validation error: {exc.errors()}")
return JSONResponse(
status_code=422,
content={
"error": {
"message": "Validation error",
"type": "validation_error",
"details": exc.errors()
}
}
)
@app.exception_handler(Exception)
async def general_exception_handler(request, exc):
"""Handle all other exceptions"""
logger = global_logger
logger.error(f"Unexpected error: {exc}")
return JSONResponse(
status_code=500,
content={
"error": {
"message": "An unexpected error occurred.",
"type": "internal_server_error",
"details": str(exc)
}
}
)

41
models.py Normal file
View file

@ -0,0 +1,41 @@
from sqlalchemy import Column, ForeignKey, String, Integer, Table
from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.orm import relationship
from database import Base
from typing import Optional, List
project_user = Table(
"project_user",
Base.metadata,
Column("project_id", Integer, ForeignKey("projects.id"), primary_key=True),
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True)
)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password_hash = Column(String)
password_salt = Column(String)
projects = relationship("Project", secondary=project_user, back_populates="users")
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, index=True)
description = Column(String)
users = relationship("User", secondary=project_user, back_populates="projects")
tasks = relationship("Task", back_populates="project")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
title = Column(String, index=True)
description = Column(String)
status = Column(String, default="pending")
project_id = Column(Integer, ForeignKey("projects.id"))
project = relationship("Project", back_populates="tasks")

58
requirements.txt Normal file
View file

@ -0,0 +1,58 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
bcrypt==5.0.0
certifi==2026.1.4
cffi==2.0.0
click==8.3.1
cryptography==46.0.4
dnspython==2.8.0
ecdsa==0.19.1
email-validator==2.3.0
fastapi==0.128.0
fastapi-cli==0.0.20
fastapi-cloud-cli==0.11.0
fastar==0.8.0
greenlet==3.3.1
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
itsdangerous==2.2.0
Jinja2==3.1.6
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
passlib==1.7.4
pyargon2==1.1.2
pyasn1==0.6.2
pycparser==3.0
pydantic==2.12.5
pydantic-extra-types==2.11.0
pydantic-settings==2.12.0
pydantic_core==2.41.5
Pygments==2.19.2
python-dotenv==1.2.1
python-jose==3.5.0
python-multipart==0.0.22
PyYAML==6.0.3
rich==14.3.1
rich-toolkit==0.17.1
rignore==0.7.6
rsa==4.9.1
sentry-sdk==2.50.0
shellingham==1.5.4
six==1.17.0
SQLAlchemy==2.0.46
starlette==0.50.0
starlette-session==0.4.3
starlette-session-middleware==0.1.6
typer==0.21.1
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.40.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0

142
routers/auth.py Normal file
View file

@ -0,0 +1,142 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
from database import db_dependency
from jose import JWTError, jwt
from datetime import datetime, timedelta, timezone
import models
import schemas.users as user_schemas
from pyargon2 import hash
router = APIRouter(prefix="/auth", tags=["auth"])
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production")
ALGORITHM = "HS256"
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
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
to_encode.update({"iat": datetime.now(timezone.utc)})
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"""
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:
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="Could not verify credentials"
)
return db_user
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 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):
"""Login and receive JWT token in cookie"""
## check if access token already exists
get_token = request.cookies.get("access_token")
if get_token:
try:
user_id = verify_jwt_token(get_token)
return {
"message": "Already logged in",
"user": {
"id": user_id
}
}
except HTTPException:
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(
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
)
# Set JWT in httpOnly cookie
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax",
secure=False # Set to True in production with HTTPS
)
return {
"message": "Login successful",
"user": {
"id": db_user.id,
"name": db_user.name,
"email": db_user.email
}
}

57
routers/me.py Normal file
View file

@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
from database import db_dependency
from jose import JWTError, jwt
import models
from routers import auth
from schemas.users import UserBase
from schemas.projects import ProjectBase
from schemas.projects_users import ProjectUserBase
router = APIRouter(prefix="/me", tags=["me"])
@router.get("/", response_model=ProjectUserBase, tags=["me", "users"])
def get_me(request: Request, db: db_dependency):
"""Get current authenticated user"""
user = auth.get_user_from_jwt(request, db)
return user
@router.get("/logout", tags=["me", "auth"])
def logout(request: Request,response: Response):
"""Logout by clearing the JWT cookie"""
get_token = request.cookies.get("access_token")
if not get_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not logged in"
)
response.delete_cookie(key="access_token")
return {"message": "Logout successful"}
@router.delete("/delete-me", tags=["me", "auth", "users"])
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
request.cookies.clear()
return {"message": "User deleted successfully"}

234
routers/projects.py Normal file
View file

@ -0,0 +1,234 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from typing import List, Annotated
from database import db_dependency
from schemas.tasks import TaskBase, TaskCreate, TaskUpdate
from schemas.projects import ProjectBase, ProjectCreate, ProjectUpdate, ProjectAddUser, ProjectRemoveUsers, ProjectFull
from schemas.users import UserBase
from schemas.projects_users import ProjectUserBase
from schemas.projects_tasks import ProjectTaskBase, ProjectTaskCreate
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"])
@router.get("/", response_model=List[ProjectFull], tags=["projects", "me"])
def get_projects(db: db_dependency, request: Request):
"""Get a user's projects"""
user = get_user_from_jwt(request, db)
user_id = getattr(user, "id")
## fetching projects for the user
projects = db.query(Project).join(Project.users).filter(User.id == user_id).all()
return projects
@router.get("/{project_id}", response_model=ProjectFull)
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
@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 = get_project_by_id_for_user(user, project_id, db)
return db_project.users
@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(User).filter(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")
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(Task).filter(Task.project_id == project_id).all()
return db_tasks
@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 = Project(
name=project.name,
description=project.description,
tasks=[]
)
db_project.users.append(user)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.post("/{project_id}/tasks", response_model=ProjectTaskBase, 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)
db_task = Task(
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.post("/{project_id}/users", response_model=ProjectFull, tags=["users"])
def add_project_user(project_id: int, user_data: ProjectAddUser, db: db_dependency, request: Request):
"""Add a user to a specified project using their email address"""
user = get_user_from_jwt(request, db)
db_project = get_project_by_id_for_user(user, project_id, db)
db_user = db.query(User).filter(User.email == user_data.user_email).first()
if not db_user:
raise HTTPException(status_code=404, detail="User with the specified email not found")
if db_user not in db_project.users:
db_project.users.append(db_user)
else:
raise HTTPException(status_code=400, detail="User is already a member of the project")
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)
db_project = get_project_by_id_for_user(user, project_id, db)
db_user = db.query(User).filter(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 db_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)
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
@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)
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
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[Task] = db.query(Task).filter(Task.project_id == project_id).all()
for task in tasks:
db.delete(task)
db_project.tasks.remove(task)
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)
db.commit()
return {"detail": "Task deleted successfully"}

76
routers/users.py Normal file
View file

@ -0,0 +1,76 @@
import os
from typing import List
from fastapi import APIRouter, HTTPException, Depends, Request
from jose import JWTError, jwt
from database import db_dependency
import models
from routers import auth
import schemas.users as users
import schemas.projects as projects
from routers.auth import get_user_from_jwt
from pyargon2 import hash
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=users.UserBase)
def read_user(user_id: int, db: db_dependency, request:Request):
"""Get a user by ID"""
get_user_from_jwt(request, db)
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user
@router.get("/{user_id}/projects", response_model=List[projects.ProjectBase])
def read_projects_from_user(user_id: int, db: db_dependency, request: Request):
"""Get projects assigned to a user"""
get_user_from_jwt(request, db)
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user.projects
##
## POST endpoints
##
@router.post("/", response_model=users.UserBase)
def create_user(user: users.UserCreate, db: db_dependency):
"""Create a new user"""
user_salt = os.urandom(32).hex()
print("Generated salt:", user_salt)
hashed_password = hash(password=user.password, salt=user_salt, variant="id")
db_user = models.User(
name=user.name,
email=user.email,
password_hash=hashed_password,
password_salt=user_salt
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.delete("/{user_id}")
def delete_user(user_id: int, db: db_dependency):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(db_user)
db.commit()
return {"detail": "User deleted"}

36
schemas/projects.py Normal file
View file

@ -0,0 +1,36 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from schemas.tasks import TaskBase
from schemas.users import UserBase
class ProjectBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
description: str
tasks: List[TaskBase]
users: List[UserBase]
class ProjectFull(ProjectBase):
id: int
class ProjectCreate(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
description: Optional[str] = None
tasks: List[TaskBase] = []
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ProjectAddUser(BaseModel):
user_email: str
class ProjectAddUsers(BaseModel):
user_ids: List[int] = []
class ProjectRemoveUsers(BaseModel):
user_ids: List[int] = []

11
schemas/projects_tasks.py Normal file
View file

@ -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

11
schemas/projects_users.py Normal file
View file

@ -0,0 +1,11 @@
from typing import List
from pydantic import ConfigDict
from schemas.projects import ProjectFull
from schemas.users import UserBase
class ProjectUserBase(UserBase):
model_config = ConfigDict(from_attributes=True)
projects: List[ProjectFull]

29
schemas/tasks.py Normal file
View file

@ -0,0 +1,29 @@
from enum import Enum
from pydantic import BaseModel, ConfigDict
from typing import List, Annotated, Optional
class TaskStatus(str, Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
STASHED = "stashed"
class TaskBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
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
status: Optional[TaskStatus] = None

26
schemas/users.py Normal file
View file

@ -0,0 +1,26 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
class UserBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
class UserCreate(BaseModel):
name: str
email: str
password: str
class UserUpdateInfo(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
class UserUpdatePassword(BaseModel):
password: str
new_password: str
class UserLogin(BaseModel):
email: str
password: str