Унифицированная типизация данных через 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.database import get_db
|
||||||
from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType
|
from tracker.enums import AuthMethod, ListenMode, MemberRole, MemberStatus, MemberType
|
||||||
from tracker.models import Member, AgentConfig
|
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.schemas import AgentConfigOut, MemberOut
|
||||||
from tracker.api.converters import member_out
|
from tracker.api.converters import member_out
|
||||||
|
|
||||||
@ -56,6 +56,34 @@ class MemberUpdate(BaseModel):
|
|||||||
agent_config: AgentConfigSchema | None = None
|
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 ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.get("/members", response_model=list[MemberOut])
|
@router.get("/members", response_model=list[MemberOut])
|
||||||
@ -69,7 +97,7 @@ async def list_members(
|
|||||||
query = query.where(Member.is_active == True)
|
query = query.where(Member.is_active == True)
|
||||||
|
|
||||||
result = await db.execute(query)
|
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)
|
@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()
|
member = result.scalar_one_or_none()
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(404, "Member not found")
|
raise HTTPException(404, "Member not found")
|
||||||
return member_out(member).model_dump()
|
return _to_member_out(member)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/members", response_model=MemberCreateResponse)
|
@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.commit()
|
||||||
await db.refresh(member)
|
await db.refresh(member)
|
||||||
return member_out(member).model_dump()
|
return _to_member_out(member)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/members/{slug}/regenerate-token")
|
@router.post("/members/{slug}/regenerate-token")
|
||||||
|
|||||||
@ -14,8 +14,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from tracker.database import get_db
|
from tracker.database import get_db
|
||||||
from tracker.models import ProjectFile, Project, Member
|
from tracker.models import ProjectFile, Project, Member
|
||||||
|
from tracker.schemas import ProjectFileOut, MemberBrief
|
||||||
from tracker.api.schemas import ProjectFileOut
|
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"])
|
router = APIRouter(tags=["project-files"])
|
||||||
|
|
||||||
@ -31,6 +32,29 @@ class ProjectFileUpdate(BaseModel):
|
|||||||
|
|
||||||
# --- Helpers ---
|
# --- 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:
|
def _get_member(request: Request) -> Member:
|
||||||
member = getattr(request.state, "member", None)
|
member = getattr(request.state, "member", None)
|
||||||
if not member:
|
if not member:
|
||||||
@ -64,7 +88,7 @@ async def list_project_files(
|
|||||||
q = q.order_by(ProjectFile.created_at.desc())
|
q = q.order_by(ProjectFile.created_at.desc())
|
||||||
result = await db.execute(q)
|
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)
|
@router.post("/projects/{slug}/files", response_model=ProjectFileOut)
|
||||||
@ -121,7 +145,7 @@ async def upload_project_file(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing_file, ["uploader"])
|
await db.refresh(existing_file, ["uploader"])
|
||||||
|
|
||||||
return project_file_out(existing_file).model_dump()
|
return _to_project_file_out(existing_file)
|
||||||
else:
|
else:
|
||||||
pf = ProjectFile(
|
pf = ProjectFile(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
@ -137,7 +161,7 @@ async def upload_project_file(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(pf, ["uploader"])
|
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}")
|
@router.get("/projects/{slug}/files/{file_id}")
|
||||||
@ -159,7 +183,7 @@ async def get_project_file(
|
|||||||
if not pf:
|
if not pf:
|
||||||
raise HTTPException(404, "File not found")
|
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")
|
@router.get("/projects/{slug}/files/{file_id}/download")
|
||||||
@ -225,7 +249,7 @@ async def update_project_file(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(pf)
|
await db.refresh(pf)
|
||||||
|
|
||||||
return project_file_out(pf).model_dump()
|
return _to_project_file_out(pf)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{slug}/files/{file_id}")
|
@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.database import get_db
|
||||||
from tracker.models import Step, Task
|
from tracker.models import Step, Task
|
||||||
|
from tracker.schemas import StepOut
|
||||||
from tracker.api.schemas import StepOut
|
from tracker.api.schemas import StepOut
|
||||||
from tracker.api.converters import step_out
|
from tracker.api.converters import step_out
|
||||||
|
|
||||||
router = APIRouter(tags=["steps"])
|
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):
|
class StepCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
@ -29,7 +44,7 @@ async def list_steps(task_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Step).where(Step.task_id == uuid.UUID(task_id)).order_by(Step.position)
|
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)
|
@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)
|
db.add(step)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(step)
|
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)
|
@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.commit()
|
||||||
await db.refresh(step)
|
await db.refresh(step)
|
||||||
return step_out(step).model_dump()
|
return _to_step_out(step)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}/steps/{step_id}")
|
@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,
|
ProjectStatus, WSEventType,
|
||||||
)
|
)
|
||||||
from tracker.models import Member, AgentConfig, Chat, Message, Project, ProjectMember
|
from tracker.models import Member, AgentConfig, Chat, Message, Project, ProjectMember
|
||||||
from tracker.api.schemas import MessageOut
|
from tracker.schemas import MessageOut, MemberBrief
|
||||||
from tracker.api.converters import member_brief
|
|
||||||
from tracker.ws.manager import ConnectedClient, manager
|
from tracker.ws.manager import ConnectedClient, manager
|
||||||
|
|
||||||
logger = logging.getLogger("tracker.ws")
|
logger = logging.getLogger("tracker.ws")
|
||||||
router = APIRouter()
|
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")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(ws: WebSocket, token: str = ""):
|
async def websocket_endpoint(ws: WebSocket, token: str = ""):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
@ -286,19 +312,7 @@ async def _handle_chat_send(session_id: str, data: dict):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(msg)
|
await db.refresh(msg)
|
||||||
|
|
||||||
msg_out = MessageOut(
|
msg_data = _to_message_out(msg, member).model_dump()
|
||||||
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()
|
|
||||||
|
|
||||||
# Determine project_id for filtering
|
# Determine project_id for filtering
|
||||||
project_id = None
|
project_id = None
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user