Строгая типизация: единые Pydantic-схемы и конвертеры для REST/WS
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s

- Добавлен api/schemas.py с MemberBrief, MemberOut, TaskOut, MessageOut, StepOut, ProjectFileOut, AttachmentOut
- Добавлен api/converters.py — единые ORM→Pydantic конвертеры
- Все REST endpoints используют response_model из schemas
- Все WS broadcasts используют .model_dump() вместо ручных dict-ов
- Удалены дублирующие локальные схемы из каждого модуля
This commit is contained in:
markov 2026-02-25 14:03:31 +01:00
parent a4fcfe91b3
commit 67eecc079f
9 changed files with 369 additions and 357 deletions

View File

@ -0,0 +1,122 @@
"""ORM → Pydantic converters. Single place for all model-to-schema transformations."""
from tracker.models import Attachment, Member, Message, ProjectFile, Step, Task
from tracker.enums import MemberType
from tracker.api.schemas import (
AgentConfigOut,
AttachmentOut,
MemberBrief,
MemberOut,
MessageOut,
ProjectFileOut,
StepOut,
TaskOut,
)
def member_brief(m: Member | None) -> MemberBrief | None:
if not m:
return None
return MemberBrief(id=str(m.id), slug=m.slug, name=m.name)
def member_out(m: Member) -> MemberOut:
agent_cfg = None
if m.agent_config:
agent_cfg = 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),
name=m.name,
slug=m.slug,
type=m.type,
role=m.role,
status=m.status,
avatar_url=m.avatar_url,
is_active=m.is_active if hasattr(m, "is_active") else True,
agent_config=agent_cfg,
token=m.token if m.type in (MemberType.AGENT, MemberType.BRIDGE) else None,
)
def attachment_out(a: Attachment) -> AttachmentOut:
return AttachmentOut(
id=str(a.id),
filename=a.filename,
mime_type=a.mime_type,
size=a.size,
)
def step_out(s: Step) -> StepOut:
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 "",
)
def message_out(m: Message) -> MessageOut:
return MessageOut(
id=str(m.id),
chat_id=str(m.chat_id) if m.chat_id else None,
task_id=str(m.task_id) if m.task_id else None,
parent_id=str(m.parent_id) if m.parent_id else None,
author_type=m.author_type,
author_id=str(m.author_id) if m.author_id else None,
author=member_brief(m.author) if m.author else None,
content=m.content,
mentions=m.mentions or [],
voice_url=m.voice_url,
attachments=[attachment_out(a) for a in (m.attachments or [])],
created_at=m.created_at.isoformat() if m.created_at else "",
)
def task_out(t: Task, project_slug: str = "") -> TaskOut:
prefix = project_slug[:2].upper() if project_slug else "XX"
return TaskOut(
id=str(t.id),
project_id=str(t.project_id),
parent_id=str(t.parent_id) if t.parent_id else None,
number=t.number,
key=f"{prefix}-{t.number}",
title=t.title,
description=t.description,
type=t.type,
status=t.status,
priority=t.priority,
labels=t.labels or [],
assignee_id=str(t.assignee_id) if t.assignee_id else None,
assignee=member_brief(t.assignee),
reviewer_id=str(t.reviewer_id) if t.reviewer_id else None,
reviewer=member_brief(t.reviewer),
watcher_ids=[str(w) for w in (t.watcher_ids or [])],
depends_on=[str(d) for d in (t.depends_on or [])],
position=t.position,
time_spent=t.time_spent,
steps=[step_out(s) for s in (t.steps or [])],
created_at=t.created_at.isoformat() if t.created_at else "",
updated_at=t.updated_at.isoformat() if t.updated_at else "",
)
def project_file_out(f: ProjectFile) -> ProjectFileOut:
return ProjectFileOut(
id=str(f.id),
filename=f.filename,
description=f.description,
mime_type=f.mime_type,
size=f.size,
uploaded_by=member_brief(f.uploader) if f.uploader else None,
created_at=f.created_at.isoformat() if f.created_at else "",
updated_at=f.updated_at.isoformat() if f.updated_at else "",
)

