mirror of
https://github.com/a-mayb3/Kanban_clone_backend.git
synced 2026-03-21 10:05:38 +01:00
Compare commits
66 commits
4722c4cf99
...
5275838fc7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5275838fc7 | |||
| 95d37bd378 | |||
| 8d7537b602 | |||
| 0f0b27b2d9 | |||
| 1b84af0025 | |||
| 48b2bcfa70 | |||
| 839728cf66 | |||
| ac21fceffc | |||
| a40296929a | |||
| 7b011fd887 | |||
| b7d24a85e4 | |||
| 4a2675a6f3 | |||
| e6e285a2c1 | |||
| b34662e877 | |||
| a3fb4903ed | |||
| 52f13f5023 | |||
| e53fb3f773 | |||
| 2a9fdc31a6 | |||
| 5f7746279b | |||
| 3580a4f79f | |||
| 7bd2325649 | |||
| 11899d985e | |||
| 96d833a089 | |||
| 8fb4ba71b9 | |||
| 74a2174bb2 | |||
| aadc5ba45f | |||
| e953e09fee | |||
| 057797c07d | |||
| eccb3b35b4 | |||
| dc0c06e1ac | |||
| 407b22eaf5 | |||
| f9631cfe87 | |||
| 192b5f9fc5 | |||
| 6285ebbd16 | |||
| c7ab0f00c2 | |||
| 33c5c008aa | |||
| 59ec938c77 | |||
| b909a23fa3 | |||
| e557c25789 | |||
| 6c87298003 | |||
| 8b5f35ddad | |||
| 68bdba08fe | |||
| 4e4f25de06 | |||
| df70a95418 | |||
| 73d54b6fe7 | |||
| d689fbc453 | |||
| 57a6ec2676 | |||
| e3be049870 | |||
| d76a50f5f0 | |||
| abd4d5e988 | |||
| 0b676688c2 | |||
| 88180c9bd7 | |||
| 2c92627949 | |||
| 3d8edb8fa1 | |||
| 170446fcc2 | |||
| 6cd7bf8da2 | |||
| ec9e0fad78 | |||
| 552ab862bb | |||
| 05eb7e0e5c | |||
| b3899de769 | |||
| 41ca480363 | |||
| 19ad5e97cd | |||
| 8b91f3441f | |||
| d76caf491c | |||
| 45f1395303 | |||
| f08f748712 |
14 changed files with 914 additions and 0 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1,3 +1,11 @@
|
||||||
|
*.db
|
||||||
|
log.app
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
.env*
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
|
|
|
||||||
32
database.py
Normal file
32
database.py
Normal 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
153
main.py
Normal 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
41
models.py
Normal 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
58
requirements.txt
Normal 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
142
routers/auth.py
Normal 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
57
routers/me.py
Normal 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
234
routers/projects.py
Normal 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
76
routers/users.py
Normal 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
36
schemas/projects.py
Normal 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
11
schemas/projects_tasks.py
Normal 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
11
schemas/projects_users.py
Normal 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
29
schemas/tasks.py
Normal 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
26
schemas/users.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue