Строгая типизация: единые 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.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.api.schemas import AgentConfigOut, MemberOut
from tracker.api.converters import member_out
router = APIRouter(tags=["members"]) router = APIRouter(tags=["members"])
@ -26,21 +29,6 @@ class AgentConfigSchema(BaseModel):
model: 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
agent_config: AgentConfigSchema | None = None
token: str | None = None
class Config:
from_attributes = True
class MemberCreate(BaseModel): class MemberCreate(BaseModel):
name: str name: str
slug: str slug: str
@ -68,31 +56,6 @@ class MemberUpdate(BaseModel):
agent_config: AgentConfigSchema | None = None 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 --- # --- Endpoints ---
@router.get("/members", response_model=list[MemberOut]) @router.get("/members", response_model=list[MemberOut])
@ -106,7 +69,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_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) @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() 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_to_out(member) return member_out(member).model_dump()
@router.post("/members", response_model=MemberCreateResponse) @router.post("/members", response_model=MemberCreateResponse)
@ -156,17 +119,25 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
await db.commit() await db.commit()
resp = { agent_cfg = None
"id": str(member.id),
"name": member.name,
"slug": member.slug,
"type": member.type,
"role": member.role,
"token": token,
}
if req.agent_config: if req.agent_config:
resp["agent_config"] = req.agent_config agent_cfg = AgentConfigOut(
return resp 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) @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.commit()
await db.refresh(member) await db.refresh(member)
return _member_to_out(member) return member_out(member).model_dump()
@router.post("/members/{slug}/regenerate-token") @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.database import get_db
from tracker.enums import AuthorType, ChatKind from tracker.enums import AuthorType, ChatKind
from tracker.models import Message, Chat, Attachment from tracker.models import Message, Chat, Attachment
from tracker.api.schemas import MessageOut
from tracker.api.converters import message_out
router = APIRouter(tags=["messages"]) router = APIRouter(tags=["messages"])
@ -21,28 +23,6 @@ UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads")
# --- Schemas --- # --- 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): class AttachmentInput(BaseModel):
"""Uploaded file info from /upload endpoint.""" """Uploaded file info from /upload endpoint."""
file_id: str # UUID from upload file_id: str # UUID from upload
@ -64,28 +44,6 @@ class MessageCreate(BaseModel):
attachments: list[AttachmentInput] = [] 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 --- # --- Endpoints ---
@router.get("/messages", response_model=list[MessageOut]) @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 # Get newest N messages (DESC), then reverse to chronological order
q = q.order_by(Message.created_at.desc()).offset(offset).limit(limit) q = q.order_by(Message.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(q) 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() messages.reverse()
return messages return messages
@ -163,27 +121,13 @@ async def create_message(req: MessageCreate, request: Request, db: AsyncSession
) )
msg = result2.scalar_one() 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" 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 # Broadcast via WebSocket
from tracker.ws.manager import manager 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 project_id = None
if req.chat_id: if req.chat_id:
@ -196,7 +140,7 @@ async def create_message(req: MessageCreate, request: Request, db: AsyncSession
{"type": "message.new", "data": msg_data}, {"type": "message.new", "data": msg_data},
exclude_slug=author_slug, exclude_slug=author_slug,
) )
return _message_out(msg) return _to_message_out(msg)
if project_id: if project_id:
await manager.broadcast_message(project_id, msg_data, author_slug=author_slug) 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, exclude_slug=author_slug,
) )
return _message_out(msg) return _to_message_out(msg)
@router.get("/messages/{message_id}/replies", response_model=list[MessageOut]) @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)) .options(selectinload(Message.attachments), selectinload(Message.author))
.order_by(Message.created_at) .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.database import get_db
from tracker.models import ProjectFile, Project, Member 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"]) router = APIRouter(tags=["project-files"])
@ -23,18 +25,6 @@ PROJECTS_DIR = os.environ.get("PROJECTS_DIR", "/data/projects")
# --- Schemas --- # --- 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): class ProjectFileUpdate(BaseModel):
description: str | None = None description: str | None = None
@ -49,7 +39,6 @@ def _get_member(request: Request) -> Member:
async def _get_project(slug: str, db: AsyncSession) -> Project: async def _get_project(slug: str, db: AsyncSession) -> Project:
"""Get project by slug."""
result = await db.execute(select(Project).where(Project.slug == slug)) result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -57,21 +46,6 @@ async def _get_project(slug: str, db: AsyncSession) -> Project:
return 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 --- # --- Endpoints ---
@router.get("/projects/{slug}/files", response_model=list[ProjectFileOut]) @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"), search: Optional[str] = Query(None, description="Search by filename"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get list of project files with optional search by filename."""
project = await _get_project(slug, db) project = await _get_project(slug, db)
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader)) q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
if search: if search:
# Search by filename (case insensitive)
q = q.where(ProjectFile.filename.ilike(f"%{search}%")) q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
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)
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) @router.post("/projects/{slug}/files", response_model=ProjectFileOut)
@ -104,11 +75,9 @@ async def upload_project_file(
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Upload a file to project. If file with same name exists, update it."""
member = _get_member(request) member = _get_member(request)
project = await _get_project(slug, db) project = await _get_project(slug, db)
# Read file
content = await file.read() content = await file.read()
if len(content) > MAX_FILE_SIZE: if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)") 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: if not file.filename:
raise HTTPException(400, "Filename is required") raise HTTPException(400, "Filename is required")
# Sanitize filename — prevent path traversal
safe_filename = os.path.basename(file.filename).strip() safe_filename = os.path.basename(file.filename).strip()
if not safe_filename or safe_filename.startswith('.'): if not safe_filename or safe_filename.startswith('.'):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
# Create project directory if not exists
project_dir = os.path.join(PROJECTS_DIR, slug) project_dir = os.path.join(PROJECTS_DIR, slug)
os.makedirs(project_dir, exist_ok=True) 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)) full_path = os.path.realpath(os.path.join(project_dir, safe_filename))
if not full_path.startswith(os.path.realpath(project_dir) + os.sep): if not full_path.startswith(os.path.realpath(project_dir) + os.sep):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
# Check if file with same name already exists
result = await db.execute( result = await db.execute(
select(ProjectFile).where( select(ProjectFile).where(
ProjectFile.project_id == project.id, ProjectFile.project_id == project.id,
@ -139,7 +104,6 @@ async def upload_project_file(
) )
existing_file = result.scalar_one_or_none() existing_file = result.scalar_one_or_none()
# Save file to disk
storage_path = safe_filename storage_path = safe_filename
with open(full_path, "wb") as f: 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] mime_type = file.content_type or mimetypes.guess_type(file.filename)[0]
if existing_file: if existing_file:
# Update existing file
existing_file.description = description existing_file.description = description
existing_file.mime_type = mime_type existing_file.mime_type = mime_type
existing_file.size = len(content) existing_file.size = len(content)
@ -158,10 +121,9 @@ async def upload_project_file(
await db.commit() await db.commit()
await db.refresh(existing_file, ["uploader"]) await db.refresh(existing_file, ["uploader"])
return _file_out(existing_file) return project_file_out(existing_file).model_dump()
else: else:
# Create new file record pf = ProjectFile(
project_file = ProjectFile(
project_id=project.id, project_id=project.id,
filename=safe_filename, filename=safe_filename,
description=description, description=description,
@ -171,11 +133,11 @@ async def upload_project_file(
storage_path=storage_path, storage_path=storage_path,
) )
db.add(project_file) db.add(pf)
await db.commit() 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}") @router.get("/projects/{slug}/files/{file_id}")
@ -184,7 +146,6 @@ async def get_project_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get project file metadata."""
project = await _get_project(slug, db) project = await _get_project(slug, db)
result = await db.execute( result = await db.execute(
@ -193,12 +154,12 @@ async def get_project_file(
ProjectFile.project_id == project.id ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader)) ).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") 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") @router.get("/projects/{slug}/files/{file_id}/download")
@ -208,7 +169,6 @@ async def download_project_file(
token: Optional[str] = Query(None), token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Download project file."""
project = await _get_project(slug, db) project = await _get_project(slug, db)
result = await db.execute( result = await db.execute(
@ -217,14 +177,13 @@ async def download_project_file(
ProjectFile.project_id == project.id 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") 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)) 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): if not full_path.startswith(project_dir + os.sep):
raise HTTPException(400, "Invalid file path") raise HTTPException(400, "Invalid file path")
@ -233,8 +192,8 @@ async def download_project_file(
return FileResponse( return FileResponse(
full_path, full_path,
filename=project_file.filename, filename=pf.filename,
media_type=project_file.mime_type or "application/octet-stream", media_type=pf.mime_type or "application/octet-stream",
) )
@ -246,7 +205,6 @@ async def update_project_file(
data: ProjectFileUpdate, data: ProjectFileUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Update project file description."""
member = _get_member(request) member = _get_member(request)
project = await _get_project(slug, db) project = await _get_project(slug, db)
@ -256,19 +214,18 @@ async def update_project_file(
ProjectFile.project_id == project.id ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader)) ).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") raise HTTPException(404, "File not found")
# Update description
if data.description is not None: if data.description is not None:
project_file.description = data.description pf.description = data.description
await db.commit() 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}") @router.delete("/projects/{slug}/files/{file_id}")
@ -278,7 +235,6 @@ async def delete_project_file(
file_id: str, file_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Delete project file from database and disk."""
member = _get_member(request) member = _get_member(request)
project = await _get_project(slug, db) project = await _get_project(slug, db)
@ -288,19 +244,17 @@ async def delete_project_file(
ProjectFile.project_id == project.id 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") raise HTTPException(404, "File not found")
# Delete file from disk — validate path
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug)) 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): if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path):
os.remove(full_path) os.remove(full_path)
# Delete from database await db.delete(pf)
await db.delete(project_file)
await db.commit() await db.commit()
return {"ok": True} return {"ok": True}

View File

@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.enums import ChatKind, MemberRole, ProjectStatus from tracker.enums import ChatKind, MemberRole, ProjectStatus
from tracker.models import Project, Chat, Member, ProjectMember from tracker.models import Project, Chat, Member, ProjectMember
from tracker.api.schemas import ProjectOut
router = APIRouter(tags=["projects"]) router = APIRouter(tags=["projects"])
@ -27,20 +28,6 @@ class ProjectUpdate(BaseModel):
status: str | None = None 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): class ProjectMemberAdd(BaseModel):
slug: str 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.database import get_db
from tracker.models import Step, Task from tracker.models import Step, Task
from tracker.api.schemas import StepOut
from tracker.api.converters import step_out
router = APIRouter(tags=["steps"]) router = APIRouter(tags=["steps"])
@ -22,40 +24,20 @@ class StepUpdate(BaseModel):
done: bool | None = None 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]) @router.get("/tasks/{task_id}/steps", response_model=list[StepOut])
async def list_steps(task_id: str, db: AsyncSession = Depends(get_db)): 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) 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) @router.post("/tasks/{task_id}/steps", response_model=StepOut)
async def create_step(task_id: str, req: StepCreate, db: AsyncSession = Depends(get_db)): 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)) task = await db.get(Task, uuid.UUID(task_id))
if not task: if not task:
raise HTTPException(404, "Task not found") raise HTTPException(404, "Task not found")
# Get max position
result = await db.execute( result = await db.execute(
select(func.coalesce(func.max(Step.position), -1)).where(Step.task_id == task.id) 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) db.add(step)
await db.commit() await db.commit()
await db.refresh(step) 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) @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.commit()
await db.refresh(step) await db.refresh(step)
return _step_out(step) return step_out(step).model_dump()
@router.delete("/tasks/{task_id}/steps/{step_id}") @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.models import Task, Step, Project, Member, Message, Chat, TaskAction
from tracker.api.auth import get_current_member 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"]) router = APIRouter(tags=["tasks"])
# --- Schemas --- # --- 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): class TaskCreate(BaseModel):
title: str title: str
description: str | None = None description: str | None = None
@ -93,12 +59,6 @@ class AssignRequest(BaseModel):
# --- Helpers --- # --- 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: async def _get_project_chat_id(db: AsyncSession, project_id) -> uuid.UUID | None:
result = await db.execute( result = await db.execute(
select(Chat).where(Chat.project_id == project_id, Chat.kind == ChatKind.PROJECT) select(Chat).where(Chat.project_id == project_id, Chat.kind == ChatKind.PROJECT)
@ -135,18 +95,18 @@ async def _system_message(
project_slug: str = "", project_slug: str = "",
actor: Member | None = None, 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 from tracker.ws.manager import manager
prefix = project_slug[:2].upper() if project_slug else "XX" prefix = project_slug[:2].upper() if project_slug else "XX"
key = f"{prefix}-{task.number}" key = f"{prefix}-{task.number}"
task_text = task_text or chat_text 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" 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_msg = Message(
task_id=task.id, task_id=task.id,
author_type=AuthorType.SYSTEM, author_type=AuthorType.SYSTEM,
@ -170,60 +130,34 @@ async def _system_message(
await db.flush() await db.flush()
# Broadcast task comment # Build MessageOut for task comment broadcast
await manager.broadcast_task_event(str(task.project_id), WSEventType.MESSAGE_NEW, { task_msg_out = MessageOut(
"id": str(task_msg.id), id=str(task_msg.id),
"task_id": str(task.id), task_id=str(task.id),
"task_key": key, author_type=AuthorType.SYSTEM,
"author_type": AuthorType.SYSTEM, author_id=None,
"author_id": None, author=None,
"author_slug": "system", content=task_text,
"author": None, mentions=[],
"content": task_text, attachments=[],
"created_at": task_msg.created_at.isoformat() if task_msg.created_at else now.isoformat(), 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 # Broadcast chat message
if chat_msg and chat_id: if chat_msg and chat_id:
await manager.broadcast_message(str(task.project_id), { chat_msg_out = MessageOut(
"id": str(chat_msg.id), id=str(chat_msg.id),
"chat_id": str(chat_id), chat_id=str(chat_id),
"author_type": AuthorType.SYSTEM, author_type=AuthorType.SYSTEM,
"author_id": None, author_id=None,
"author_slug": "system", author=None,
"author": None, content=chat_text,
"content": chat_text, mentions=[],
"created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(), attachments=[],
}, author_slug="system") 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")
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 [])
],
}
async def _get_task(task_id: str, db: AsyncSession) -> Task: 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.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 [_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) @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 _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) @router.post("/tasks", response_model=TaskOut)
@ -320,15 +254,16 @@ async def create_task(
watcher_ids=[assignee_id] if assignee_id else [], watcher_ids=[assignee_id] if assignee_id else [],
) )
db.add(task) 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 _record_action(db, task, current_member, TaskActionType.CREATED)
await db.commit() await db.commit()
task_full = await _get_task(str(task.id), db) task_full = await _get_task(str(task.id), db)
# Broadcast # 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)
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, {
"id": str(task.id), "id": str(task.id),
@ -338,7 +273,7 @@ async def create_task(
"title": task.title, "title": task.title,
"status": task.status, "status": task.status,
"assignee_id": str(assignee_id) if assignee_id else None, "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 # System message
@ -351,7 +286,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 _task_out(task_full, project.slug) return _to_task_out(task_full, project.slug)
@router.patch("/tasks/{task_id}", response_model=TaskOut) @router.patch("/tasks/{task_id}", response_model=TaskOut)
@ -368,7 +303,6 @@ async def update_task(
who = f"@{current_member.slug}" who = f"@{current_member.slug}"
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") 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: if req.title is not None and req.title != task.title:
await _record_action(db, task, current_member, TaskActionType.TITLE_CHANGED, await _record_action(db, task, current_member, TaskActionType.TITLE_CHANGED,
"title", task.title, req.title) "title", task.title, req.title)
@ -419,7 +353,6 @@ async def update_task(
task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})", task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})",
project_slug=slug, actor=current_member, project_slug=slug, actor=current_member,
) )
# Add to watchers
if new_assignee_id not in (task.watcher_ids or []): if new_assignee_id not in (task.watcher_ids or []):
task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id] task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id]
else: else:
@ -447,17 +380,17 @@ async def update_task(
await db.commit() await db.commit()
await db.refresh(task) await db.refresh(task)
# Broadcast # 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, { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, {
"id": str(task.id), "id": str(task.id),
"status": task.status, "status": task.status,
"assignee_id": str(task.assignee_id) if task.assignee_id else None, "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}") @router.delete("/tasks/{task_id}")
@ -514,14 +447,14 @@ async def take_task(
await db.commit() await db.commit()
from tracker.ws.manager import manager 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, { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id), "id": str(task.id),
"assignee_id": str(current_member.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 _to_task_out(task_full, proj_slug)
return _task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/reject") @router.post("/tasks/{task_id}/reject")
@ -553,9 +486,10 @@ async def reject_task(
await db.commit() await db.commit()
from tracker.ws.manager import manager from tracker.ws.manager import manager
await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, { task_full = await _get_task(task_id, db)
"id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_id": None, "assignee": None, 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} return {"ok": True, "reason": req.reason}
@ -590,14 +524,14 @@ async def assign_task(
await db.commit() await db.commit()
from tracker.ws.manager import manager 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, { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id), "id": str(task.id),
"assignee_id": str(assignee.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 _to_task_out(task_full, proj_slug)
return _task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/watch") @router.post("/tasks/{task_id}/watch")

View File

@ -13,6 +13,8 @@ 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.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")
@ -284,18 +286,19 @@ 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_data = { msg_out = MessageOut(
"id": str(msg.id), id=str(msg.id),
"chat_id": chat_id, chat_id=chat_id,
"task_id": task_id, task_id=task_id,
"author_type": member.type, author_type=member.type,
"author_id": str(member.id), author_id=str(member.id),
"author": {"id": str(member.id), "slug": member.slug, "name": member.name}, author=member_brief(member),
"content": content, content=content,
"mentions": mentions, mentions=mentions,
"attachments": [], attachments=[],
"created_at": msg.created_at.isoformat(), 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