refactor: полный UUID рефакторинг tasks.py + audit log + WS member_id
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
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:
parent
41f4bc5ebc
commit
e8f7d04821
@ -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 [])]}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user