From be5fc6e99e833d7a8d7c8ccbc805125dec79bcc3 Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 14:08:33 +0100 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BD=D0=B8=D1=84=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Pydantic?= =?UTF-8?q?=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен schemas.py с едиными моделями для API и WS - Заменены ручные dict-ы на helper функции во всех endpoints - Обновлены WS broadcasts для использования тех же схем - Все передаваемые данные теперь строго типизированы --- src/tracker/api/members.py | 36 ++++++++++-- src/tracker/api/project_files.py | 36 ++++++++++-- src/tracker/api/steps.py | 21 ++++++- src/tracker/schemas.py | 97 ++++++++++++++++++++++++++++++++ src/tracker/ws/handler.py | 44 ++++++++++----- 5 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 src/tracker/schemas.py diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py index 15cc27c..003d47c 100644 --- a/src/tracker/api/members.py +++ b/src/tracker/api/members.py @@ -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") diff --git a/src/tracker/api/project_files.py b/src/tracker/api/project_files.py index 7e7177f..f4be235 100644 --- a/src/tracker/api/project_files.py +++ b/src/tracker/api/project_files.py @@ -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}") diff --git a/src/tracker/api/steps.py b/src/tracker/api/steps.py index d9c5568..eca1359 100644 --- a/src/tracker/api/steps.py +++ b/src/tracker/api/steps.py @@ -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}") diff --git a/src/tracker/schemas.py b/src/tracker/schemas.py new file mode 100644 index 0000000..29e7816 --- /dev/null +++ b/src/tracker/schemas.py @@ -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 \ No newline at end of file diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index d91582a..2d30ebe 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -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