diff --git a/.gitignore b/.gitignore index b7faf40..784b2bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +*.db +log.app + +.idea/ +.vscode/ + +.env* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/database.py b/database.py new file mode 100644 index 0000000..3c253b3 --- /dev/null +++ b/database.py @@ -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)] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..da64346 --- /dev/null +++ b/main.py @@ -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) + } + } + ) \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..74562b0 --- /dev/null +++ b/models.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc52ab5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..1264294 --- /dev/null +++ b/routers/auth.py @@ -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 + } + } + diff --git a/routers/me.py b/routers/me.py new file mode 100644 index 0000000..b5cd2b3 --- /dev/null +++ b/routers/me.py @@ -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"} diff --git a/routers/projects.py b/routers/projects.py new file mode 100644 index 0000000..fc87a81 --- /dev/null +++ b/routers/projects.py @@ -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"} diff --git a/routers/users.py b/routers/users.py new file mode 100644 index 0000000..3e29d33 --- /dev/null +++ b/routers/users.py @@ -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"} + + + diff --git a/schemas/projects.py b/schemas/projects.py new file mode 100644 index 0000000..28c2ad4 --- /dev/null +++ b/schemas/projects.py @@ -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] = [] 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/projects_users.py b/schemas/projects_users.py new file mode 100644 index 0000000..36c74fd --- /dev/null +++ b/schemas/projects_users.py @@ -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] + \ No newline at end of file diff --git a/schemas/tasks.py b/schemas/tasks.py new file mode 100644 index 0000000..12f7d81 --- /dev/null +++ b/schemas/tasks.py @@ -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 + diff --git a/schemas/users.py b/schemas/users.py new file mode 100644 index 0000000..1d663da --- /dev/null +++ b/schemas/users.py @@ -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