refactor: UUID-based member identification + task audit log + soft delete
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s

- Member: добавлен is_active для soft delete
- Message: author_id (UUID FK) вместо author_slug
- Task: assignee_id, reviewer_id (UUID FK), watcher_ids (UUID[])
- Новая модель TaskAction для аудита действий с задачами
- Новый enum TaskActionType
- API обновлен под новые поля с relationships
- WS handler использует author_id вместо author_slug
- Soft delete для members через is_active=False
- Автоматическое создание TaskAction записей при изменениях
This commit is contained in:
markov 2026-02-25 00:13:30 +01:00
parent daf3f06dcb
commit 41f4bc5ebc
9 changed files with 137 additions and 55 deletions

View File

@ -3,7 +3,7 @@
import secrets
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -96,10 +96,16 @@ def _member_to_out(m: Member) -> dict:
# --- Endpoints ---
@router.get("/members", response_model=list[MemberOut])
async def list_members(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Member).options(selectinload(Member.agent_config))
)
async def list_members(
include_inactive: bool = Query(False),
db: AsyncSession = Depends(get_db)
):
query = select(Member).options(selectinload(Member.agent_config))
if not include_inactive:
query = query.where(Member.is_active == True)
result = await db.execute(query)
return [_member_to_out(m) for m in result.scalars()]
@ -237,3 +243,16 @@ async def update_my_status(status: str, db: AsyncSession = Depends(get_db)):
"""Quick status update (used by agents)."""
# TODO: get current member from auth
return {"status": status}
@router.delete("/members/{slug}")
async def delete_member(slug: str, db: AsyncSession = Depends(get_db)):
"""Soft delete member by setting is_active=False."""
result = await db.execute(select(Member).where(Member.slug == slug))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
member.is_active = False
await db.commit()
return {"ok": True}

View File

@ -31,7 +31,8 @@ class MessageOut(BaseModel):
task_id: str | None = None
parent_id: str | None = None
author_type: str
author_slug: str
author_id: str
author: dict | None = None # Member object for display
content: str
mentions: list[str] = []
voice_url: str | None = None
@ -44,7 +45,7 @@ class MessageCreate(BaseModel):
task_id: str | None = None
parent_id: str | None = None
author_type: str = AuthorType.HUMAN
author_slug: str = "admin"
author_id: str
content: str
mentions: list[str] = []
voice_url: str | None = None
@ -59,7 +60,8 @@ def _message_out(m: Message) -> dict:
"task_id": str(m.task_id) if m.task_id else None,
"parent_id": str(m.parent_id) if m.parent_id else None,
"author_type": m.author_type,
"author_slug": m.author_slug,
"author_id": str(m.author_id),
"author": {"id": str(m.author.id), "slug": m.author.slug, "name": m.author.name} if m.author else None,
"content": m.content,
"mentions": m.mentions or [],
"voice_url": m.voice_url,
@ -82,7 +84,7 @@ async def list_messages(
offset: int = Query(0),
db: AsyncSession = Depends(get_db),
):
q = select(Message).options(selectinload(Message.attachments))
q = select(Message).options(selectinload(Message.attachments), selectinload(Message.author))
if chat_id:
q = q.where(Message.chat_id == uuid.UUID(chat_id))
@ -113,7 +115,7 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db))
task_id=uuid.UUID(req.task_id) if req.task_id else None,
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
author_type=req.author_type,
author_slug=req.author_slug,
author_id=uuid.UUID(req.author_id),
content=req.content,
mentions=req.mentions,
voice_url=req.voice_url,
@ -121,15 +123,16 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db))
db.add(msg)
await db.commit()
result2 = await db.execute(
select(Message).where(Message.id == msg.id).options(selectinload(Message.attachments))
select(Message).where(Message.id == msg.id).options(
selectinload(Message.attachments),
selectinload(Message.author)
)
)
msg = result2.scalar_one()
# Get author name for broadcast
from tracker.models import Member
author_result = await db.execute(select(Member).where(Member.slug == msg.author_slug))
author = author_result.scalar_one_or_none()
author_name = author.name if author else msg.author_slug
# Get author name for broadcast (already loaded via relationship)
author_name = msg.author.name if msg.author else "Unknown"
author_slug = msg.author.slug if msg.author else "unknown"
# Broadcast via WebSocket
from tracker.ws.manager import manager
@ -138,7 +141,8 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db))
"chat_id": req.chat_id,
"task_id": req.task_id,
"author_type": msg.author_type,
"author_slug": msg.author_slug,
"author_id": str(msg.author_id),
"author_slug": author_slug,
"author_name": author_name,
"content": msg.content,
"mentions": msg.mentions or [],
@ -154,16 +158,16 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db))
elif chat and chat.kind == ChatKind.LOBBY:
await manager.broadcast_all(
{"type": "message.new", "data": msg_data},
exclude_slug=msg.author_slug,
exclude_slug=author_slug,
)
return _message_out(msg)
if project_id:
await manager.broadcast_message(project_id, msg_data, author_slug=msg.author_slug)
await manager.broadcast_message(project_id, msg_data, author_slug=author_slug)
else:
await manager.broadcast_all(
{"type": "message.new", "data": msg_data},
exclude_slug=msg.author_slug,
exclude_slug=author_slug,
)
return _message_out(msg)
@ -175,7 +179,7 @@ async def list_replies(message_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Message)
.where(Message.parent_id == uuid.UUID(message_id))
.options(selectinload(Message.attachments))
.options(selectinload(Message.attachments), selectinload(Message.author))
.order_by(Message.created_at)
)
return [_message_out(m) for m in result.scalars()]

