Унифицированная типизация данных через Pydantic схемы
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

- Добавлен schemas.py с едиными моделями для API и WS
- Заменены ручные dict-ы на helper функции во всех endpoints
- Обновлены WS broadcasts для использования тех же схем
- Все передаваемые данные теперь строго типизированы
This commit is contained in:
markov 2026-02-25 14:08:33 +01:00
parent 67eecc079f
commit be5fc6e99e
5 changed files with 206 additions and 28 deletions

View File

@ -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")

View File

@ -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}")

View File

@ -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
View 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

View File

@ -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