View File

@ -12,6 +12,9 @@ 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.api.schemas import AgentConfigOut, MemberOut
from tracker.api.converters import member_out
router = APIRouter(tags=["members"])
@ -26,21 +29,6 @@ class AgentConfigSchema(BaseModel):
model: str | None = None
class MemberOut(BaseModel):
id: str
name: str
slug: str
type: str
role: str
status: str
avatar_url: str | None = None
agent_config: AgentConfigSchema | None = None
token: str | None = None
class Config:
from_attributes = True
class MemberCreate(BaseModel):
name: str
slug: str
@ -68,31 +56,6 @@ class MemberUpdate(BaseModel):
agent_config: AgentConfigSchema | None = None
# --- Helpers ---
def _member_to_out(m: Member) -> dict:
d = {
"id": str(m.id),
"name": m.name,
"slug": m.slug,
"type": m.type,
"role": m.role,
"status": m.status,
"avatar_url": m.avatar_url,
}
if m.type == MemberType.AGENT:
d["token"] = m.token
if m.agent_config:
d["agent_config"] = AgentConfigSchema(
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 d
# --- Endpoints ---
@router.get("/members", response_model=list[MemberOut])
@ -106,7 +69,7 @@ async def list_members(
query = query.where(Member.is_active == True)
result = await db.execute(query)
return [_member_to_out(m) for m in result.scalars()]
return [member_out(m).model_dump() for m in result.scalars()]
@router.get("/members/{slug}", response_model=MemberOut)
@ -117,7 +80,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_to_out(member)
return member_out(member).model_dump()
@router.post("/members", response_model=MemberCreateResponse)
@ -156,17 +119,25 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
await db.commit()
resp = {
"id": str(member.id),
"name": member.name,
"slug": member.slug,
"type": member.type,
"role": member.role,
"token": token,
}
agent_cfg = None
if req.agent_config:
resp["agent_config"] = req.agent_config
return resp
agent_cfg = AgentConfigOut(
capabilities=req.agent_config.capabilities,
chat_listen=req.agent_config.chat_listen,
task_listen=req.agent_config.task_listen,
prompt=req.agent_config.prompt,
model=req.agent_config.model,
)
return MemberCreateResponse(
id=str(member.id),
name=member.name,
slug=member.slug,
type=member.type,
role=member.role,
token=token,
agent_config=agent_cfg,
).model_dump()
@router.patch("/members/{slug}", response_model=MemberOut)
@ -204,7 +175,7 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends
await db.commit()
await db.refresh(member)
return _member_to_out(member)
return member_out(member).model_dump()
@router.post("/members/{slug}/regenerate-token")

View File

@ -13,6 +13,8 @@ from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.enums import AuthorType, ChatKind
from tracker.models import Message, Chat, Attachment
from tracker.api.schemas import MessageOut
from tracker.api.converters import message_out
router = APIRouter(tags=["messages"])
@ -21,28 +23,6 @@ UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads")
# --- Schemas ---
class AttachmentOut(BaseModel):
id: str
filename: str
mime_type: str | None = None
size: int
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: dict | None = None # {id, slug, name} — None for system messages
content: str
mentions: list[str] = []
voice_url: str | None = None
attachments: list[AttachmentOut] = []
created_at: str
class AttachmentInput(BaseModel):
"""Uploaded file info from /upload endpoint."""
file_id: str # UUID from upload
@ -64,28 +44,6 @@ class MessageCreate(BaseModel):
attachments: list[AttachmentInput] = []
# --- Helpers ---
def _message_out(m: Message) -> dict:
return {
"id": str(m.id),
"chat_id": str(m.chat_id) if m.chat_id else None,
"task_id": str(m.task_id) if m.task_id else None,
"parent_id": str(m.parent_id) if m.parent_id else None,
"author_type": m.author_type,
"author_id": str(m.author_id) if m.author_id else None,
"author": {"id": str(m.author.id), "slug": m.author.slug, "name": m.author.name} if m.author else None,
"content": m.content,
"mentions": m.mentions or [],
"voice_url": m.voice_url,
"attachments": [
{"id": str(a.id), "filename": a.filename, "mime_type": a.mime_type, "size": a.size}
for a in (m.attachments or [])
],
"created_at": m.created_at.isoformat() if m.created_at else "",
}
# --- Endpoints ---
@router.get("/messages", response_model=list[MessageOut])
@ -113,7 +71,7 @@ async def list_messages(
# Get newest N messages (DESC), then reverse to chronological order
q = q.order_by(Message.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(q)
messages = [_message_out(m) for m in result.scalars()]
messages = [message_out(m).model_dump() for m in result.scalars()]
messages.reverse()
return messages
@ -163,27 +121,13 @@ async def create_message(req: MessageCreate, request: Request, db: AsyncSession
)
msg = result2.scalar_one()
# Get author name for broadcast (already loaded via relationship)
author_name = msg.author.name if msg.author else "System"
author_slug = msg.author.slug if msg.author else "system"
# Build response using shared converter
msg_data = _to_message_out(msg).model_dump()
# Broadcast via WebSocket
from tracker.ws.manager import manager
msg_data = {
"id": str(msg.id),
"chat_id": req.chat_id,
"task_id": req.task_id,
"author_type": msg.author_type,
"author_id": str(msg.author_id) if msg.author_id else None,
"author": {"id": str(msg.author.id), "slug": msg.author.slug, "name": msg.author.name} if msg.author else None,
"content": msg.content,
"mentions": msg.mentions or [],
"attachments": [
{"id": str(a.id), "filename": a.filename, "mime_type": a.mime_type, "size": a.size}
for a in (msg.attachments or [])
],
"created_at": msg.created_at.isoformat(),
}
project_id = None
if req.chat_id:
@ -196,7 +140,7 @@ async def create_message(req: MessageCreate, request: Request, db: AsyncSession
{"type": "message.new", "data": msg_data},
exclude_slug=author_slug,
)
return _message_out(msg)
return _to_message_out(msg)
if project_id:
await manager.broadcast_message(project_id, msg_data, author_slug=author_slug)
@ -206,7 +150,7 @@ async def create_message(req: MessageCreate, request: Request, db: AsyncSession
exclude_slug=author_slug,
)
return _message_out(msg)
return _to_message_out(msg)
@router.get("/messages/{message_id}/replies", response_model=list[MessageOut])
@ -218,4 +162,4 @@ async def list_replies(message_id: str, db: AsyncSession = Depends(get_db)):
.options(selectinload(Message.attachments), selectinload(Message.author))
.order_by(Message.created_at)
)
return [_message_out(m) for m in result.scalars()]
return [_to_message_out(m) for m in result.scalars()]

View File

@ -14,6 +14,8 @@ from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.models import ProjectFile, Project, Member
from tracker.api.schemas import ProjectFileOut
from tracker.api.converters import project_file_out
router = APIRouter(tags=["project-files"])
@ -23,18 +25,6 @@ PROJECTS_DIR = os.environ.get("PROJECTS_DIR", "/data/projects")
# --- Schemas ---
class ProjectFileOut(BaseModel):
id: str
filename: str
description: str | None = None
mime_type: str | None = None
size: int
uploaded_by: str
uploader: dict | None = None # Member object for display
created_at: str
updated_at: str
class ProjectFileUpdate(BaseModel):
description: str | None = None
@ -49,7 +39,6 @@ def _get_member(request: Request) -> Member:
async def _get_project(slug: str, db: AsyncSession) -> Project:
"""Get project by slug."""
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
@ -57,21 +46,6 @@ async def _get_project(slug: str, db: AsyncSession) -> Project:
return project
def _file_out(f: ProjectFile) -> dict:
"""Convert ProjectFile to dict for API response."""
return {
"id": str(f.id),
"filename": f.filename,
"description": f.description,
"mime_type": f.mime_type,
"size": f.size,
"uploaded_by": str(f.uploaded_by),
"uploader": {"id": str(f.uploader.id), "slug": f.uploader.slug, "name": f.uploader.name} if f.uploader else None,
"created_at": f.created_at.isoformat() if f.created_at else "",
"updated_at": f.updated_at.isoformat() if f.updated_at else "",
}
# --- Endpoints ---
@router.get("/projects/{slug}/files", response_model=list[ProjectFileOut])
@ -80,20 +54,17 @@ async def list_project_files(
search: Optional[str] = Query(None, description="Search by filename"),
db: AsyncSession = Depends(get_db),
):
"""Get list of project files with optional search by filename."""
project = await _get_project(slug, db)
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
if search:
# Search by filename (case insensitive)
q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
q = q.order_by(ProjectFile.created_at.desc())
result = await db.execute(q)
files = result.scalars().all()
return [_file_out(f) for f in files]
return [project_file_out(f).model_dump() for f in result.scalars().all()]
@router.post("/projects/{slug}/files", response_model=ProjectFileOut)
@ -104,11 +75,9 @@ async def upload_project_file(
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Upload a file to project. If file with same name exists, update it."""
member = _get_member(request)
project = await _get_project(slug, db)
# Read file
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
@ -116,21 +85,17 @@ async def upload_project_file(
if not file.filename:
raise HTTPException(400, "Filename is required")
# Sanitize filename — prevent path traversal
safe_filename = os.path.basename(file.filename).strip()
if not safe_filename or safe_filename.startswith('.'):
raise HTTPException(400, "Invalid filename")
# Create project directory if not exists
project_dir = os.path.join(PROJECTS_DIR, slug)
os.makedirs(project_dir, exist_ok=True)
# Verify resolved path is inside project dir
full_path = os.path.realpath(os.path.join(project_dir, safe_filename))
if not full_path.startswith(os.path.realpath(project_dir) + os.sep):
raise HTTPException(400, "Invalid filename")
# Check if file with same name already exists
result = await db.execute(
select(ProjectFile).where(
ProjectFile.project_id == project.id,
@ -139,7 +104,6 @@ async def upload_project_file(
)
existing_file = result.scalar_one_or_none()
# Save file to disk
storage_path = safe_filename
with open(full_path, "wb") as f:
@ -148,7 +112,6 @@ async def upload_project_file(
mime_type = file.content_type or mimetypes.guess_type(file.filename)[0]
if existing_file:
# Update existing file
existing_file.description = description
existing_file.mime_type = mime_type
existing_file.size = len(content)
@ -158,10 +121,9 @@ async def upload_project_file(
await db.commit()
await db.refresh(existing_file, ["uploader"])
return _file_out(existing_file)
return project_file_out(existing_file).model_dump()
else:
# Create new file record
project_file = ProjectFile(
pf = ProjectFile(
project_id=project.id,
filename=safe_filename,
description=description,
@ -171,11 +133,11 @@ async def upload_project_file(
storage_path=storage_path,
)
db.add(project_file)
db.add(pf)
await db.commit()
await db.refresh(project_file, ["uploader"])
await db.refresh(pf, ["uploader"])
return _file_out(project_file)
return project_file_out(pf).model_dump()
@router.get("/projects/{slug}/files/{file_id}")
@ -184,7 +146,6 @@ async def get_project_file(
file_id: str,
db: AsyncSession = Depends(get_db),
):
"""Get project file metadata."""
project = await _get_project(slug, db)
result = await db.execute(
@ -193,12 +154,12 @@ async def get_project_file(
ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader))
)
project_file = result.scalar_one_or_none()
pf = result.scalar_one_or_none()
if not project_file:
if not pf:
raise HTTPException(404, "File not found")
return _file_out(project_file)
return project_file_out(pf).model_dump()
@router.get("/projects/{slug}/files/{file_id}/download")
@ -208,7 +169,6 @@ async def download_project_file(
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Download project file."""
project = await _get_project(slug, db)
result = await db.execute(
@ -217,14 +177,13 @@ async def download_project_file(
ProjectFile.project_id == project.id
)
)
project_file = result.scalar_one_or_none()
pf = result.scalar_one_or_none()
if not project_file:
if not pf:
raise HTTPException(404, "File not found")
# Full path to file — validate it stays inside project dir
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
full_path = os.path.realpath(os.path.join(project_dir, project_file.storage_path))
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
if not full_path.startswith(project_dir + os.sep):
raise HTTPException(400, "Invalid file path")
@ -233,8 +192,8 @@ async def download_project_file(
return FileResponse(
full_path,
filename=project_file.filename,
media_type=project_file.mime_type or "application/octet-stream",
filename=pf.filename,
media_type=pf.mime_type or "application/octet-stream",
)
@ -246,7 +205,6 @@ async def update_project_file(
data: ProjectFileUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update project file description."""
member = _get_member(request)
project = await _get_project(slug, db)
@ -256,19 +214,18 @@ async def update_project_file(
ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader))
)
project_file = result.scalar_one_or_none()
pf = result.scalar_one_or_none()
if not project_file:
if not pf:
raise HTTPException(404, "File not found")
# Update description
if data.description is not None:
project_file.description = data.description
pf.description = data.description
await db.commit()
await db.refresh(project_file)
await db.refresh(pf)
return _file_out(project_file)
return project_file_out(pf).model_dump()
@router.delete("/projects/{slug}/files/{file_id}")
@ -278,7 +235,6 @@ async def delete_project_file(
file_id: str,
db: AsyncSession = Depends(get_db),
):
"""Delete project file from database and disk."""
member = _get_member(request)
project = await _get_project(slug, db)
@ -288,19 +244,17 @@ async def delete_project_file(
ProjectFile.project_id == project.id
)
)
project_file = result.scalar_one_or_none()
pf = result.scalar_one_or_none()
if not project_file:
if not pf:
raise HTTPException(404, "File not found")
# Delete file from disk — validate path
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
full_path = os.path.realpath(os.path.join(project_dir, project_file.storage_path))
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path):
os.remove(full_path)
# Delete from database
await db.delete(project_file)
await db.delete(pf)
await db.commit()
return {"ok": True}

View File

@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.enums import ChatKind, MemberRole, ProjectStatus
from tracker.models import Project, Chat, Member, ProjectMember
from tracker.api.schemas import ProjectOut
router = APIRouter(tags=["projects"])
@ -27,20 +28,6 @@ class ProjectUpdate(BaseModel):
status: str | None = None
class ProjectOut(BaseModel):
id: str
name: str
slug: str
description: str | None = None
repo_urls: list[str] = []
status: str
task_counter: int
chat_id: str | None = None
class Config:
from_attributes = True
class ProjectMemberAdd(BaseModel):
slug: str

115
src/tracker/api/schemas.py Normal file
View File

@ -0,0 +1,115 @@
"""Shared Pydantic schemas — single source of truth for all API responses."""
from pydantic import BaseModel
class MemberBrief(BaseModel):
"""Compact member reference for nested objects (author, assignee, reviewer, uploader)."""
id: str
slug: str
name: str
class AttachmentOut(BaseModel):
id: str
filename: str
mime_type: str | None = None
size: int
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
parent_id: str | None = None
number: int
key: str
title: str
description: str | None = None
type: str
status: str
priority: str
labels: list[str] = []
assignee_id: str | None = None
assignee: MemberBrief | None = None
reviewer_id: str | None = None
reviewer: MemberBrief | None = None
watcher_ids: list[str] = []
depends_on: list[str] = []
position: int
time_spent: int
steps: list[StepOut] = []
created_at: str
updated_at: str
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[AttachmentOut] = []
created_at: str
class AgentConfigOut(BaseModel):
capabilities: list[str] = []
chat_listen: str
task_listen: str
prompt: str | None = None
model: str | None = None
class MemberOut(BaseModel):
id: str
name: str
slug: str
type: str
role: str
status: str
avatar_url: str | None = None
is_active: bool = True
agent_config: AgentConfigOut | None = None
token: str | None = None
class Config:
from_attributes = True
class ProjectOut(BaseModel):
id: str
name: str
slug: str
description: str | None = None
repo_urls: list[str] = []
status: str
task_counter: int
chat_id: str | None = None
class Config:
from_attributes = True
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

@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from tracker.database import get_db
from tracker.models import Step, Task
from tracker.api.schemas import StepOut
from tracker.api.converters import step_out
router = APIRouter(tags=["steps"])
@ -22,40 +24,20 @@ class StepUpdate(BaseModel):
done: bool | None = None
class StepOut(BaseModel):
id: str
task_id: str
title: str
done: bool
position: int
def _step_out(s: Step) -> dict:
return {
"id": str(s.id),
"task_id": str(s.task_id),
"title": s.title,
"done": s.done,
"position": s.position,
}
@router.get("/tasks/{task_id}/steps", response_model=list[StepOut])
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) for s in result.scalars()]
return [step_out(s).model_dump() for s in result.scalars()]
@router.post("/tasks/{task_id}/steps", response_model=StepOut)
async def create_step(task_id: str, req: StepCreate, db: AsyncSession = Depends(get_db)):
# Verify task exists
task = await db.get(Task, uuid.UUID(task_id))
if not task:
raise HTTPException(404, "Task not found")
# Get max position
result = await db.execute(
select(func.coalesce(func.max(Step.position), -1)).where(Step.task_id == task.id)
)
@ -70,7 +52,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)
return step_out(step).model_dump()
@router.patch("/tasks/{task_id}/steps/{step_id}", response_model=StepOut)
@ -89,7 +71,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)
return step_out(step).model_dump()
@router.delete("/tasks/{task_id}/steps/{step_id}")

View File

@ -16,48 +16,14 @@ from tracker.enums import (
)
from tracker.models import Task, Step, Project, Member, Message, Chat, TaskAction
from tracker.api.auth import get_current_member
from tracker.api.schemas import TaskOut, MessageOut
from tracker.api.converters import task_out, message_out, member_brief
router = APIRouter(tags=["tasks"])
# --- Schemas ---
class MemberRef(BaseModel):
id: str
slug: str
name: str
class StepOut(BaseModel):
id: str
title: str
done: bool
position: int
class TaskOut(BaseModel):
id: str
project_id: str
parent_id: str | None = None
number: int
key: str
title: str
description: str | None = None
type: str
status: str
priority: str
labels: list[str] = []
assignee_id: str | None = None
assignee: MemberRef | None = None
reviewer_id: str | None = None
reviewer: MemberRef | None = None
watcher_ids: list[str] = []
depends_on: list[str] = []
position: int
time_spent: int
steps: list[StepOut] = []
class TaskCreate(BaseModel):
title: str
description: str | None = None
@ -93,12 +59,6 @@ class AssignRequest(BaseModel):
# --- Helpers ---
def _member_ref(m: Member | None) -> dict | None:
if not m:
return None
return {"id": str(m.id), "slug": m.slug, "name": m.name}
async def _get_project_chat_id(db: AsyncSession, project_id) -> uuid.UUID | None:
result = await db.execute(
select(Chat).where(Chat.project_id == project_id, Chat.kind == ChatKind.PROJECT)
@ -135,18 +95,18 @@ async def _system_message(
project_slug: str = "",
actor: Member | None = None,
):
"""Create system messages: one in project chat + one in task comments."""
"""Create system messages: one in project chat + one in task comments.
Uses MessageOut schema for WS broadcasts."""
from tracker.ws.manager import manager
prefix = project_slug[:2].upper() if project_slug else "XX"
key = f"{prefix}-{task.number}"
task_text = task_text or chat_text
now = datetime.datetime.now(datetime.timezone.utc)
chat_id = await _get_project_chat_id(db, task.project_id)
actor_slug = actor.slug if actor else "system"
# 1. Task comment (author=None for system, actor tracked in task_actions)
chat_id = await _get_project_chat_id(db, task.project_id)
# 1. Task comment
task_msg = Message(
task_id=task.id,
author_type=AuthorType.SYSTEM,
@ -170,60 +130,34 @@ async def _system_message(
await db.flush()
# Broadcast task comment
await manager.broadcast_task_event(str(task.project_id), WSEventType.MESSAGE_NEW, {
"id": str(task_msg.id),
"task_id": str(task.id),
"task_key": key,
"author_type": AuthorType.SYSTEM,
"author_id": None,
"author_slug": "system",
"author": None,
"content": task_text,
"created_at": task_msg.created_at.isoformat() if task_msg.created_at else now.isoformat(),
})
# Build MessageOut for task comment broadcast
task_msg_out = MessageOut(
id=str(task_msg.id),
task_id=str(task.id),
author_type=AuthorType.SYSTEM,
author_id=None,
author=None,
content=task_text,
mentions=[],
attachments=[],
created_at=task_msg.created_at.isoformat() if task_msg.created_at else datetime.datetime.now(datetime.timezone.utc).isoformat(),
)
await manager.broadcast_task_event(str(task.project_id), WSEventType.MESSAGE_NEW, task_msg_out.model_dump())
# Broadcast chat message
if chat_msg and chat_id:
await manager.broadcast_message(str(task.project_id), {
"id": str(chat_msg.id),
"chat_id": str(chat_id),
"author_type": AuthorType.SYSTEM,
"author_id": None,
"author_slug": "system",
"author": None,
"content": chat_text,
"created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(),
}, author_slug="system")
def _task_out(t: Task, project_slug: str = "") -> dict:
prefix = project_slug[:2].upper() if project_slug else "XX"
return {
"id": str(t.id),
"project_id": str(t.project_id),
"parent_id": str(t.parent_id) if t.parent_id else None,
"number": t.number,
"key": f"{prefix}-{t.number}",
"title": t.title,
"description": t.description,
"type": t.type,
"status": t.status,
"priority": t.priority,
"labels": t.labels or [],
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"assignee": _member_ref(t.assignee),
"reviewer_id": str(t.reviewer_id) if t.reviewer_id else None,
"reviewer": _member_ref(t.reviewer),
"watcher_ids": [str(w) for w in (t.watcher_ids or [])],
"depends_on": [str(d) for d in (t.depends_on or [])],
"position": t.position,
"time_spent": t.time_spent,
"steps": [
{"id": str(s.id), "title": s.title, "done": s.done, "position": s.position}
for s in (t.steps or [])
],
}
chat_msg_out = MessageOut(
id=str(chat_msg.id),
chat_id=str(chat_id),
author_type=AuthorType.SYSTEM,
author_id=None,
author=None,
content=chat_text,
mentions=[],
attachments=[],
created_at=chat_msg.created_at.isoformat() if chat_msg.created_at else datetime.datetime.now(datetime.timezone.utc).isoformat(),
)
await manager.broadcast_message(str(task.project_id), chat_msg_out.model_dump(), author_slug="system")
async def _get_task(task_id: str, db: AsyncSession) -> Task:
@ -277,13 +211,13 @@ async def list_tasks(
q = q.where(Task.labels.contains([label]))
q = q.order_by(Task.position, Task.created_at)
result = await db.execute(q)
return [_task_out(t, t.project.slug if t.project else "") for t in result.scalars()]
return [_to_task_out(t, t.project.slug if t.project else "") for t in result.scalars()]
@router.get("/tasks/{task_id}", response_model=TaskOut)
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
task = await _get_task(task_id, db)
return _task_out(task, task.project.slug if task.project else "")
return _to_task_out(task, task.project.slug if task.project else "")
@router.post("/tasks", response_model=TaskOut)
@ -320,15 +254,16 @@ async def create_task(
watcher_ids=[assignee_id] if assignee_id else [],
)
db.add(task)
await db.flush() # get task.id before recording action
await db.flush()
await _record_action(db, task, current_member, TaskActionType.CREATED)
await db.commit()
task_full = await _get_task(str(task.id), db)
# Broadcast
# Broadcast using TaskOut schema
from tracker.ws.manager import manager
task_schema = _to_task_out(task_full, project.slug)
key = f"{project.slug[:2].upper()}-{task.number}"
await manager.broadcast_task_event(str(project.id), WSEventType.TASK_CREATED, {
"id": str(task.id),
@ -338,7 +273,7 @@ async def create_task(
"title": task.title,
"status": task.status,
"assignee_id": str(assignee_id) if assignee_id else None,
"assignee": _member_ref(task_full.assignee),
"assignee": _to_member_brief(task_full.assignee).model_dump() if task_full.assignee else None,
})
# System message
@ -351,7 +286,7 @@ async def create_task(
project_slug=project.slug, actor=current_member)
await db.commit()
return _task_out(task_full, project.slug)
return _to_task_out(task_full, project.slug)
@router.patch("/tasks/{task_id}", response_model=TaskOut)
@ -368,7 +303,6 @@ async def update_task(
who = f"@{current_member.slug}"
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC")
# Track changes for audit + system messages
if req.title is not None and req.title != task.title:
await _record_action(db, task, current_member, TaskActionType.TITLE_CHANGED,
"title", task.title, req.title)
@ -419,7 +353,6 @@ async def update_task(
task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})",
project_slug=slug, actor=current_member,
)
# Add to watchers
if new_assignee_id not in (task.watcher_ids or []):
task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id]
else:
@ -447,17 +380,17 @@ async def update_task(
await db.commit()
await db.refresh(task)
# Broadcast
# Broadcast simplified update
from tracker.ws.manager import manager
task_full = await _get_task(task_id, db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, {
"id": str(task.id),
"status": task.status,
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee": _member_ref(task_full.assignee),
"assignee": _to_member_brief(task_full.assignee).model_dump() if task_full.assignee else None,
})
return _task_out(task_full, slug)
return _to_task_out(task_full, slug)
@router.delete("/tasks/{task_id}")
@ -514,14 +447,14 @@ async def take_task(
await db.commit()
from tracker.ws.manager import manager
task_full = await _get_task(task_id, db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id),
"assignee_id": str(current_member.id),
"assignee": _member_ref(current_member),
"assignee": _to_member_brief(current_member).model_dump(),
})
task_full = await _get_task(task_id, db)
return _task_out(task_full, proj_slug)
return _to_task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/reject")
@ -553,9 +486,10 @@ async def reject_task(
await db.commit()
from tracker.ws.manager import manager
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, {
"id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_id": None, "assignee": None,
})
task_full = await _get_task(task_id, db)
proj_slug = task.project.slug if task.project else ""
task_schema = task_out(task_full, proj_slug)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, task_schema.model_dump())
return {"ok": True, "reason": req.reason}
@ -590,14 +524,14 @@ async def assign_task(
await db.commit()
from tracker.ws.manager import manager
task_full = await _get_task(str(task.id), db)
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id),
"assignee_id": str(assignee.id),
"assignee": _member_ref(assignee),
"assignee": _to_member_brief(assignee).model_dump(),
})
task_full = await _get_task(str(task.id), db)
return _task_out(task_full, proj_slug)
return _to_task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/watch")

View File

@ -13,6 +13,8 @@ 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.ws.manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws")
@ -284,18 +286,19 @@ async def _handle_chat_send(session_id: str, data: dict):
await db.commit()
await db.refresh(msg)
msg_data = {
"id": str(msg.id),
"chat_id": chat_id,
"task_id": task_id,
"author_type": member.type,
"author_id": str(member.id),
"author": {"id": str(member.id), "slug": member.slug, "name": member.name},
"content": content,
"mentions": mentions,
"attachments": [],
"created_at": msg.created_at.isoformat(),
}
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()
# Determine project_id for filtering
project_id = None