Унифицированная типизация данных через Pydantic схемы
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
- Добавлен schemas.py с едиными моделями для API и WS - Заменены ручные dict-ы на helper функции во всех endpoints - Обновлены WS broadcasts для использования тех же схем - Все передаваемые данные теперь строго типизированы
This commit is contained in:
parent
67eecc079f
commit
be5fc6e99e
@ -12,7 +12,7 @@ from sqlalchemy.orm import selectinload
|
||||
from tracker.database import get_db
|
||||
from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType
|
||||
from tracker.models import Member, AgentConfig
|
||||
from tracker.schemas import MemberOut
|
||||
from tracker.schemas import MemberOut, AgentConfigOut
|
||||
from tracker.api.schemas import AgentConfigOut, MemberOut
|
||||
from tracker.api.converters import member_out
|
||||
|
||||
@ -56,6 +56,34 @@ class MemberUpdate(BaseModel):
|
||||
agent_config: AgentConfigSchema | None = None
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _to_member_out(m: Member, include_token: bool = False) -> MemberOut:
|
||||
"""Convert Member to MemberOut schema."""
|
||||
agent_config = None
|
||||
if m.agent_config:
|
||||
agent_config = AgentConfigOut(
|
||||
capabilities=m.agent_config.capabilities or [],
|
||||
chat_listen=m.agent_config.chat_listen,
|
||||
task_listen=m.agent_config.task_listen,
|
||||
prompt=m.agent_config.prompt,
|
||||
model=m.agent_config.model,
|
||||
)
|
||||
|
||||
return MemberOut(
|
||||
id=str(m.id),
|
||||
slug=m.slug,
|
||||
name=m.name,
|
||||
type=m.type,
|
||||
role=m.role,
|
||||
status=m.status,
|
||||
avatar_url=m.avatar_url,
|
||||
is_active=m.is_active,
|
||||
token=m.token if include_token and m.type == MemberType.AGENT else None,
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/members", response_model=list[MemberOut])
|
||||
@ -69,7 +97,7 @@ async def list_members(
|
||||
query = query.where(Member.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
return [member_out(m).model_dump() for m in result.scalars()]
|
||||
return [_to_member_out(m) for m in result.scalars()]
|
||||
|
||||
|
||||
@router.get("/members/{slug}", response_model=MemberOut)
|
||||
@ -80,7 +108,7 @@ async def get_member(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
return member_out(member).model_dump()
|
||||
return _to_member_out(member)
|
||||
|
||||
|
||||
@router.post("/members", response_model=MemberCreateResponse)
|
||||
@ -175,7 +203,7 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
return member_out(member).model_dump()
|
||||
return _to_member_out(member)
|
||||
|
||||
|
||||
@router.post("/members/{slug}/regenerate-token")
|
||||
|
||||
@ -14,8 +14,9 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from tracker.database import get_db
|
||||
from tracker.models import ProjectFile, Project, Member
|
||||
from tracker.schemas import ProjectFileOut, MemberBrief
|
||||
from tracker.api.schemas import ProjectFileOut
|
||||
from tracker.api.converters import project_file_out
|
||||
# Using unified schemas from schemas.py
|
||||
|
||||
router = APIRouter(tags=["project-files"])
|
||||
|
||||
@ -31,6 +32,29 @@ class ProjectFileUpdate(BaseModel):
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _to_member_brief(member: Member | None) -> MemberBrief | None:
|
||||
if not member:
|
||||
return None
|
||||
return MemberBrief(
|
||||
id=str(member.id),
|
||||
slug=member.slug,
|
||||
name=member.name
|
||||
)
|
||||
|
||||
|
||||
def _to_project_file_out(f: ProjectFile) -> ProjectFileOut:
|
||||
"""Convert ProjectFile to ProjectFileOut schema."""
|
||||
return ProjectFileOut(
|
||||
id=str(f.id),
|
||||
filename=f.filename,
|
||||
description=f.description,
|
||||
mime_type=f.mime_type,
|
||||
size=f.size,
|
||||
uploaded_by=_to_member_brief(f.uploader),
|
||||
created_at=f.created_at.isoformat() if f.created_at else "",
|
||||
updated_at=f.updated_at.isoformat() if f.updated_at else "",
|
||||
)
|
||||
|
||||
def _get_member(request: Request) -> Member:
|
||||
member = getattr(request.state, "member", None)
|
||||
if not member:
|
||||
@ -64,7 +88,7 @@ async def list_project_files(
|
||||
q = q.order_by(ProjectFile.created_at.desc())
|
||||
result = await db.execute(q)
|
||||
|
||||
return [project_file_out(f).model_dump() for f in result.scalars().all()]
|
||||
return [_to_project_file_out(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/projects/{slug}/files", response_model=ProjectFileOut)
|
||||
@ -121,7 +145,7 @@ async def upload_project_file(
|
||||
await db.commit()
|
||||
await db.refresh(existing_file, ["uploader"])
|
||||
|
||||
return project_file_out(existing_file).model_dump()
|
||||
return _to_project_file_out(existing_file)
|
||||
else:
|
||||
pf = ProjectFile(
|
||||
project_id=project.id,
|
||||
@ -137,7 +161,7 @@ async def upload_project_file(
|
||||
await db.commit()
|
||||
await db.refresh(pf, ["uploader"])
|
||||
|
||||
return project_file_out(pf).model_dump()
|
||||
return _to_project_file_out(pf)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}/files/{file_id}")
|
||||
@ -159,7 +183,7 @@ async def get_project_file(
|
||||
if not pf:
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return project_file_out(pf).model_dump()
|
||||
return _to_project_file_out(pf)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}/files/{file_id}/download")
|
||||
@ -225,7 +249,7 @@ async def update_project_file(
|
||||
await db.commit()
|
||||
await db.refresh(pf)
|
||||
|
||||
return project_file_out(pf).model_dump()
|
||||
return _to_project_file_out(pf)
|
||||
|
||||
|
||||
@router.delete("/projects/{slug}/files/{file_id}")
|
||||
|
||||
@ -9,12 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from tracker.database import get_db
|
||||
from tracker.models import Step, Task
|
||||
from tracker.schemas import StepOut
|
||||
from tracker.api.schemas import StepOut
|
||||
from tracker.api.converters import step_out
|
||||
|
||||
router = APIRouter(tags=["steps"])
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _to_step_out(s: Step) -> StepOut:
|
||||
"""Convert Step to StepOut schema."""
|
||||
return StepOut(
|
||||
id=str(s.id),
|
||||
task_id=str(s.task_id),
|
||||
title=s.title,
|
||||
done=s.done,
|
||||
position=s.position,
|
||||
created_at=s.created_at.isoformat() if s.created_at else ""
|
||||
)
|
||||
|
||||
|
||||
class StepCreate(BaseModel):
|
||||
title: str
|
||||
|
||||
@ -29,7 +44,7 @@ async def list_steps(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Step).where(Step.task_id == uuid.UUID(task_id)).order_by(Step.position)
|
||||
)
|
||||
return [step_out(s).model_dump() for s in result.scalars()]
|
||||
return [_to_step_out(s) for s in result.scalars()]
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/steps", response_model=StepOut)
|
||||
@ -52,7 +67,7 @@ async def create_step(task_id: str, req: StepCreate, db: AsyncSession = Depends(
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
return step_out(step).model_dump()
|
||||
return _to_step_out(step)
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}/steps/{step_id}", response_model=StepOut)
|
||||
@ -71,7 +86,7 @@ async def update_step(task_id: str, step_id: str, req: StepUpdate, db: AsyncSess
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
return step_out(step).model_dump()
|
||||
return _to_step_out(step)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}/steps/{step_id}")
|
||||
|
||||
97
src/tracker/schemas.py
Normal file
97
src/tracker/schemas.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Unified Pydantic schemas for consistent data serialization across REST API and WebSocket broadcasts."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MemberBrief(BaseModel):
|
||||
"""Краткий member для вложенных объектов (author, assignee, reviewer)"""
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class AgentConfigOut(BaseModel):
|
||||
capabilities: list[str] = []
|
||||
chat_listen: str = "mentions"
|
||||
task_listen: str = "mentions"
|
||||
prompt: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
type: str
|
||||
role: str
|
||||
status: str
|
||||
avatar_url: str | None = None
|
||||
is_active: bool
|
||||
token: str | None = None # Показывается только для агентов при создании
|
||||
agent_config: AgentConfigOut | None = None
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: str
|
||||
chat_id: str | None = None
|
||||
task_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
author_type: str
|
||||
author_id: str | None = None
|
||||
author: MemberBrief | None = None
|
||||
content: str
|
||||
mentions: list[str] = []
|
||||
voice_url: str | None = None
|
||||
attachments: list[dict] = []
|
||||
created_at: str
|
||||
|
||||
|
||||
class StepOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
title: str
|
||||
done: bool
|
||||
position: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
number: int
|
||||
key: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
type: str
|
||||
status: str
|
||||
priority: str
|
||||
labels: list[str] = []
|
||||
assignee: MemberBrief | None = None
|
||||
reviewer: MemberBrief | None = None
|
||||
assignee_id: str | None = None
|
||||
reviewer_id: str | None = None
|
||||
watcher_ids: list[str] = []
|
||||
depends_on: list[str] = []
|
||||
position: int
|
||||
time_spent: int = 0
|
||||
steps: list[StepOut] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
|
||||
|
||||
class ProjectFileOut(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
description: str | None = None
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
uploaded_by: MemberBrief | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@ -13,14 +13,40 @@ from tracker.enums import (
|
||||
ProjectStatus, WSEventType,
|
||||
)
|
||||
from tracker.models import Member, AgentConfig, Chat, Message, Project, ProjectMember
|
||||
from tracker.api.schemas import MessageOut
|
||||
from tracker.api.converters import member_brief
|
||||
from tracker.schemas import MessageOut, MemberBrief
|
||||
from tracker.ws.manager import ConnectedClient, manager
|
||||
|
||||
logger = logging.getLogger("tracker.ws")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _to_member_brief(member: Member) -> MemberBrief:
|
||||
return MemberBrief(
|
||||
id=str(member.id),
|
||||
slug=member.slug,
|
||||
name=member.name
|
||||
)
|
||||
|
||||
|
||||
def _to_message_out(msg: Message, author: Member | None = None) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=str(msg.id),
|
||||
chat_id=str(msg.chat_id) if msg.chat_id else None,
|
||||
task_id=str(msg.task_id) if msg.task_id else None,
|
||||
parent_id=str(msg.parent_id) if msg.parent_id else None,
|
||||
author_type=msg.author_type,
|
||||
author_id=str(msg.author_id) if msg.author_id else None,
|
||||
author=_to_member_brief(author) if author else None,
|
||||
content=msg.content,
|
||||
mentions=msg.mentions or [],
|
||||
voice_url=msg.voice_url,
|
||||
attachments=[], # WS broadcasts don't include full attachments
|
||||
created_at=msg.created_at.isoformat() if msg.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket, token: str = ""):
|
||||
await ws.accept()
|
||||
@ -286,19 +312,7 @@ async def _handle_chat_send(session_id: str, data: dict):
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
msg_out = MessageOut(
|
||||
id=str(msg.id),
|
||||
chat_id=chat_id,
|
||||
task_id=task_id,
|
||||
author_type=member.type,
|
||||
author_id=str(member.id),
|
||||
author=member_brief(member),
|
||||
content=content,
|
||||
mentions=mentions,
|
||||
attachments=[],
|
||||
created_at=msg.created_at.isoformat(),
|
||||
)
|
||||
msg_data = msg_out.model_dump()
|
||||
msg_data = _to_message_out(msg, member).model_dump()
|
||||
|
||||
# Determine project_id for filtering
|
||||
project_id = None
|
||||
|
||||
Loading…
Reference in New Issue
Block a user