feat: add project

This commit is contained in:
qixinbo
2026-03-16 16:12:35 +08:00
parent 1354a0cbc6
commit cec5fde098
23 changed files with 990 additions and 179 deletions
+25 -38
View File
@@ -1,57 +1,35 @@
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.datasource import DataSource
from app.schemas.datasource import DataSourceCreate, DataSourceUpdate, DataSource as DataSourceSchema, DataSourceTestRequest
from app.core.security import SECRET_KEY, ALGORITHM
from app.core.security import get_current_user, get_admin_user, CurrentUser
from app.connectors.factory import get_connector_from_config
from pydantic import BaseModel
router = APIRouter()
security = HTTPBearer()
class CurrentUser(BaseModel):
id: int
username: str
is_admin: bool = False
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise unauthorized
user_id = payload.get("id")
username = payload.get("sub")
is_admin = bool(payload.get("is_admin", False))
if user_id is None or username is None:
raise unauthorized
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
@router.get("/datasources", response_model=List[DataSourceSchema])
def list_datasources(
project_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
# Admin can see all, regular user might only see allowed ones?
# For now, let's assume only admin can manage, but maybe regular users can see them to use?
# The requirement says "Add data source config in Admin User Center", implying management is admin-only.
# But usage in chat should be available to users.
# Let's allow read for all authenticated users for now.
datasources = db.query(DataSource).offset(skip).limit(limit).all()
query = db.query(DataSource)
if project_id:
query = query.filter(DataSource.project_id == project_id)
# If not admin, check if user has access to the project
if not current_user.is_admin and project_id:
from app.models.project import Project
project = db.query(Project).filter(Project.id == project_id).first()
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions for this project")
datasources = query.offset(skip).limit(limit).all()
# Hide sensitive info for non-admins if necessary, but config usually contains secrets.
# Maybe we should return a sanitized version for regular users?
@@ -75,8 +53,17 @@ def list_datasources(
def create_datasource(
datasource: DataSourceCreate,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_admin_user)
current_user: CurrentUser = Depends(get_current_user)
):
# Check if project exists and user has access
from app.models.project import Project
project = db.query(Project).filter(Project.id == datasource.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions for this project")
db_datasource = DataSource(**datasource.dict())
db.add(db_datasource)
db.commit()
+92
View File
@@ -0,0 +1,92 @@
from typing import List
from fastapi import APIRouter, HTTPException, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate, Project as ProjectSchema
from app.core.security import get_current_user, CurrentUser
router = APIRouter()
@router.get("/projects", response_model=List[ProjectSchema])
def list_projects(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
# Users can only see their own projects, unless they are admin (who can see all?)
# For simplicity, let's allow users to see their own projects.
query = db.query(Project)
if not current_user.is_admin:
query = query.filter(Project.owner_id == current_user.id)
projects = query.offset(skip).limit(limit).all()
return projects
@router.post("/projects", response_model=ProjectSchema)
def create_project(
project: ProjectCreate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_project = Project(**project.dict(), owner_id=current_user.id)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.get("/projects/{project_id}", response_model=ProjectSchema)
def read_project(
project_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
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 not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
return db_project
@router.put("/projects/{project_id}", response_model=ProjectSchema)
def update_project(
project_id: int,
project: ProjectUpdate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
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 not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
project_data = project.dict(exclude_unset=True)
for key, value in project_data.items():
setattr(db_project, key, value)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.delete("/projects/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
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 not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
db.delete(db_project)
db.commit()
return {"status": "success"}
+33 -10
View File
@@ -14,6 +14,7 @@ class Skill(BaseModel):
description: Optional[str] = Field(None, description="Description of what the skill does")
content: str = Field(..., description="The content/prompt/logic of the skill")
type: str = Field("python", description="Type of the skill (python, sql, api)")
project_id: Optional[int] = Field(None, description="The ID of the project this skill belongs to")
class SkillCreate(BaseModel):
id: str
@@ -21,12 +22,14 @@ class SkillCreate(BaseModel):
description: Optional[str] = None
content: str
type: str = "python"
project_id: Optional[int] = None
class SkillUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
content: Optional[str] = None
type: Optional[str] = None
project_id: Optional[int] = None
def _load_data() -> List[Dict[str, Any]]:
if not os.path.exists(DATA_FILE):
@@ -42,19 +45,24 @@ def _save_data(data: List[Dict[str, Any]]):
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
def load_skills() -> List[Dict[str, Any]]:
return _load_data()
def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
data = _load_data()
if project_id is not None:
return [item for item in data if item.get("project_id") == project_id]
return data
@router.get("/skills", response_model=List[Skill])
def list_skills():
data = load_skills()
def list_skills(project_id: Optional[int] = None):
data = load_skills(project_id)
return [Skill(**item) for item in data]
@router.get("/skills/{skill_id}", response_model=Skill)
def get_skill(skill_id: str):
def get_skill(skill_id: str, project_id: Optional[int] = None):
data = _load_data()
for item in data:
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
continue
return Skill(**item)
raise HTTPException(status_code=404, detail="Skill not found")
@@ -70,10 +78,12 @@ def create_skill(skill: SkillCreate):
return Skill(**new_skill)
@router.put("/skills/{skill_id}", response_model=Skill)
def update_skill(skill_id: str, skill: SkillUpdate):
def update_skill(skill_id: str, skill: SkillUpdate, project_id: Optional[int] = None):
data = _load_data()
for i, item in enumerate(data):
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
continue
updated_item = item.copy()
update_data = skill.dict(exclude_unset=True)
updated_item.update(update_data)
@@ -83,11 +93,24 @@ def update_skill(skill_id: str, skill: SkillUpdate):
raise HTTPException(status_code=404, detail="Skill not found")
@router.delete("/skills/{skill_id}")
def delete_skill(skill_id: str):
def delete_skill(skill_id: str, project_id: Optional[int] = None):
data = _load_data()
initial_len = len(data)
data = [item for item in data if item["id"] != skill_id]
if len(data) == initial_len:
# If project_id is provided, we only delete if it matches
new_data = []
found = False
for item in data:
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
new_data.append(item)
continue
found = True
else:
new_data.append(item)
if not found:
raise HTTPException(status_code=404, detail="Skill not found")
_save_data(data)
_save_data(new_data)
return {"message": "Skill deleted successfully"}
+31 -1
View File
@@ -1,13 +1,22 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
SECRET_KEY = "your-super-secret-key-for-dataclaw" # In production, use env variable
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
security = HTTPBearer()
class CurrentUser(BaseModel):
id: int
username: str
is_admin: bool = False
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
@@ -24,3 +33,24 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise unauthorized
user_id = payload.get("id")
username = payload.get("sub")
is_admin = bool(payload.get("is_admin", False))
if user_id is None or username is None:
raise unauthorized
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
+5 -1
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, JSON, DateTime, func
from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class DataSource(Base):
@@ -8,5 +9,8 @@ class DataSource(Base):
name = Column(String, nullable=False)
type = Column(String, nullable=False)
config = Column(JSON, nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
project = relationship("Project", back_populates="data_sources")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="projects")
data_sources = relationship("DataSource", back_populates="project", cascade="all, delete-orphan")
+3
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -12,3 +13,5 @@ class User(Base):
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
projects = relationship("Project", back_populates="owner")
+1
View File
@@ -6,6 +6,7 @@ class DataSourceBase(BaseModel):
name: str
type: str # sqlite, postgres, clickhouse, supabase, parquet
config: Dict[str, Any]
project_id: int
class DataSourceCreate(DataSourceBase):
pass
+23
View File
@@ -0,0 +1,23 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ProjectBase(BaseModel):
name: str
description: Optional[str] = None
class ProjectCreate(ProjectBase):
pass
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class Project(ProjectBase):
id: int
owner_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True