Строгая типизация: единые Pydantic-схемы и конвертеры для REST/WS
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
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:
parent
a4fcfe91b3
commit
67eecc079f
122
src/tracker/api/converters.py
Normal file
122
src/tracker/api/converters.py
Normal 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 "",
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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()]
|
||||
|
||||
@ -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}
|
||||
@ -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
115
src/tracker/api/schemas.py
Normal 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
|
||||
@ -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}")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user