refactor: полный UUID рефакторинг tasks.py + audit log + WS member_id
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s

- Все slug ссылки заменены на UUID (assignee_id, reviewer_id, watcher_ids, author_id)
- TaskAction записывается при каждом изменении задачи
- _system_message принимает actor: Member вместо actor_slug
- ConnectedClient хранит member_id для task event фильтрации
- broadcast_task_event фильтрует по member_id вместо slug
This commit is contained in:
markov 2026-02-25 04:59:57 +01:00
parent 41f4bc5ebc
commit e8f7d04821
3 changed files with 296 additions and 203 deletions

View File

@ -1,5 +1,6 @@
"""Tasks API — CRUD + take/reject/assign/watch.""" """Tasks API — CRUD + take/reject/assign/watch."""
import datetime
import uuid import uuid
from typing import Optional from typing import Optional
@ -10,7 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from tracker.database import get_db from tracker.database import get_db
from tracker.enums import AuthorType, ChatKind, TaskPriority, TaskStatus, TaskType, TaskActionType from tracker.enums import (
AuthorType, ChatKind, TaskActionType, TaskPriority, TaskStatus, TaskType, WSEventType,
)
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
@ -19,6 +22,12 @@ router = APIRouter(tags=["tasks"])
# --- Schemas --- # --- Schemas ---
class MemberRef(BaseModel):
id: str
slug: str
name: str
class StepOut(BaseModel): class StepOut(BaseModel):
id: str id: str
title: str title: str
@ -31,7 +40,7 @@ class TaskOut(BaseModel):
project_id: str project_id: str
parent_id: str | None = None parent_id: str | None = None
number: int number: int
key: str # e.g. "TE-1" key: str
title: str title: str
description: str | None = None description: str | None = None
type: str type: str
@ -39,9 +48,9 @@ class TaskOut(BaseModel):
priority: str priority: str
labels: list[str] = [] labels: list[str] = []
assignee_id: str | None = None assignee_id: str | None = None
assignee: dict | None = None # Member object for display assignee: MemberRef | None = None
reviewer_id: str | None = None reviewer_id: str | None = None
reviewer: dict | None = None # Member object for display reviewer: MemberRef | None = None
watcher_ids: list[str] = [] watcher_ids: list[str] = []
depends_on: list[str] = [] depends_on: list[str] = []
position: int position: int
@ -84,8 +93,13 @@ class AssignRequest(BaseModel):
# --- Helpers --- # --- Helpers ---
async def _get_project_chat_id(db: AsyncSession, project_id) -> str | None: def _member_ref(m: Member | None) -> dict | None:
"""Find project chat UUID.""" 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( 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)
) )
@ -93,21 +107,36 @@ async def _get_project_chat_id(db: AsyncSession, project_id) -> str | None:
return chat.id if chat else None return chat.id if chat else None
async def _record_action(
db: AsyncSession,
task: Task,
actor: Member,
action: str,
field: str | None = None,
old_value: str | None = None,
new_value: str | None = None,
):
"""Record a task action in the audit log."""
db.add(TaskAction(
task_id=task.id,
actor_id=actor.id,
action=action,
field=field,
old_value=old_value,
new_value=new_value,
))
async def _system_message( async def _system_message(
db: AsyncSession, db: AsyncSession,
task: Task, task: Task,
chat_text: str, chat_text: str,
task_text: str | None = None, task_text: str | None = None,
project_slug: str = "", project_slug: str = "",
actor_id: str | 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."""
chat_text: short message for project chat
task_text: detailed message for task history (if None, same as chat_text)
"""
from tracker.ws.manager import manager from tracker.ws.manager import manager
import datetime
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}"
@ -115,24 +144,26 @@ async def _system_message(
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
chat_id = await _get_project_chat_id(db, task.project_id) chat_id = await _get_project_chat_id(db, task.project_id)
actor_id = actor.id if actor else None
actor_slug = actor.slug if actor else "system"
# 1. Task comment — detailed history # 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,
author_id=uuid.UUID(actor_id) if actor_id else None, author_id=actor_id,
content=task_text, content=task_text,
mentions=[], mentions=[],
) )
db.add(task_msg) db.add(task_msg)
# 2. Chat message — brief notification # 2. Chat message
chat_msg = None chat_msg = None
if chat_id: if chat_id:
chat_msg = Message( chat_msg = Message(
chat_id=chat_id, chat_id=chat_id,
author_type=AuthorType.SYSTEM, author_type=AuthorType.SYSTEM,
author_id=uuid.UUID(actor_id) if actor_id else None, author_id=actor_id,
content=chat_text, content=chat_text,
mentions=[], mentions=[],
) )
@ -141,11 +172,12 @@ async def _system_message(
await db.flush() await db.flush()
# Broadcast task comment # Broadcast task comment
await manager.broadcast_task_event(str(task.project_id), "message.new", { await manager.broadcast_task_event(str(task.project_id), WSEventType.MESSAGE_NEW, {
"id": str(task_msg.id), "id": str(task_msg.id),
"task_id": str(task.id), "task_id": str(task.id),
"task_key": key, "task_key": key,
"author_type": AuthorType.SYSTEM, "author_type": AuthorType.SYSTEM,
"author_id": str(actor_id) if actor_id else None,
"author_slug": actor_slug, "author_slug": actor_slug,
"content": task_text, "content": task_text,
"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 now.isoformat(),
@ -153,14 +185,15 @@ async def _system_message(
# Broadcast chat message # Broadcast chat message
if chat_msg and chat_id: if chat_msg and chat_id:
await manager.broadcast_task_event(str(task.project_id), "message.new", { await manager.broadcast_message(str(task.project_id), {
"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": str(actor_id) if actor_id else None,
"author_slug": actor_slug, "author_slug": actor_slug,
"content": chat_text, "content": chat_text,
"created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(), "created_at": chat_msg.created_at.isoformat() if chat_msg.created_at else now.isoformat(),
}) }, author_slug=actor_slug)
def _task_out(t: Task, project_slug: str = "") -> dict: def _task_out(t: Task, project_slug: str = "") -> dict:
@ -178,9 +211,9 @@ def _task_out(t: Task, project_slug: str = "") -> dict:
"priority": t.priority, "priority": t.priority,
"labels": t.labels or [], "labels": t.labels or [],
"assignee_id": str(t.assignee_id) if t.assignee_id else None, "assignee_id": str(t.assignee_id) if t.assignee_id else None,
"assignee": {"id": str(t.assignee.id), "slug": t.assignee.slug, "name": t.assignee.name} if t.assignee else None, "assignee": _member_ref(t.assignee),
"reviewer_id": str(t.reviewer_id) if t.reviewer_id else None, "reviewer_id": str(t.reviewer_id) if t.reviewer_id else None,
"reviewer": {"id": str(t.reviewer.id), "slug": t.reviewer.slug, "name": t.reviewer.name} if t.reviewer else None, "reviewer": _member_ref(t.reviewer),
"watcher_ids": [str(w) for w in (t.watcher_ids or [])], "watcher_ids": [str(w) for w in (t.watcher_ids or [])],
"depends_on": [str(d) for d in (t.depends_on or [])], "depends_on": [str(d) for d in (t.depends_on or [])],
"position": t.position, "position": t.position,
@ -199,7 +232,7 @@ async def _get_task(task_id: str, db: AsyncSession) -> Task:
selectinload(Task.steps), selectinload(Task.steps),
joinedload(Task.project), joinedload(Task.project),
joinedload(Task.assignee), joinedload(Task.assignee),
joinedload(Task.reviewer) joinedload(Task.reviewer),
) )
) )
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
@ -208,13 +241,22 @@ async def _get_task(task_id: str, db: AsyncSession) -> Task:
return task return task
async def _resolve_member(db: AsyncSession, member_id: str) -> Member:
"""Resolve member by UUID. Raises 404 if not found."""
result = await db.execute(select(Member).where(Member.id == uuid.UUID(member_id)))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, f"Member {member_id} not found")
return member
# --- Endpoints --- # --- Endpoints ---
@router.get("/tasks", response_model=list[TaskOut]) @router.get("/tasks", response_model=list[TaskOut])
async def list_tasks( async def list_tasks(
project_id: Optional[str] = Query(None), project_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
assignee: Optional[str] = Query(None), assignee_id: Optional[str] = Query(None),
label: Optional[str] = Query(None), label: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@ -222,14 +264,14 @@ async def list_tasks(
selectinload(Task.steps), selectinload(Task.steps),
joinedload(Task.project), joinedload(Task.project),
joinedload(Task.assignee), joinedload(Task.assignee),
joinedload(Task.reviewer) joinedload(Task.reviewer),
) )
if project_id: if project_id:
q = q.where(Task.project_id == uuid.UUID(project_id)) q = q.where(Task.project_id == uuid.UUID(project_id))
if status: if status:
q = q.where(Task.status == status) q = q.where(Task.status == status)
if assignee: if assignee_id:
q = q.where(Task.assignee_id == uuid.UUID(assignee)) q = q.where(Task.assignee_id == uuid.UUID(assignee_id))
if label: if label:
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)
@ -250,16 +292,17 @@ async def create_task(
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# Find project
result = await db.execute(select(Project).where(Project.slug == project_slug)) result = await db.execute(select(Project).where(Project.slug == project_slug))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(404, "Project not found") raise HTTPException(404, "Project not found")
# Increment task counter
project.task_counter += 1 project.task_counter += 1
number = project.task_counter number = project.task_counter
assignee_id = uuid.UUID(req.assignee_id) if req.assignee_id else None
reviewer_id = uuid.UUID(req.reviewer_id) if req.reviewer_id else None
task = Task( task = Task(
project_id=project.id, project_id=project.id,
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None, parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
@ -270,54 +313,43 @@ async def create_task(
status=req.status, status=req.status,
priority=req.priority, priority=req.priority,
labels=req.labels, labels=req.labels,
assignee_id=uuid.UUID(req.assignee_id) if req.assignee_id else None, assignee_id=assignee_id,
reviewer_id=uuid.UUID(req.reviewer_id) if req.reviewer_id else None, reviewer_id=reviewer_id,
depends_on=[uuid.UUID(d) for d in req.depends_on] if req.depends_on else [], depends_on=[uuid.UUID(d) for d in req.depends_on] if req.depends_on else [],
watcher_ids=[uuid.UUID(req.assignee_id)] if req.assignee_id else [], watcher_ids=[assignee_id] if assignee_id else [],
) )
db.add(task) db.add(task)
# Create audit log await _record_action(db, task, current_member, TaskActionType.CREATED)
task_action = TaskAction(
task_id=task.id,
actor_id=current_member.id,
action=TaskActionType.CREATED
)
db.add(task_action)
await db.commit() await db.commit()
await db.refresh(task)
# Load steps
task_full = await _get_task(str(task.id), db) task_full = await _get_task(str(task.id), db)
# Broadcast task.created event # Broadcast
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_data = { key = f"{project.slug[:2].upper()}-{task.number}"
await manager.broadcast_task_event(str(project.id), WSEventType.TASK_CREATED, {
"id": str(task.id), "id": str(task.id),
"project_id": str(project.id), "project_id": str(project.id),
"number": task.number, "number": task.number,
"key": f"{project.slug[:2].upper()}-{task.number}", "key": key,
"title": task.title, "title": task.title,
"status": task.status, "status": task.status,
"assignee_slug": task.assignee_slug, "assignee_id": str(assignee_id) if assignee_id else None,
"reviewer_slug": task.reviewer_slug, "assignee": _member_ref(task_full.assignee),
"watchers": task.watchers or [], })
"created_at": task.created_at.isoformat() if task.created_at else "",
}
await manager.broadcast_task_event(str(project.id), "task.created", task_data)
# System message # System message
slug = project.slug
key = f"{slug[:2].upper()}-{task.number}"
chat_text = f"{key} создана: {task.title}" chat_text = f"{key} создана: {task.title}"
task_text = f"Задача создана: {task.title}" task_text = f"Задача создана: {task.title}"
if task.assignee_slug: if task_full.assignee:
chat_text += f" → @{task.assignee_slug}" chat_text += f" → @{task_full.assignee.slug}"
task_text += f". Назначена на @{task.assignee_slug}" task_text += f". Назначена на @{task_full.assignee.slug}"
await _system_message(db, task_full, chat_text=chat_text, task_text=task_text, project_slug=slug) await _system_message(db, task_full, chat_text=chat_text, task_text=task_text,
project_slug=project.slug, actor=current_member)
await db.commit() await db.commit()
return _task_out(task_full, slug) return _task_out(task_full, project.slug)
@router.patch("/tasks/{task_id}", response_model=TaskOut) @router.patch("/tasks/{task_id}", response_model=TaskOut)
@ -332,209 +364,264 @@ async def update_task(
prefix = slug[:2].upper() if slug else "XX" prefix = slug[:2].upper() if slug else "XX"
key = f"{prefix}-{task.number}" key = f"{prefix}-{task.number}"
who = f"@{current_member.slug}" who = f"@{current_member.slug}"
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC")
old_status = task.status # Track changes for audit + system messages
old_assignee_id = task.assignee_id if req.title is not None and req.title != task.title:
await _record_action(db, task, current_member, TaskActionType.TITLE_CHANGED,
if req.title is not None: "title", task.title, req.title)
task.title = req.title task.title = req.title
if req.description is not None:
if req.description is not None and req.description != task.description:
await _record_action(db, task, current_member, TaskActionType.DESCRIPTION_CHANGED,
"description", task.description or "", req.description)
task.description = req.description task.description = req.description
if req.type is not None: if req.type is not None:
task.type = req.type task.type = req.type
if req.status is not None:
task.status = req.status
if req.priority is not None:
task.priority = req.priority
if req.labels is not None:
task.labels = req.labels
if req.assignee_id is not None:
task.assignee_id = uuid.UUID(req.assignee_id) if req.assignee_id else None
if req.reviewer_id is not None:
task.reviewer_id = uuid.UUID(req.reviewer_id) if req.reviewer_id else None
if req.position is not None:
task.position = req.position
# System messages for important changes if req.status is not None and req.status != task.status:
import datetime old_status = task.status
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") await _record_action(db, task, current_member, TaskActionType.STATUS_CHANGED,
if req.status is not None and req.status != old_status: "status", old_status, req.status)
task.status = req.status
await _system_message( await _system_message(
db, task, db, task,
chat_text=f"{key}: {old_status}{req.status}", chat_text=f"{key}: {old_status}{req.status}",
task_text=f"{who} изменил статус: {old_status}{req.status} ({now_str})", task_text=f"{who} изменил статус: {old_status}{req.status} ({now_str})",
project_slug=slug, project_slug=slug, actor=current_member,
actor_id=str(current_member.id),
) )
if req.assignee_slug is not None and req.assignee_slug != old_assignee:
if req.assignee_slug: if req.priority is not None and req.priority != task.priority:
await _system_message( old_priority = task.priority
db, task, await _record_action(db, task, current_member, TaskActionType.PRIORITY_CHANGED,
chat_text=f"{key}: назначена на @{req.assignee_slug}", "priority", old_priority, req.priority)
task_text=f"{who} назначил задачу на @{req.assignee_slug} ({now_str})", task.priority = req.priority
project_slug=slug,
actor_slug=current_member.slug, if req.labels is not None:
) task.labels = req.labels
else:
await _system_message( if req.assignee_id is not None:
db, task, old_assignee_id = task.assignee_id
chat_text=f"{key}: исполнитель снят", new_assignee_id = uuid.UUID(req.assignee_id) if req.assignee_id else None
task_text=f"{who} снял исполнителя @{old_assignee} ({now_str})", if new_assignee_id != old_assignee_id:
project_slug=slug, task.assignee_id = new_assignee_id
actor_slug=current_member.slug, if new_assignee_id:
) new_assignee = await _resolve_member(db, req.assignee_id)
await _record_action(db, task, current_member, TaskActionType.ASSIGNED,
"assignee_id", str(old_assignee_id) if old_assignee_id else None,
str(new_assignee_id))
await _system_message(
db, task,
chat_text=f"{key}: назначена на @{new_assignee.slug}",
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:
await _record_action(db, task, current_member, TaskActionType.UNASSIGNED,
"assignee_id", str(old_assignee_id), None)
await _system_message(
db, task,
chat_text=f"{key}: исполнитель снят",
task_text=f"{who} снял исполнителя ({now_str})",
project_slug=slug, actor=current_member,
)
if req.reviewer_id is not None:
old_reviewer_id = task.reviewer_id
new_reviewer_id = uuid.UUID(req.reviewer_id) if req.reviewer_id else None
if new_reviewer_id != old_reviewer_id:
task.reviewer_id = new_reviewer_id
await _record_action(db, task, current_member, TaskActionType.REVIEWER_CHANGED,
"reviewer_id", str(old_reviewer_id) if old_reviewer_id else None,
str(new_reviewer_id) if new_reviewer_id else None)
if req.position is not None:
task.position = req.position
await db.commit() await db.commit()
await db.refresh(task) await db.refresh(task)
# Broadcast task.updated event # Broadcast
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_data = { task_full = await _get_task(task_id, db)
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_slug": task.assignee_slug, "assignee_id": str(task.assignee_id) if task.assignee_id else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else "", "assignee": _member_ref(task_full.assignee),
} })
await manager.broadcast_task_event(str(task.project_id), "task.updated", task_data)
return _task_out(task, slug) return _task_out(task_full, slug)
@router.delete("/tasks/{task_id}") @router.delete("/tasks/{task_id}")
async def delete_task(task_id: str, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def delete_task(
task_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
project_id = str(task.project_id) project_id = str(task.project_id)
task_data = {"id": str(task.id), "project_id": project_id} await _record_action(db, task, current_member, TaskActionType.DELETED)
await db.commit()
await db.delete(task) await db.delete(task)
await db.commit() await db.commit()
from tracker.ws.manager import manager from tracker.ws.manager import manager
await manager.broadcast_task_event(project_id, "task.deleted", task_data) await manager.broadcast_task_event(project_id, WSEventType.TASK_DELETED, {
"id": task_id, "project_id": project_id,
})
return {"ok": True} return {"ok": True}
@router.post("/tasks/{task_id}/take", response_model=TaskOut) @router.post("/tasks/{task_id}/take", response_model=TaskOut)
async def take_task(task_id: str, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def take_task(
"""Atomically take a task — only if unassigned and in backlog/todo.""" task_id: str,
slug = current_member.slug current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
"""Take a task — only if unassigned and in backlog/todo."""
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
if task.assignee_slug: if task.assignee_id:
raise HTTPException(409, f"Task already assigned to {task.assignee_slug}") assignee_name = task.assignee.slug if task.assignee else str(task.assignee_id)
raise HTTPException(409, f"Task already assigned to {assignee_name}")
if task.status not in (TaskStatus.BACKLOG, TaskStatus.TODO): if task.status not in (TaskStatus.BACKLOG, TaskStatus.TODO):
raise HTTPException(409, f"Task status is {task.status}, expected backlog or todo") raise HTTPException(409, f"Task status is {task.status}, expected backlog or todo")
task.assignee_slug = slug
task.status = TaskStatus.IN_PROGRESS
# Add to watchers
if slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [slug]
await db.commit()
await db.refresh(task)
# System message task.assignee_id = current_member.id
task.status = TaskStatus.IN_PROGRESS
if current_member.id not in (task.watcher_ids or []):
task.watcher_ids = (task.watcher_ids or []) + [current_member.id]
await _record_action(db, task, current_member, TaskActionType.ASSIGNED,
"assignee_id", None, str(current_member.id))
await _record_action(db, task, current_member, TaskActionType.STATUS_CHANGED,
"status", TaskStatus.BACKLOG, TaskStatus.IN_PROGRESS)
proj_slug = task.project.slug if task.project else "" proj_slug = task.project.slug if task.project else ""
prefix = proj_slug[:2].upper() if proj_slug else "XX" key = f"{proj_slug[:2].upper()}-{task.number}"
key = f"{prefix}-{task.number}"
import datetime
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC")
await _system_message( await _system_message(
db, task, db, task,
chat_text=f"{key}: @{slug} взял в работу", chat_text=f"{key}: @{current_member.slug} взял в работу",
task_text=f"@{slug} взял задачу в работу ({now_str})", task_text=f"@{current_member.slug} взял задачу в работу",
project_slug=proj_slug, project_slug=proj_slug, actor=current_member,
actor_slug=slug,
) )
await db.commit() await db.commit()
# Broadcast task.assigned event
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_data = { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id), "id": str(task.id),
"assignee_slug": slug, "assignee_id": str(current_member.id),
"assigner_slug": slug, # self-assigned "assignee": _member_ref(current_member),
"assigned_at": task.updated_at.isoformat() if task.updated_at else "", })
}
await manager.broadcast_task_event(str(task.project_id), "task.assigned", task_data)
return _task_out(task, proj_slug) task_full = await _get_task(task_id, db)
return _task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/reject") @router.post("/tasks/{task_id}/reject")
async def reject_task(task_id: str, req: RejectRequest, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def reject_task(
"""Reject a task with reason — unassign and return to todo.""" task_id: str,
req: RejectRequest,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
"""Reject a task — unassign and return to backlog."""
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
old_assignee = task.assignee_slug old_assignee_id = task.assignee_id
task.assignee_slug = None task.assignee_id = None
task.status = TaskStatus.BACKLOG task.status = TaskStatus.BACKLOG
# Add rejection comment
if old_assignee: await _record_action(db, task, current_member, TaskActionType.UNASSIGNED,
comment = Message( "assignee_id", str(old_assignee_id) if old_assignee_id else None, None)
task_id=task.id, await _record_action(db, task, current_member, TaskActionType.STATUS_CHANGED,
author_type=AuthorType.SYSTEM, "status", task.status, TaskStatus.BACKLOG)
author_slug=old_assignee,
content=f"Задача отклонена: {req.reason}", comment = Message(
mentions=[], task_id=task.id,
) author_type=AuthorType.SYSTEM,
db.add(comment) author_id=current_member.id,
content=f"Задача отклонена: {req.reason}",
mentions=[],
)
db.add(comment)
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), "task.updated", { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, {
"id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_slug": None, "id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_id": None, "assignee": None,
}) })
return {"ok": True, "reason": req.reason, "old_assignee": old_assignee} return {"ok": True, "reason": req.reason}
@router.post("/tasks/{task_id}/assign", response_model=TaskOut) @router.post("/tasks/{task_id}/assign", response_model=TaskOut)
async def assign_task(task_id: str, req: AssignRequest, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def assign_task(
"""Assign task to a member.""" task_id: str,
# Verify assignee exists req: AssignRequest,
result = await db.execute(select(Member).where(Member.slug == req.assignee_slug)) current_member: Member = Depends(get_current_member),
if not result.scalar_one_or_none(): db: AsyncSession = Depends(get_db),
raise HTTPException(404, f"Member {req.assignee_slug} not found") ):
"""Assign task to a member by UUID."""
assignee = await _resolve_member(db, req.assignee_id)
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
task.assignee_slug = req.assignee_slug old_assignee_id = task.assignee_id
if req.assignee_slug not in (task.watchers or []):
task.watchers = (task.watchers or []) + [req.assignee_slug] task.assignee_id = assignee.id
await db.commit() if assignee.id not in (task.watcher_ids or []):
await db.refresh(task) task.watcher_ids = (task.watcher_ids or []) + [assignee.id]
await _record_action(db, task, current_member, TaskActionType.ASSIGNED,
"assignee_id", str(old_assignee_id) if old_assignee_id else None,
str(assignee.id))
# System message
proj_slug = task.project.slug if task.project else "" proj_slug = task.project.slug if task.project else ""
prefix = proj_slug[:2].upper() if proj_slug else "XX" key = f"{proj_slug[:2].upper()}-{task.number}"
key = f"{prefix}-{task.number}"
await _system_message( await _system_message(
db, task, db, task,
chat_text=f"{key}: назначена на @{req.assignee_slug}", chat_text=f"{key}: назначена на @{assignee.slug}",
task_text=f"Задача назначена на @{req.assignee_slug}", task_text=f"@{current_member.slug} назначил задачу на @{assignee.slug}",
project_slug=proj_slug, project_slug=proj_slug, actor=current_member,
) )
await db.commit() await db.commit()
# Broadcast task.assigned event
from tracker.ws.manager import manager from tracker.ws.manager import manager
task_data = { await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, {
"id": str(task.id), "id": str(task.id),
"assignee_slug": req.assignee_slug, "assignee_id": str(assignee.id),
"assigner_slug": "admin", # TODO: get from auth context "assignee": _member_ref(assignee),
"assigned_at": task.updated_at.isoformat() if task.updated_at else "", })
}
await manager.broadcast_task_event(str(task.project_id), "task.assigned", task_data)
return _task_out(task, proj_slug) task_full = await _get_task(str(task.id), db)
return _task_out(task_full, proj_slug)
@router.post("/tasks/{task_id}/watch") @router.post("/tasks/{task_id}/watch")
async def watch_task(task_id: str, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def watch_task(
slug = current_member.slug task_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
if slug not in (task.watchers or []): if current_member.id not in (task.watcher_ids or []):
task.watchers = (task.watchers or []) + [slug] task.watcher_ids = (task.watcher_ids or []) + [current_member.id]
await _record_action(db, task, current_member, TaskActionType.WATCHER_ADDED,
"watcher_ids", None, str(current_member.id))
await db.commit() await db.commit()
return {"ok": True, "watchers": task.watchers} return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]}
@router.delete("/tasks/{task_id}/watch") @router.delete("/tasks/{task_id}/watch")
async def unwatch_task(task_id: str, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): async def unwatch_task(
slug = current_member.slug task_id: str,
current_member: Member = Depends(get_current_member),
db: AsyncSession = Depends(get_db),
):
task = await _get_task(task_id, db) task = await _get_task(task_id, db)
task.watchers = [w for w in (task.watchers or []) if w != slug] task.watcher_ids = [w for w in (task.watcher_ids or []) if w != current_member.id]
await _record_action(db, task, current_member, TaskActionType.WATCHER_REMOVED,
"watcher_ids", str(current_member.id), None)
await db.commit() await db.commit()
return {"ok": True, "watchers": task.watchers} return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]}

