diff --git a/src/tracker/api/converters.py b/src/tracker/api/converters.py new file mode 100644 index 0000000..a422ae3 --- /dev/null +++ b/src/tracker/api/converters.py @@ -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 "", + ) diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py index 0e01dea..15cc27c 100644 --- a/src/tracker/api/members.py +++ b/src/tracker/api/members.py @@ -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") diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py index ad4bbdc..948c5c3 100644 --- a/src/tracker/api/messages.py +++ b/src/tracker/api/messages.py @@ -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()] diff --git a/src/tracker/api/project_files.py b/src/tracker/api/project_files.py index 24a624c..7e7177f 100644 --- a/src/tracker/api/project_files.py +++ b/src/tracker/api/project_files.py @@ -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} \ No newline at end of file + return {"ok": True} diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index b510a23..c9859f3 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -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 diff --git a/src/tracker/api/schemas.py b/src/tracker/api/schemas.py new file mode 100644 index 0000000..5a0b22a --- /dev/null +++ b/src/tracker/api/schemas.py @@ -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 diff --git a/src/tracker/api/steps.py b/src/tracker/api/steps.py index 1eeacf8..d9c5568 100644 --- a/src/tracker/api/steps.py +++ b/src/tracker/api/steps.py @@ -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}") diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index ca39108..eaca332 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -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") diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index 59743bc..d91582a 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -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