Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist - Projects/Folders/Documents CRUD with soft deletes - Tags CRUD and assignment - FTS5 search with highlights and tag filtering - ADR-001, ADR-002, ADR-003 compliant - Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure) - 25 tests passing
This commit is contained in:
1
app/routers/__init__.py
Normal file
1
app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
167
app/routers/auth.py
Normal file
167
app/routers/auth.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.auth import AgentCreate, AgentLogin, AgentResponse, RefreshResponse, TokenResponse
|
||||
from app.services import auth as auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
COOKIE_NAME = "refresh_token"
|
||||
COOKIE_PATH = "/api/v1/auth/refresh"
|
||||
COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
secure=True, # True in production with HTTPS
|
||||
samesite="lax",
|
||||
path=COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(response: Response):
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value="",
|
||||
max_age=0,
|
||||
httponly=True,
|
||||
secure=True, # True in production with HTTPS
|
||||
samesite="lax",
|
||||
path=COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_agent(request: Request, db: AsyncSession) -> Agent:
|
||||
"""Get the current authenticated agent from request."""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
token = auth_header[7:]
|
||||
payload = auth_service.decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti:
|
||||
is_blocked = await auth_service.is_token_blocklisted(db, jti)
|
||||
if is_blocked:
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
|
||||
agent_id = payload.get("sub")
|
||||
if not agent_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||
|
||||
agent = await auth_service.get_agent_by_id(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=401, detail="Agent not found")
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
@router.post("/register", response_model=AgentResponse, status_code=201)
|
||||
async def register(payload: AgentCreate, db: AsyncSession = Depends(get_db)):
|
||||
existing = await auth_service.get_agent_by_username(db, payload.username)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
agent = await auth_service.create_agent(db, payload.username, payload.password)
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: AgentLogin,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await auth_service.get_agent_by_username(db, payload.username)
|
||||
if not agent or not auth_service.verify_password(payload.password, agent.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
|
||||
|
||||
refresh_token = auth_service.create_refresh_token()
|
||||
user_agent = request.headers.get("user-agent")
|
||||
client_ip = request.client.host if request.client else None
|
||||
await auth_service.save_refresh_token(db, agent.id, refresh_token, user_agent, client_ip)
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AgentResponse)
|
||||
async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
agent = await get_current_agent(request, db)
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="No refresh token")
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
result = await auth_service.rotate_refresh_token(db, token, user_agent, client_ip)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
new_rt, new_token = result
|
||||
|
||||
agent = await auth_service.get_agent_by_id(db, new_rt.user_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=401, detail="Agent not found")
|
||||
|
||||
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
|
||||
_set_refresh_cookie(response, new_token)
|
||||
return RefreshResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if token:
|
||||
await auth_service.revoke_refresh_token(db, token)
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
access_token = auth_header[7:]
|
||||
payload = auth_service.decode_token(access_token)
|
||||
if payload and "jti" in payload:
|
||||
exp = datetime.utcfromtimestamp(payload["exp"])
|
||||
await auth_service.add_to_blocklist(db, payload["jti"], exp)
|
||||
|
||||
_clear_refresh_cookie(response)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/logout-all", status_code=204)
|
||||
async def logout_all(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
agent = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
access_token = auth_header[7:]
|
||||
payload = auth_service.decode_token(access_token)
|
||||
if payload and "sub" in payload:
|
||||
agent = await auth_service.get_agent_by_id(db, payload["sub"])
|
||||
|
||||
if agent:
|
||||
await auth_service.revoke_all_user_tokens(db, agent.id)
|
||||
if payload and "jti" in payload:
|
||||
exp = datetime.utcfromtimestamp(payload["exp"])
|
||||
await auth_service.add_to_blocklist(db, payload["jti"], exp)
|
||||
|
||||
_clear_refresh_cookie(response)
|
||||
return Response(status_code=204)
|
||||
435
app/routers/documents.py
Normal file
435
app/routers/documents.py
Normal file
@@ -0,0 +1,435 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.document import Document
|
||||
from app.models.folder import Folder
|
||||
from app.models.project import Project
|
||||
from app.models.tag import DocumentTag, Tag
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.document import (
|
||||
DocumentBriefResponse,
|
||||
DocumentContentUpdate,
|
||||
DocumentCreate,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
DocumentUpdate,
|
||||
TagInfo,
|
||||
)
|
||||
from app.schemas.tag import DocumentTagsAssign
|
||||
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
|
||||
def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_path: str | None) -> str:
|
||||
if folder_id and folder_path:
|
||||
return f"{folder_path}/{doc_id}"
|
||||
return f"/{project_id}/{doc_id}"
|
||||
|
||||
|
||||
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM active_tags t
|
||||
JOIN document_tags dt ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = :doc_id
|
||||
"""),
|
||||
{"doc_id": doc_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [TagInfo(id=r.id, name=r.name, color=r.color) for r in rows]
|
||||
|
||||
|
||||
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
return DocumentResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
content=doc.content,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.project_id == project_id,
|
||||
Document.is_deleted == False,
|
||||
).order_by(Document.created_at.desc())
|
||||
)
|
||||
docs = result.scalars().all()
|
||||
|
||||
responses = []
|
||||
for doc in docs:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
responses.append(DocumentBriefResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
))
|
||||
|
||||
return DocumentListResponse(documents=responses)
|
||||
|
||||
|
||||
@router.post("/api/v1/projects/{project_id}/documents", response_model=DocumentResponse, status_code=201)
|
||||
async def create_document(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
folder_path = None
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
folder_path = folder.path
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
path = build_doc_path(project_id, doc_id, payload.folder_id, folder_path)
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
project_id=project_id,
|
||||
folder_id=payload.folder_id,
|
||||
path=path,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if payload.title is not None:
|
||||
doc.title = payload.title
|
||||
if payload.folder_id is not None:
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == doc.project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
doc.path = f"{folder.path}/{doc.id}"
|
||||
else:
|
||||
doc.path = f"/{doc.project_id}/{doc.id}"
|
||||
doc.folder_id = payload.folder_id
|
||||
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}", status_code=204)
|
||||
async def delete_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = True
|
||||
doc.deleted_at = datetime.utcnow()
|
||||
doc.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}/content", response_model=DocumentResponse)
|
||||
async def update_document_content(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentContentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.content = payload.content
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == True,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = False
|
||||
doc.deleted_at = None
|
||||
doc.deleted_by = None
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/tags", status_code=204)
|
||||
async def assign_tags(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentTagsAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
for tag_id in payload.tag_ids:
|
||||
tag_result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.id == tag_id,
|
||||
Tag.is_deleted == False,
|
||||
)
|
||||
)
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=400, detail=f"Tag {tag_id} not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
dt = DocumentTag(document_id=document_id, tag_id=tag_id)
|
||||
db.add(dt)
|
||||
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}/tags/{tag_id}", status_code=204)
|
||||
async def remove_tag(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
await db.execute(
|
||||
delete(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return None
|
||||
251
app/routers/folders.py
Normal file
251
app/routers/folders.py
Normal file
@@ -0,0 +1,251 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.project import Project
|
||||
from app.schemas.folder import FolderCreate, FolderListResponse, FolderResponse, FolderUpdate
|
||||
from app.routers.auth import get_current_agent
|
||||
|
||||
router = APIRouter(tags=["folders"])
|
||||
|
||||
|
||||
def build_folder_path(project_id: str, folder_id: str, parent_id: str | None, parent_path: str | None) -> str:
|
||||
if parent_id and parent_path:
|
||||
return f"{parent_path}/{folder_id}"
|
||||
return f"/{project_id}/{folder_id}"
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_id}/folders", response_model=FolderListResponse)
|
||||
async def list_folders(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
parent_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
query = select(Folder).where(
|
||||
Folder.project_id == project_id,
|
||||
Folder.parent_id == parent_id,
|
||||
Folder.is_deleted == False,
|
||||
).order_by(Folder.name)
|
||||
|
||||
result = await db.execute(query)
|
||||
folders = result.scalars().all()
|
||||
return FolderListResponse(folders=[FolderResponse.model_validate(f) for f in folders])
|
||||
|
||||
|
||||
@router.post("/api/v1/projects/{project_id}/folders", response_model=FolderResponse, status_code=201)
|
||||
async def create_folder(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
parent_path = None
|
||||
if payload.parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.parent_id,
|
||||
Folder.project_id == project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="Parent folder not found")
|
||||
parent_path = parent.path
|
||||
|
||||
folder_id = str(uuid.uuid4())
|
||||
path = build_folder_path(project_id, folder_id, payload.parent_id, parent_path)
|
||||
|
||||
folder = Folder(
|
||||
id=folder_id,
|
||||
name=payload.name,
|
||||
project_id=project_id,
|
||||
parent_id=payload.parent_id,
|
||||
path=path,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.get("/api/v1/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def get_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.put("/api/v1/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def update_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
payload: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if payload.name is not None:
|
||||
folder.name = payload.name
|
||||
if payload.parent_id is not None:
|
||||
if payload.parent_id == folder_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot set folder as its own parent")
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.parent_id,
|
||||
Folder.project_id == folder.project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="Parent folder not found")
|
||||
folder.parent_id = payload.parent_id
|
||||
folder.path = f"{parent.path}/{folder.id}"
|
||||
folder.updated_at = datetime.utcnow()
|
||||
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.delete("/api/v1/folders/{folder_id}", status_code=204)
|
||||
async def delete_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
folder.is_deleted = True
|
||||
folder.deleted_at = datetime.utcnow()
|
||||
folder.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/v1/folders/{folder_id}/restore", response_model=FolderResponse)
|
||||
async def restore_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == True,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
folder.is_deleted = False
|
||||
folder.deleted_at = None
|
||||
folder.deleted_by = None
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
146
app/routers/projects.py
Normal file
146
app/routers/projects.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectListResponse, ProjectResponse, ProjectUpdate
|
||||
from app.routers.auth import get_current_agent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectListResponse)
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
).order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
return ProjectListResponse(projects=[ProjectResponse.model_validate(p) for p in projects])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=201)
|
||||
async def create_project(
|
||||
request: Request,
|
||||
payload: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
agent_id=agent.id,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
project.updated_at = datetime.utcnow()
|
||||
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
async def delete_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project.is_deleted = True
|
||||
project.deleted_at = datetime.utcnow()
|
||||
project.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{project_id}/restore", response_model=ProjectResponse)
|
||||
async def restore_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == True,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project.is_deleted = False
|
||||
project.deleted_at = None
|
||||
project.deleted_by = None
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
36
app/routers/search.py
Normal file
36
app/routers/search.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.search import SearchResponse
|
||||
from app.services.search import search_documents
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
async def search(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=1),
|
||||
project_id: str | None = Query(None),
|
||||
tags: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
tag_list = None
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
return await search_documents(
|
||||
db=db,
|
||||
query=q,
|
||||
agent_id=agent.id,
|
||||
project_id=project_id,
|
||||
tags=tag_list,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
79
app/routers/tags.py
Normal file
79
app/routers/tags.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tag import Tag
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.tag import TagCreate, TagListResponse, TagResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=TagListResponse)
|
||||
async def list_tags(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.is_deleted == False,
|
||||
).order_by(Tag.name)
|
||||
)
|
||||
tags = result.scalars().all()
|
||||
return TagListResponse(tags=[TagResponse.model_validate(t) for t in tags])
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=201)
|
||||
async def create_tag(
|
||||
request: Request,
|
||||
payload: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
existing = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.name == payload.name,
|
||||
Tag.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Tag with this name already exists")
|
||||
|
||||
tag = Tag(
|
||||
id=str(uuid.uuid4()),
|
||||
name=payload.name,
|
||||
color=payload.color,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post("/{tag_id}/restore", response_model=TagResponse)
|
||||
async def restore_tag(
|
||||
request: Request,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.id == tag_id,
|
||||
Tag.is_deleted == True,
|
||||
)
|
||||
)
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag.is_deleted = False
|
||||
tag.deleted_at = None
|
||||
tag.deleted_by = None
|
||||
await db.flush()
|
||||
return TagResponse.model_validate(tag)
|
||||
Reference in New Issue
Block a user