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."""
|
"""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 [])]}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user