diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c383439 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +*.egg-info/ +.venv/ diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index ea7056a..0000000 Binary files a/app/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc deleted file mode 100644 index fc0760f..0000000 Binary files a/app/__pycache__/main.cpython-311.pyc and /dev/null differ diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 8cbf728..0000000 Binary files a/app/core/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/app/core/__pycache__/config.cpython-311.pyc b/app/core/__pycache__/config.cpython-311.pyc deleted file mode 100644 index 76e21c0..0000000 Binary files a/app/core/__pycache__/config.cpython-311.pyc and /dev/null differ diff --git a/app/main.py b/app/main.py index 8b60f07..ead4bf1 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,11 @@ from fastapi import FastAPI, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List +from datetime import datetime, timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel from app.core.config import get_db, settings from app.models import models @@ -22,6 +27,51 @@ app.add_middleware( allow_headers=["*"], ) +# Auth +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + user_id: int = None + +def verify_password(plain_password: str, hashed_password: str) -> bool: + if not hashed_password: + return False + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + password = password[:72] + + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: timedelta = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=30)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(models.User).filter(models.User.id == user_id).first() + if user is None: + raise credentials_exception + return user # Health check @app.get("/health") @@ -29,6 +79,23 @@ def health_check(): return {"status": "healthy"} +# ============ Auth API ============ + +@app.post("/auth/token", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.username == form_data.username).first() + if not user or not verify_password(form_data.password, user.hashed_password or ""): + raise HTTPException(status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}) + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + return {"access_token": access_token, "token_type": "bearer"} + +@app.get("/auth/me", response_model=schemas.UserResponse) +async def get_me(current_user: models.User = Depends(get_current_user)): + return current_user + + # ============ Issues API ============ @app.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) @@ -43,7 +110,7 @@ def create_issue(issue: schemas.IssueCreate, db: Session = Depends(get_db)): @app.get("/issues", response_model=List[schemas.IssueResponse]) def list_issues( project_id: int = None, - status: str = None, + issue_status: str = None, issue_type: str = None, skip: int = 0, limit: int = 100, @@ -53,8 +120,8 @@ def list_issues( if project_id: query = query.filter(models.Issue.project_id == project_id) - if status: - query = query.filter(models.Issue.status == status) + if issue_status: + query = query.filter(models.Issue.status == issue_status) if issue_type: query = query.filter(models.Issue.issue_type == issue_type) @@ -142,19 +209,13 @@ def get_project(project_id: int, db: Session = Depends(get_db)): @app.post("/users", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): - # Check if username or email exists existing = db.query(models.User).filter( (models.User.username == user.username) | (models.User.email == user.email) ).first() if existing: raise HTTPException(status_code=400, detail="Username or email already exists") - # Hash password if provided - hashed_password = None - if user.password: - from passlib.context import CryptContext - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - hashed_password = pwd_context.hash(user.password) + hashed_password = get_password_hash(user.password) if user.password else None db_user = models.User( username=user.username, @@ -188,3 +249,97 @@ def get_user(user_id: int, db: Session = Depends(get_db)): def startup(): from app.core.config import Base, engine Base.metadata.create_all(bind=engine) + + +# ============ Project Members API ============ + +@app.post("/projects/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) +def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + user = db.query(models.User).filter(models.User.id == member.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + existing = db.query(models.ProjectMember).filter( + models.ProjectMember.project_id == project_id, + models.ProjectMember.user_id == member.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already a member") + db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role) + db.add(db_member) + db.commit() + db.refresh(db_member) + return db_member + + +@app.get("/projects/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) +def list_project_members(project_id: int, db: Session = Depends(get_db)): + members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() + return members + + +@app.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): + member = db.query(models.ProjectMember).filter( + models.ProjectMember.project_id == project_id, + models.ProjectMember.user_id == user_id + ).first() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + db.delete(member) + db.commit() + return None + + +# ============ System API ============ + +@app.get("/version") +def version(): + return { + "name": "HarborForge", + "version": "0.1.0", + "description": "Agent/人类协同任务管理平台" + } + + +# ============ Projects (update/delete) ============ + +@app.patch("/projects/{project_id}", response_model=schemas.ProjectResponse) +def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + update_data = project_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(project, field, value) + db.commit() + db.refresh(project) + return project + + +@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_project(project_id: int, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + db.delete(project) + db.commit() + return None + + +# ============ Users (update/delete) ============ + +@app.patch("/users/{user_id}", response_model=schemas.UserResponse) +def update_user(user_id: int, db: Session = Depends(get_db), full_name: str = None, email: str = None): + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if full_name is not None: + user.full_name = full_name + if email is not None: + user.email = email + db.commit() + db.refresh(user) + return user diff --git a/app/models/__pycache__/__init__.cpython-311.pyc b/app/models/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 91d35f5..0000000 Binary files a/app/models/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/app/models/__pycache__/models.cpython-311.pyc b/app/models/__pycache__/models.cpython-311.pyc deleted file mode 100644 index e58dabf..0000000 Binary files a/app/models/__pycache__/models.cpython-311.pyc and /dev/null differ diff --git a/app/schemas/__pycache__/__init__.cpython-311.pyc b/app/schemas/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 28a3700..0000000 Binary files a/app/schemas/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/app/schemas/__pycache__/schemas.cpython-311.pyc b/app/schemas/__pycache__/schemas.cpython-311.pyc deleted file mode 100644 index 35767b0..0000000 Binary files a/app/schemas/__pycache__/schemas.cpython-311.pyc and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 9623ca0..6689709 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.6 alembic==1.13.1 python-dotenv==1.0.0