View File

@ -10,8 +10,8 @@ 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
from tracker.models import Task, Step, Project, Member, Message, Chat
from tracker.enums import AuthorType, ChatKind, TaskPriority, TaskStatus, TaskType, TaskActionType
from tracker.models import Task, Step, Project, Member, Message, Chat, TaskAction
from tracker.api.auth import get_current_member
router = APIRouter(tags=["tasks"])
@ -38,9 +38,11 @@ class TaskOut(BaseModel):
status: str
priority: str
labels: list[str] = []
assignee_slug: str | None = None
reviewer_slug: str | None = None
watchers: list[str] = []
assignee_id: str | None = None
assignee: dict | None = None # Member object for display
reviewer_id: str | None = None
reviewer: dict | None = None # Member object for display
watcher_ids: list[str] = []
depends_on: list[str] = []
position: int
time_spent: int
@ -55,8 +57,8 @@ class TaskCreate(BaseModel):
priority: str = TaskPriority.MEDIUM
labels: list[str] = []
parent_id: str | None = None
assignee_slug: str | None = None
reviewer_slug: str | None = None
assignee_id: str | None = None
reviewer_id: str | None = None
depends_on: list[str] = []
@ -67,8 +69,8 @@ class TaskUpdate(BaseModel):
status: str | None = None
priority: str | None = None
labels: list[str] | None = None
assignee_slug: str | None = None
reviewer_slug: str | None = None
assignee_id: str | None = None
reviewer_id: str | None = None
position: int | None = None
@ -77,7 +79,7 @@ class RejectRequest(BaseModel):
class AssignRequest(BaseModel):
assignee_slug: str
assignee_id: str
# --- Helpers ---
@ -97,7 +99,7 @@ async def _system_message(
chat_text: str,
task_text: str | None = None,
project_slug: str = "",
actor_slug: str = "system",
actor_id: str | None = None,
):
"""Create system messages: one in project chat + one in task comments.
@ -118,7 +120,7 @@ async def _system_message(
task_msg = Message(
task_id=task.id,
author_type=AuthorType.SYSTEM,
author_slug=actor_slug,
author_id=uuid.UUID(actor_id) if actor_id else None,
content=task_text,
mentions=[],
)
@ -130,7 +132,7 @@ async def _system_message(
chat_msg = Message(
chat_id=chat_id,
author_type=AuthorType.SYSTEM,
author_slug=actor_slug,
author_id=uuid.UUID(actor_id) if actor_id else None,
content=chat_text,
mentions=[],
)
@ -175,9 +177,11 @@ def _task_out(t: Task, project_slug: str = "") -> dict:
"status": t.status,
"priority": t.priority,
"labels": t.labels or [],
"assignee_slug": t.assignee_slug,
"reviewer_slug": t.reviewer_slug,
"watchers": t.watchers 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,
"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,
"watcher_ids": [str(w) for w in (t.watcher_ids or [])],
"depends_on": [str(d) for d in (t.depends_on or [])],
"position": t.position,
"time_spent": t.time_spent,
@ -191,7 +195,12 @@ def _task_out(t: Task, project_slug: str = "") -> dict:
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), joinedload(Task.project))
.options(
selectinload(Task.steps),
joinedload(Task.project),
joinedload(Task.assignee),
joinedload(Task.reviewer)
)
)
task = result.scalar_one_or_none()
if not task:
@ -209,13 +218,18 @@ async def list_tasks(
label: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
q = select(Task).options(selectinload(Task.steps), joinedload(Task.project))
q = select(Task).options(
selectinload(Task.steps),
joinedload(Task.project),
joinedload(Task.assignee),
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_slug == assignee)
q = q.where(Task.assignee_id == uuid.UUID(assignee))
if label:
q = q.where(Task.labels.contains([label]))
q = q.order_by(Task.position, Task.created_at)
@ -256,12 +270,21 @@ async def create_task(
status=req.status,
priority=req.priority,
labels=req.labels,
assignee_slug=req.assignee_slug,
reviewer_slug=req.reviewer_slug,
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,
depends_on=[uuid.UUID(d) for d in req.depends_on] if req.depends_on else [],
watchers=[req.assignee_slug] if req.assignee_slug else [],
watcher_ids=[uuid.UUID(req.assignee_id)] if req.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 db.commit()
await db.refresh(task)
# Load steps
@ -311,7 +334,7 @@ async def update_task(
who = f"@{current_member.slug}"
old_status = task.status
old_assignee = task.assignee_slug
old_assignee_id = task.assignee_id
if req.title is not None:
task.title = req.title
@ -325,10 +348,10 @@ async def update_task(
task.priority = req.priority
if req.labels is not None:
task.labels = req.labels
if req.assignee_slug is not None:
task.assignee_slug = req.assignee_slug or None
if req.reviewer_slug is not None:
task.reviewer_slug = req.reviewer_slug or None
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
@ -341,7 +364,7 @@ async def update_task(
chat_text=f"{key}: {old_status}{req.status}",
task_text=f"{who} изменил статус: {old_status}{req.status} ({now_str})",
project_slug=slug,
actor_slug=current_member.slug,
actor_id=str(current_member.id),
)
if req.assignee_slug is not None and req.assignee_slug != old_assignee:
if req.assignee_slug:

View File

@ -69,6 +69,20 @@ class AuthorType(StrEnum):
SYSTEM = "system"
class TaskActionType(StrEnum):
CREATED = "created"
STATUS_CHANGED = "status_changed"
ASSIGNED = "assigned"
UNASSIGNED = "unassigned"
PRIORITY_CHANGED = "priority_changed"
TITLE_CHANGED = "title_changed"
DESCRIPTION_CHANGED = "description_changed"
REVIEWER_CHANGED = "reviewer_changed"
WATCHER_ADDED = "watcher_added"
WATCHER_REMOVED = "watcher_removed"
DELETED = "deleted"
class WSEventType(StrEnum):
AUTH = "auth"
AUTH_OK = "auth.ok"

View File

@ -3,7 +3,7 @@
from tracker.models.base import Base
from tracker.models.member import AgentConfig, Member
from tracker.models.project import Project, ProjectMember
from tracker.models.task import Step, Task
from tracker.models.task import Step, Task, TaskAction
from tracker.models.chat import Attachment, Chat, Message
__all__ = [
@ -13,6 +13,7 @@ __all__ = [
"Project",
"ProjectMember",
"Task",
"TaskAction",
"Step",
"Chat",
"Message",

View File

@ -11,6 +11,7 @@ from tracker.enums import ChatKind
from tracker.models.base import Base
if TYPE_CHECKING:
from tracker.models.member import Member
from tracker.models.project import Project
@ -41,7 +42,7 @@ class Message(Base):
# Author
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system
author_slug: Mapped[str] = mapped_column(String(255), nullable=False)
author_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("members.id"), nullable=False)
# Content
content: Mapped[str] = mapped_column(Text, nullable=False)
@ -50,6 +51,7 @@ class Message(Base):
# Relationships
chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id])
author: Mapped["Member"] = relationship()
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
replies: Mapped[list["Message"]] = relationship(back_populates="parent")
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id")

View File

@ -26,6 +26,7 @@ class Member(Base):
token: Mapped[str | None] = mapped_column(String(255), unique=True) # for agents/bridges
status: Mapped[str] = mapped_column(String(20), default=MemberStatus.OFFLINE) # online | offline | busy
avatar_url: Mapped[str | None] = mapped_column(String(500))
is_active: Mapped[bool] = mapped_column(default=True)
# Agent-specific config (nullable for humans)
agent_config: Mapped["AgentConfig | None"] = relationship(

View File

@ -11,6 +11,7 @@ from tracker.enums import TaskPriority, TaskStatus, TaskType
from tracker.models.base import Base
if TYPE_CHECKING:
from tracker.models.member import Member
from tracker.models.project import Project
@ -26,14 +27,16 @@ class Task(Base):
status: Mapped[str] = mapped_column(String(20), default=TaskStatus.BACKLOG) # backlog | todo | in_progress | in_review | done
priority: Mapped[str] = mapped_column(String(20), default=TaskPriority.MEDIUM) # critical | high | medium | low
labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
assignee_slug: Mapped[str | None] = mapped_column(String(255))
reviewer_slug: Mapped[str | None] = mapped_column(String(255))
watchers: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # slugs
assignee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True)
reviewer_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True)
watcher_ids: Mapped[list[uuid.UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
depends_on: Mapped[list[uuid.UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
time_spent: Mapped[int] = mapped_column(Integer, default=0) # minutes
project: Mapped["Project"] = relationship(back_populates="tasks")
assignee: Mapped["Member | None"] = relationship(foreign_keys=[assignee_id])
reviewer: Mapped["Member | None"] = relationship(foreign_keys=[reviewer_id])
parent: Mapped["Task | None"] = relationship(remote_side="Task.id", back_populates="subtasks")
subtasks: Mapped[list["Task"]] = relationship(back_populates="parent")
steps: Mapped[list["Step"]] = relationship(back_populates="task", cascade="all, delete-orphan", order_by="Step.position")
@ -48,3 +51,17 @@ class Step(Base):
position: Mapped[int] = mapped_column(Integer, default=0)
task: Mapped["Task"] = relationship(back_populates="steps")
class TaskAction(Base):
__tablename__ = "task_actions"
task_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"))
actor_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("members.id"))
action: Mapped[str] = mapped_column(String(50), nullable=False) # created, status_changed, assigned, etc.
field: Mapped[str | None] = mapped_column(String(100)) # какое поле изменилось
old_value: Mapped[str | None] = mapped_column(Text)
new_value: Mapped[str | None] = mapped_column(Text)
task: Mapped["Task"] = relationship()
actor: Mapped["Member"] = relationship()

View File

@ -275,7 +275,7 @@ async def _handle_chat_send(session_id: str, data: dict):
chat_id=uuid.UUID(chat_id) if chat_id else None,
task_id=uuid.UUID(task_id) if task_id else None,
author_type=member.type,
author_slug=member.slug,
author_id=member.id,
content=content,
mentions=mentions,
)
@ -288,6 +288,7 @@ async def _handle_chat_send(session_id: str, data: dict):
"chat_id": chat_id,
"task_id": task_id,
"author_type": member.type,
"author_id": str(member.id),
"author_slug": member.slug,
"author_name": member.name,
"content": content,