From e8f7d04821d0e3873f18c282a56174ee1d4ffe46 Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 04:59:57 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20UUID=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20tasks.py=20+=20audit=20log=20+=20WS?= =?UTF-8?q?=20member=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Все 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 --- src/tracker/api/tasks.py | 481 ++++++++++++++++++++++---------------- src/tracker/ws/handler.py | 1 + src/tracker/ws/manager.py | 17 +- 3 files changed, 296 insertions(+), 203 deletions(-) diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index 8ff567f..8ac6778 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -1,5 +1,6 @@ """Tasks API — CRUD + take/reject/assign/watch.""" +import datetime import uuid from typing import Optional @@ -10,7 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload 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.api.auth import get_current_member @@ -19,6 +22,12 @@ router = APIRouter(tags=["tasks"]) # --- Schemas --- +class MemberRef(BaseModel): + id: str + slug: str + name: str + + class StepOut(BaseModel): id: str title: str @@ -31,7 +40,7 @@ class TaskOut(BaseModel): project_id: str parent_id: str | None = None number: int - key: str # e.g. "TE-1" + key: str title: str description: str | None = None type: str @@ -39,9 +48,9 @@ class TaskOut(BaseModel): priority: str labels: list[str] = [] assignee_id: str | None = None - assignee: dict | None = None # Member object for display + assignee: MemberRef | None = None reviewer_id: str | None = None - reviewer: dict | None = None # Member object for display + reviewer: MemberRef | None = None watcher_ids: list[str] = [] depends_on: list[str] = [] position: int @@ -84,8 +93,13 @@ class AssignRequest(BaseModel): # --- Helpers --- -async def _get_project_chat_id(db: AsyncSession, project_id) -> str | None: - """Find project chat UUID.""" +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) ) @@ -93,21 +107,36 @@ async def _get_project_chat_id(db: AsyncSession, project_id) -> str | 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( db: AsyncSession, task: Task, chat_text: str, task_text: str | None = None, project_slug: str = "", - actor_id: str | None = None, + actor: Member | None = None, ): - """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) - """ + """Create system messages: one in project chat + one in task comments.""" from tracker.ws.manager import manager - import datetime prefix = project_slug[:2].upper() if project_slug else "XX" key = f"{prefix}-{task.number}" @@ -115,24 +144,26 @@ async def _system_message( now = datetime.datetime.now(datetime.timezone.utc) 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_id=task.id, author_type=AuthorType.SYSTEM, - author_id=uuid.UUID(actor_id) if actor_id else None, + author_id=actor_id, content=task_text, mentions=[], ) db.add(task_msg) - # 2. Chat message — brief notification + # 2. Chat message chat_msg = None if chat_id: chat_msg = Message( chat_id=chat_id, author_type=AuthorType.SYSTEM, - author_id=uuid.UUID(actor_id) if actor_id else None, + author_id=actor_id, content=chat_text, mentions=[], ) @@ -141,11 +172,12 @@ async def _system_message( await db.flush() # 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), "task_id": str(task.id), "task_key": key, "author_type": AuthorType.SYSTEM, + "author_id": str(actor_id) if actor_id else None, "author_slug": actor_slug, "content": task_text, "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 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), "chat_id": str(chat_id), "author_type": AuthorType.SYSTEM, + "author_id": str(actor_id) if actor_id else None, "author_slug": actor_slug, "content": chat_text, "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: @@ -178,9 +211,9 @@ def _task_out(t: Task, project_slug: str = "") -> dict: "priority": t.priority, "labels": t.labels or [], "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), "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 [])], "depends_on": [str(d) for d in (t.depends_on or [])], "position": t.position, @@ -196,10 +229,10 @@ async def _get_task(task_id: str, db: AsyncSession) -> Task: result = await db.execute( select(Task).where(Task.id == uuid.UUID(task_id)) .options( - selectinload(Task.steps), + selectinload(Task.steps), joinedload(Task.project), joinedload(Task.assignee), - joinedload(Task.reviewer) + joinedload(Task.reviewer), ) ) task = result.scalar_one_or_none() @@ -208,28 +241,37 @@ async def _get_task(task_id: str, db: AsyncSession) -> 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 --- @router.get("/tasks", response_model=list[TaskOut]) async def list_tasks( project_id: 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), db: AsyncSession = Depends(get_db), ): q = select(Task).options( - selectinload(Task.steps), + selectinload(Task.steps), joinedload(Task.project), joinedload(Task.assignee), - joinedload(Task.reviewer) + joinedload(Task.reviewer), ) if project_id: q = q.where(Task.project_id == uuid.UUID(project_id)) if status: q = q.where(Task.status == status) - if assignee: - q = q.where(Task.assignee_id == uuid.UUID(assignee)) + if assignee_id: + q = q.where(Task.assignee_id == uuid.UUID(assignee_id)) if label: q = q.where(Task.labels.contains([label])) q = q.order_by(Task.position, Task.created_at) @@ -250,16 +292,17 @@ async def create_task( current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db), ): - # Find project result = await db.execute(select(Project).where(Project.slug == project_slug)) project = result.scalar_one_or_none() if not project: raise HTTPException(404, "Project not found") - # Increment task counter project.task_counter += 1 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( project_id=project.id, parent_id=uuid.UUID(req.parent_id) if req.parent_id else None, @@ -270,54 +313,43 @@ async def create_task( status=req.status, priority=req.priority, labels=req.labels, - 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, + assignee_id=assignee_id, + reviewer_id=reviewer_id, 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) - - # Create audit log - task_action = TaskAction( - task_id=task.id, - actor_id=current_member.id, - action=TaskActionType.CREATED - ) - db.add(task_action) - + + await _record_action(db, task, current_member, TaskActionType.CREATED) + await db.commit() - await db.refresh(task) - # Load steps task_full = await _get_task(str(task.id), db) - - # Broadcast task.created event + + # Broadcast 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), "project_id": str(project.id), "number": task.number, - "key": f"{project.slug[:2].upper()}-{task.number}", + "key": key, "title": task.title, "status": task.status, - "assignee_slug": task.assignee_slug, - "reviewer_slug": task.reviewer_slug, - "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) + "assignee_id": str(assignee_id) if assignee_id else None, + "assignee": _member_ref(task_full.assignee), + }) # System message - slug = project.slug - key = f"{slug[:2].upper()}-{task.number}" chat_text = f"{key} создана: {task.title}" task_text = f"Задача создана: {task.title}" - if task.assignee_slug: - chat_text += f" → @{task.assignee_slug}" - task_text += f". Назначена на @{task.assignee_slug}" - await _system_message(db, task_full, chat_text=chat_text, task_text=task_text, project_slug=slug) + if task_full.assignee: + chat_text += f" → @{task_full.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=project.slug, actor=current_member) await db.commit() - return _task_out(task_full, slug) + return _task_out(task_full, project.slug) @router.patch("/tasks/{task_id}", response_model=TaskOut) @@ -332,209 +364,264 @@ async def update_task( prefix = slug[:2].upper() if slug else "XX" key = f"{prefix}-{task.number}" who = f"@{current_member.slug}" + now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") - old_status = task.status - old_assignee_id = task.assignee_id - - if req.title is not None: + # 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) 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 + if req.type is not None: 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 - import datetime - now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") - if req.status is not None and req.status != old_status: + if req.status is not None and req.status != task.status: + old_status = task.status + await _record_action(db, task, current_member, TaskActionType.STATUS_CHANGED, + "status", old_status, req.status) + task.status = req.status await _system_message( db, task, chat_text=f"{key}: {old_status} → {req.status}", task_text=f"{who} изменил статус: {old_status} → {req.status} ({now_str})", - project_slug=slug, - actor_id=str(current_member.id), + project_slug=slug, actor=current_member, ) - if req.assignee_slug is not None and req.assignee_slug != old_assignee: - if req.assignee_slug: - await _system_message( - db, task, - chat_text=f"{key}: назначена на @{req.assignee_slug}", - task_text=f"{who} назначил задачу на @{req.assignee_slug} ({now_str})", - project_slug=slug, - actor_slug=current_member.slug, - ) - else: - await _system_message( - db, task, - chat_text=f"{key}: исполнитель снят", - task_text=f"{who} снял исполнителя @{old_assignee} ({now_str})", - project_slug=slug, - actor_slug=current_member.slug, - ) + + if req.priority is not None and req.priority != task.priority: + old_priority = task.priority + await _record_action(db, task, current_member, TaskActionType.PRIORITY_CHANGED, + "priority", old_priority, req.priority) + task.priority = req.priority + + if req.labels is not None: + task.labels = req.labels + + if req.assignee_id is not None: + old_assignee_id = task.assignee_id + new_assignee_id = uuid.UUID(req.assignee_id) if req.assignee_id else None + if new_assignee_id != old_assignee_id: + task.assignee_id = new_assignee_id + 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.refresh(task) - - # Broadcast task.updated event + + # Broadcast 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), "status": task.status, - "assignee_slug": task.assignee_slug, - "updated_at": task.updated_at.isoformat() if task.updated_at else "", - } - await manager.broadcast_task_event(str(task.project_id), "task.updated", task_data) - - return _task_out(task, slug) + "assignee_id": str(task.assignee_id) if task.assignee_id else None, + "assignee": _member_ref(task_full.assignee), + }) + + return _task_out(task_full, slug) @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) 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.commit() 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} @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)): - """Atomically take a task — only if unassigned and in backlog/todo.""" - slug = current_member.slug +async def take_task( + task_id: str, + 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) - if task.assignee_slug: - raise HTTPException(409, f"Task already assigned to {task.assignee_slug}") + if task.assignee_id: + 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): raise HTTPException(409, f"Task status is {task.status}, expected backlog or todo") - task.assignee_slug = slug + + task.assignee_id = current_member.id 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 + 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 "" - prefix = proj_slug[:2].upper() if proj_slug else "XX" - key = f"{prefix}-{task.number}" - import datetime - now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") + key = f"{proj_slug[:2].upper()}-{task.number}" await _system_message( db, task, - chat_text=f"{key}: @{slug} взял в работу", - task_text=f"@{slug} взял задачу в работу ({now_str})", - project_slug=proj_slug, - actor_slug=slug, + chat_text=f"{key}: @{current_member.slug} взял в работу", + task_text=f"@{current_member.slug} взял задачу в работу", + project_slug=proj_slug, actor=current_member, ) await db.commit() - # Broadcast task.assigned event from tracker.ws.manager import manager - task_data = { + await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, { "id": str(task.id), - "assignee_slug": slug, - "assigner_slug": slug, # self-assigned - "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) + "assignee_id": str(current_member.id), + "assignee": _member_ref(current_member), + }) + + task_full = await _get_task(task_id, db) + return _task_out(task_full, proj_slug) @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)): - """Reject a task with reason — unassign and return to todo.""" +async def reject_task( + 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) - old_assignee = task.assignee_slug - task.assignee_slug = None + old_assignee_id = task.assignee_id + task.assignee_id = None task.status = TaskStatus.BACKLOG - # Add rejection comment - if old_assignee: - comment = Message( - task_id=task.id, - author_type=AuthorType.SYSTEM, - author_slug=old_assignee, - content=f"Задача отклонена: {req.reason}", - mentions=[], - ) - db.add(comment) + + await _record_action(db, task, current_member, TaskActionType.UNASSIGNED, + "assignee_id", str(old_assignee_id) if old_assignee_id else None, None) + await _record_action(db, task, current_member, TaskActionType.STATUS_CHANGED, + "status", task.status, TaskStatus.BACKLOG) + + comment = Message( + task_id=task.id, + author_type=AuthorType.SYSTEM, + author_id=current_member.id, + content=f"Задача отклонена: {req.reason}", + mentions=[], + ) + db.add(comment) await db.commit() + from tracker.ws.manager import manager - await manager.broadcast_task_event(str(task.project_id), "task.updated", { - "id": str(task.id), "status": TaskStatus.BACKLOG, "assignee_slug": None, + await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_UPDATED, { + "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) -async def assign_task(task_id: str, req: AssignRequest, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): - """Assign task to a member.""" - # Verify assignee exists - result = await db.execute(select(Member).where(Member.slug == req.assignee_slug)) - if not result.scalar_one_or_none(): - raise HTTPException(404, f"Member {req.assignee_slug} not found") +async def assign_task( + task_id: str, + req: AssignRequest, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): + """Assign task to a member by UUID.""" + assignee = await _resolve_member(db, req.assignee_id) task = await _get_task(task_id, db) - task.assignee_slug = req.assignee_slug - if req.assignee_slug not in (task.watchers or []): - task.watchers = (task.watchers or []) + [req.assignee_slug] - await db.commit() - await db.refresh(task) - - # System message + old_assignee_id = task.assignee_id + + task.assignee_id = assignee.id + if assignee.id not in (task.watcher_ids or []): + 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)) + proj_slug = task.project.slug if task.project else "" - prefix = proj_slug[:2].upper() if proj_slug else "XX" - key = f"{prefix}-{task.number}" + key = f"{proj_slug[:2].upper()}-{task.number}" await _system_message( db, task, - chat_text=f"{key}: назначена на @{req.assignee_slug}", - task_text=f"Задача назначена на @{req.assignee_slug}", - project_slug=proj_slug, + chat_text=f"{key}: назначена на @{assignee.slug}", + task_text=f"@{current_member.slug} назначил задачу на @{assignee.slug}", + project_slug=proj_slug, actor=current_member, ) await db.commit() - # Broadcast task.assigned event from tracker.ws.manager import manager - task_data = { + await manager.broadcast_task_event(str(task.project_id), WSEventType.TASK_ASSIGNED, { "id": str(task.id), - "assignee_slug": req.assignee_slug, - "assigner_slug": "admin", # TODO: get from auth context - "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) + "assignee_id": str(assignee.id), + "assignee": _member_ref(assignee), + }) + + task_full = await _get_task(str(task.id), db) + return _task_out(task_full, proj_slug) @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)): - slug = current_member.slug +async def watch_task( + task_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_db), +): task = await _get_task(task_id, db) - if slug not in (task.watchers or []): - task.watchers = (task.watchers or []) + [slug] + 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.WATCHER_ADDED, + "watcher_ids", None, str(current_member.id)) 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") -async def unwatch_task(task_id: str, current_member: Member = Depends(get_current_member), db: AsyncSession = Depends(get_db)): - slug = current_member.slug +async def unwatch_task( + task_id: str, + current_member: Member = Depends(get_current_member), + db: AsyncSession = Depends(get_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() - return {"ok": True, "watchers": task.watchers} + return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]} diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index aebae95..08f0e31 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -163,6 +163,7 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No client = ConnectedClient( ws=ws, session_id=session_id, + member_id=str(member.id), member_slug=effective_slug, member_type=effective_type, chat_listen=chat_listen, diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py index b03943d..504fda7 100644 --- a/src/tracker/ws/manager.py +++ b/src/tracker/ws/manager.py @@ -16,8 +16,9 @@ logger = logging.getLogger("tracker.ws") class ConnectedClient: ws: WebSocket session_id: str # unique per connection - member_slug: str - member_type: str # human | agent | bridge + member_id: str | None = None # UUID as string + member_slug: str = "" + member_type: str = "" # human | agent | bridge chat_listen: str = ListenMode.ALL task_listen: str = ListenMode.ALL 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): """Broadcast task events. Humans get everything, agents filtered.""" - assignee = data.get("assignee_slug") - reviewer = data.get("reviewer_slug") - watchers = data.get("watchers", []) + assignee_id = data.get("assignee_id") + reviewer_id = data.get("reviewer_id") + watcher_ids = data.get("watcher_ids", []) payload = {"type": event_type, "data": data} for session_id, client in list(self.sessions.items()): @@ -136,7 +137,11 @@ class ConnectionManager: if client.task_listen == ListenMode.ALL: await self.send_to_session(session_id, payload) 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) async def broadcast_all(self, data: dict, exclude_slug: str | None = None):