View File

@ -163,6 +163,7 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No
client = ConnectedClient( client = ConnectedClient(
ws=ws, ws=ws,
session_id=session_id, session_id=session_id,
member_id=str(member.id),
member_slug=effective_slug, member_slug=effective_slug,
member_type=effective_type, member_type=effective_type,
chat_listen=chat_listen, chat_listen=chat_listen,

View File

@ -16,8 +16,9 @@ logger = logging.getLogger("tracker.ws")
class ConnectedClient: class ConnectedClient:
ws: WebSocket ws: WebSocket
session_id: str # unique per connection session_id: str # unique per connection
member_slug: str member_id: str | None = None # UUID as string
member_type: str # human | agent | bridge member_slug: str = ""
member_type: str = "" # human | agent | bridge
chat_listen: str = ListenMode.ALL chat_listen: str = ListenMode.ALL
task_listen: str = ListenMode.ALL task_listen: str = ListenMode.ALL
subscribed_projects: set[str] = field(default_factory=set) subscribed_projects: set[str] = field(default_factory=set)
@ -118,9 +119,9 @@ class ConnectionManager:
async def broadcast_task_event(self, project_id: str, event_type: str, data: dict): async def broadcast_task_event(self, project_id: str, event_type: str, data: dict):
"""Broadcast task events. Humans get everything, agents filtered.""" """Broadcast task events. Humans get everything, agents filtered."""
assignee = data.get("assignee_slug") assignee_id = data.get("assignee_id")
reviewer = data.get("reviewer_slug") reviewer_id = data.get("reviewer_id")
watchers = data.get("watchers", []) watcher_ids = data.get("watcher_ids", [])
payload = {"type": event_type, "data": data} payload = {"type": event_type, "data": data}
for session_id, client in list(self.sessions.items()): for session_id, client in list(self.sessions.items()):
@ -136,7 +137,11 @@ class ConnectionManager:
if client.task_listen == ListenMode.ALL: if client.task_listen == ListenMode.ALL:
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
continue continue
if client.member_slug in (assignee, reviewer) or client.member_slug in watchers: # For "mentions" mode: check if agent is assignee/reviewer/watcher
if client.member_id and (
client.member_id in (assignee_id, reviewer_id) or
client.member_id in watcher_ids
):
await self.send_to_session(session_id, payload) await self.send_to_session(session_id, payload)
async def broadcast_all(self, data: dict, exclude_slug: str | None = None): async def broadcast_all(self, data: dict, exclude_slug: str | None = None):