типизация: единые схемы везде, убраны ручные dict-ы из broadcasts, удалён дублирующий schemas.py
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

This commit is contained in:
markov 2026-02-25 14:14:29 +01:00
parent be5fc6e99e
commit d0c0c87120
9 changed files with 50 additions and 154 deletions

View File

@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from tracker.database import get_db from tracker.database import get_db
from tracker.models import Attachment, Message, Member from tracker.models import Attachment, Message, Member
from tracker.api.schemas import UploadOut
router = APIRouter(tags=["attachments"]) router = APIRouter(tags=["attachments"])
@ -50,13 +51,13 @@ async def upload_file(
mime = file.content_type or mimetypes.guess_type(file.filename or "")[0] mime = file.content_type or mimetypes.guess_type(file.filename or "")[0]
return { return UploadOut(
"file_id": str(file_id), file_id=str(file_id),
"filename": file.filename, filename=file.filename or "",
"mime_type": mime, mime_type=mime,
"size": len(content), size=len(content),
"storage_name": storage_name, storage_name=storage_name,
} )
@router.get("/attachments/{attachment_id}/download") @router.get("/attachments/{attachment_id}/download")

View File

@ -12,8 +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, AgentConfigOut from tracker.api.schemas import MemberOut, AgentConfigOut, OkResponse
from tracker.api.schemas import AgentConfigOut, MemberOut
from tracker.api.converters import member_out from tracker.api.converters import member_out
router = APIRouter(tags=["members"]) router = APIRouter(tags=["members"])

View File

@ -14,7 +14,7 @@ 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, MemberBrief
from tracker.api.schemas import ProjectFileOut from tracker.api.schemas import ProjectFileOut
# Using unified schemas from schemas.py # Using unified schemas from schemas.py

View File

@ -43,22 +43,21 @@ class ProjectMemberOut(BaseModel):
from_attributes = True from_attributes = True
async def _project_out(p: Project, db: AsyncSession) -> dict: async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
# Get project chat
chat_result = await db.execute( chat_result = await db.execute(
select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT) select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT)
) )
chat = chat_result.scalar_one_or_none() chat = chat_result.scalar_one_or_none()
return { return ProjectOut(
"id": str(p.id), id=str(p.id),
"name": p.name, name=p.name,
"slug": p.slug, slug=p.slug,
"description": p.description, description=p.description,
"repo_urls": p.repo_urls or [], repo_urls=p.repo_urls or [],
"status": p.status, status=p.status,
"task_counter": p.task_counter, task_counter=p.task_counter,
"chat_id": str(chat.id) if chat else None, chat_id=str(chat.id) if chat else None,
} )
@router.get("/projects", response_model=list[ProjectOut]) @router.get("/projects", response_model=list[ProjectOut])

View File

@ -104,6 +104,19 @@ class ProjectOut(BaseModel):
from_attributes = True from_attributes = True
class UploadOut(BaseModel):
"""Response from file upload endpoint."""
file_id: str
filename: str
mime_type: str | None = None
size: int
storage_name: str
class OkResponse(BaseModel):
ok: bool = True
class ProjectFileOut(BaseModel): class ProjectFileOut(BaseModel):
id: str id: str
filename: str filename: str

View File

@ -9,7 +9,7 @@ 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.schemas import StepOut
from tracker.api.converters import step_out from tracker.api.converters import step_out

View File

@ -211,13 +211,13 @@ async def list_tasks(
q = q.where(Task.labels.contains([label])) q = q.where(Task.labels.contains([label]))
q = q.order_by(Task.position, Task.created_at) q = q.order_by(Task.position, Task.created_at)
result = await db.execute(q) result = await db.execute(q)
return [_to_task_out(t, t.project.slug if t.project else "") for t in result.scalars()] return [task_out(t, t.project.slug if t.project else "") for t in result.scalars()]
@router.get("/tasks/{task_id}", response_model=TaskOut) @router.get("/tasks/{task_id}", response_model=TaskOut)
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)): async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
return _to_task_out(task, task.project.slug if task.project else "") return task_out(task, task.project.slug if task.project else "")
@router.post("/tasks", response_model=TaskOut) @router.post("/tasks", response_model=TaskOut)
@ -263,18 +263,9 @@ async def create_task(
# Broadcast using TaskOut schema # Broadcast using TaskOut schema
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_schema = _to_task_out(task_full, project.slug) task_schema = task_out(task_full, project.slug)
key = f"{project.slug[:2].upper()}-{task.number}" key = f"{project.slug[:2].upper()}-{task.number}"
await manager.broadcast_task_event(str(project.id), WSEventType.TASK_CREATED, { await manager.broadcast_task_event(str(project.id), WSEventType.TASK_CREATED, task_schema.model_dump())
"id": str(task.id),
"project_id": str(project.id),
"number": task.number,
"key": key,
"title": task.title,
"status": task.status,
"assignee_id": str(assignee_id) if assignee_id else None,
"assignee": _to_member_brief(task_full.assignee).model_dump() if task_full.assignee else None,
})
# System message # System message
chat_text = f"{key} создана: {task.title}" chat_text = f"{key} создана: {task.title}"
@ -286,7 +277,7 @@ async def create_task(
project_slug=project.slug, actor=current_member) project_slug=project.slug, actor=current_member)
await db.commit() await db.commit()
return _to_task_out(task_full, project.slug) return task_out(task_full, project.slug)
@router.patch("/tasks/{task_id}", response_model=TaskOut) @router.patch("/tasks/{task_id}", response_model=TaskOut)
@ -383,14 +374,10 @@ async def update_task(
# Broadcast simplified update # Broadcast simplified update
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_full = await _get_task(task_id, db) task_full = await _get_task(task_id, db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, { task_schema = task_out(task_full, slug)
"id": str(task.id), await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, task_schema.model_dump())
"status": task.status,
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee": _to_member_brief(task_full.assignee).model_dump() if task_full.assignee else None,
})
return _to_task_out(task_full, slug) return task_schema
@router.delete("/tasks/{task_id}") @router.delete("/tasks/{task_id}")
@ -448,13 +435,10 @@ async def take_task(
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_full = await _get_task(task_id, db) task_full = await _get_task(task_id, db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, { task_schema = task_out(task_full, proj_slug)
"id": str(task.id), await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, task_schema.model_dump())
"assignee_id": str(current_member.id),
"assignee": _to_member_brief(current_member).model_dump(),
})
return _to_task_out(task_full, proj_slug) return task_schema
@router.post("/tasks/{task_id}/reject") @router.post("/tasks/{task_id}/reject")
@ -525,13 +509,10 @@ async def assign_task(
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_full = await _get_task(str(task.id), db) task_full = await _get_task(str(task.id), db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, { task_schema = task_out(task_full, proj_slug)
"id": str(task.id), await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, task_schema.model_dump())
"assignee_id": str(assignee.id),
"assignee": _to_member_brief(assignee).model_dump(),
})
return _to_task_out(task_full, proj_slug) return task_schema
@router.post("/tasks/{task_id}/watch") @router.post("/tasks/{task_id}/watch")

View File

@ -1,97 +0,0 @@
"""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,7 +13,7 @@ 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.schemas import MessageOut, MemberBrief from tracker.api.schemas import MessageOut, MemberBrief
from tracker.ws.manager import ConnectedClient, manager from tracker.ws.manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws") logger = logging.getLogger("tracker.ws")