Строгая типизация: единые 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.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")
|
||||||
|
|||||||
@ -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()]
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
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.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}")
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user