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 secrets
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -96,10 +96,16 @@ def _member_to_out(m: Member) -> dict:
# --- Endpoints --- # --- Endpoints ---
@router.get("/members", response_model=list[MemberOut]) @router.get("/members", response_model=list[MemberOut])
async def list_members(db: AsyncSession = Depends(get_db)): async def list_members(
result = await db.execute( include_inactive: bool = Query(False),
select(Member).options(selectinload(Member.agent_config)) 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()] 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).""" """Quick status update (used by agents)."""
# TODO: get current member from auth # TODO: get current member from auth
return {"status": status} 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 task_id: str | None = None
parent_id: str | None = None parent_id: str | None = None
author_type: str author_type: str
author_slug: str author_id: str
author: dict | None = None # Member object for display
content: str content: str
mentions: list[str] = [] mentions: list[str] = []
voice_url: str | None = None voice_url: str | None = None
@ -44,7 +45,7 @@ class MessageCreate(BaseModel):
task_id: str | None = None task_id: str | None = None
parent_id: str | None = None parent_id: str | None = None
author_type: str = AuthorType.HUMAN author_type: str = AuthorType.HUMAN
author_slug: str = "admin" author_id: str
content: str content: str
mentions: list[str] = [] mentions: list[str] = []
voice_url: str | None = None 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, "task_id": str(m.task_id) if m.task_id else None,
"parent_id": str(m.parent_id) if m.parent_id else None, "parent_id": str(m.parent_id) if m.parent_id else None,
"author_type": m.author_type, "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, "content": m.content,
"mentions": m.mentions or [], "mentions": m.mentions or [],
"voice_url": m.voice_url, "voice_url": m.voice_url,
@ -82,7 +84,7 @@ async def list_messages(
offset: int = Query(0), offset: int = Query(0),
db: AsyncSession = Depends(get_db), 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: if chat_id:
q = q.where(Message.chat_id == uuid.UUID(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, 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, parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
author_type=req.author_type, author_type=req.author_type,
author_slug=req.author_slug, author_id=uuid.UUID(req.author_id),
content=req.content, content=req.content,
mentions=req.mentions, mentions=req.mentions,
voice_url=req.voice_url, voice_url=req.voice_url,
@ -121,15 +123,16 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db))
db.add(msg) db.add(msg)
await db.commit() await db.commit()
result2 = await db.execute( 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() msg = result2.scalar_one()
# Get author name for broadcast # Get author name for broadcast (already loaded via relationship)
from tracker.models import Member author_name = msg.author.name if msg.author else "Unknown"
author_result = await db.execute(select(Member).where(Member.slug == msg.author_slug)) author_slug = msg.author.slug if msg.author else "unknown"
author = author_result.scalar_one_or_none()
author_name = author.name if author else msg.author_slug
# Broadcast via WebSocket # Broadcast via WebSocket
from tracker.ws.manager import manager 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, "chat_id": req.chat_id,
"task_id": req.task_id, "task_id": req.task_id,
"author_type": msg.author_type, "author_type": msg.author_type,
"author_slug": msg.author_slug, "author_id": str(msg.author_id),
"author_slug": author_slug,
"author_name": author_name, "author_name": author_name,
"content": msg.content, "content": msg.content,
"mentions": msg.mentions or [], "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: elif chat and chat.kind == ChatKind.LOBBY:
await manager.broadcast_all( await manager.broadcast_all(
{"type": "message.new", "data": msg_data}, {"type": "message.new", "data": msg_data},
exclude_slug=msg.author_slug, exclude_slug=author_slug,
) )
return _message_out(msg) return _message_out(msg)
if project_id: 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: else:
await manager.broadcast_all( await manager.broadcast_all(
{"type": "message.new", "data": msg_data}, {"type": "message.new", "data": msg_data},
exclude_slug=msg.author_slug, exclude_slug=author_slug,
) )
return _message_out(msg) return _message_out(msg)
@ -175,7 +179,7 @@ async def list_replies(message_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute( result = await db.execute(
select(Message) select(Message)
.where(Message.parent_id == uuid.UUID(message_id)) .where(Message.parent_id == uuid.UUID(message_id))
.options(selectinload(Message.attachments)) .options(selectinload(Message.attachments), selectinload(Message.author))
.order_by(Message.created_at) .order_by(Message.created_at)
) )
return [_message_out(m) for m in result.scalars()] 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 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 from tracker.enums import AuthorType, ChatKind, TaskPriority, TaskStatus, TaskType, TaskActionType
from tracker.models import Task, Step, Project, Member, Message, Chat 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
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@ -38,9 +38,11 @@ class TaskOut(BaseModel):
status: str status: str
priority: str priority: str
labels: list[str] = [] labels: list[str] = []
assignee_slug: str | None = None assignee_id: str | None = None
reviewer_slug: str | None = None assignee: dict | None = None # Member object for display
watchers: list[str] = [] reviewer_id: str | None = None
reviewer: dict | None = None # Member object for display
watcher_ids: list[str] = []
depends_on: list[str] = [] depends_on: list[str] = []
position: int position: int
time_spent: int time_spent: int
@ -55,8 +57,8 @@ class TaskCreate(BaseModel):
priority: str = TaskPriority.MEDIUM priority: str = TaskPriority.MEDIUM
labels: list[str] = [] labels: list[str] = []
parent_id: str | None = None parent_id: str | None = None
assignee_slug: str | None = None assignee_id: str | None = None
reviewer_slug: str | None = None reviewer_id: str | None = None
depends_on: list[str] = [] depends_on: list[str] = []
@ -67,8 +69,8 @@ class TaskUpdate(BaseModel):
status: str | None = None status: str | None = None
priority: str | None = None priority: str | None = None
labels: list[str] | None = None labels: list[str] | None = None
assignee_slug: str | None = None assignee_id: str | None = None
reviewer_slug: str | None = None reviewer_id: str | None = None
position: int | None = None position: int | None = None
@ -77,7 +79,7 @@ class RejectRequest(BaseModel):
class AssignRequest(BaseModel): class AssignRequest(BaseModel):
assignee_slug: str assignee_id: str
# --- Helpers --- # --- Helpers ---
@ -97,7 +99,7 @@ async def _system_message(
chat_text: str, chat_text: str,
task_text: str | None = None, task_text: str | None = None,
project_slug: str = "", project_slug: str = "",
actor_slug: str = "system", actor_id: str | None = None,
): ):
"""Create system messages: one in project chat + one in task comments. """Create system messages: one in project chat + one in task comments.
@ -118,7 +120,7 @@ async def _system_message(
task_msg = Message( task_msg = Message(
task_id=task.id, task_id=task.id,
author_type=AuthorType.SYSTEM, author_type=AuthorType.SYSTEM,
author_slug=actor_slug, author_id=uuid.UUID(actor_id) if actor_id else None,
content=task_text, content=task_text,
mentions=[], mentions=[],
) )
@ -130,7 +132,7 @@ async def _system_message(
chat_msg = Message( chat_msg = Message(
chat_id=chat_id, chat_id=chat_id,
author_type=AuthorType.SYSTEM, author_type=AuthorType.SYSTEM,
author_slug=actor_slug, author_id=uuid.UUID(actor_id) if actor_id else None,
content=chat_text, content=chat_text,
mentions=[], mentions=[],
) )
@ -175,9 +177,11 @@ def _task_out(t: Task, project_slug: str = "") -> dict:
"status": t.status, "status": t.status,
"priority": t.priority, "priority": t.priority,
"labels": t.labels or [], "labels": t.labels or [],
"assignee_slug": t.assignee_slug, "assignee_id": str(t.assignee_id) if t.assignee_id else None,
"reviewer_slug": t.reviewer_slug, "assignee": {"id": str(t.assignee.id), "slug": t.assignee.slug, "name": t.assignee.name} if t.assignee else None,
"watchers": t.watchers or [], "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 [])], "depends_on": [str(d) for d in (t.depends_on or [])],
"position": t.position, "position": t.position,
"time_spent": t.time_spent, "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: async def _get_task(task_id: str, db: AsyncSession) -> Task:
result = await db.execute( result = await db.execute(
select(Task).where(Task.id == uuid.UUID(task_id)) 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() task = result.scalar_one_or_none()
if not task: if not task:
@ -209,13 +218,18 @@ async def list_tasks(
label: Optional[str] = Query(None), label: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), 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: 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:
q = q.where(Task.assignee_slug == assignee) q = q.where(Task.assignee_id == uuid.UUID(assignee))
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)
@ -256,12 +270,21 @@ async def create_task(
status=req.status, status=req.status,
priority=req.priority, priority=req.priority,
labels=req.labels, labels=req.labels,
assignee_slug=req.assignee_slug, assignee_id=uuid.UUID(req.assignee_id) if req.assignee_id else None,
reviewer_slug=req.reviewer_slug, 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 [], 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) 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.commit()
await db.refresh(task) await db.refresh(task)
# Load steps # Load steps
@ -311,7 +334,7 @@ async def update_task(
who = f"@{current_member.slug}" who = f"@{current_member.slug}"
old_status = task.status old_status = task.status
old_assignee = task.assignee_slug old_assignee_id = task.assignee_id
if req.title is not None: if req.title is not None:
task.title = req.title task.title = req.title
@ -325,10 +348,10 @@ async def update_task(
task.priority = req.priority task.priority = req.priority
if req.labels is not None: if req.labels is not None:
task.labels = req.labels task.labels = req.labels
if req.assignee_slug is not None: if req.assignee_id is not None:
task.assignee_slug = req.assignee_slug or None task.assignee_id = uuid.UUID(req.assignee_id) if req.assignee_id else None
if req.reviewer_slug is not None: if req.reviewer_id is not None:
task.reviewer_slug = req.reviewer_slug or None task.reviewer_id = uuid.UUID(req.reviewer_id) if req.reviewer_id else None
if req.position is not None: if req.position is not None:
task.position = req.position task.position = req.position
@ -341,7 +364,7 @@ async def update_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_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 is not None and req.assignee_slug != old_assignee:
if req.assignee_slug: if req.assignee_slug:

View File

@ -69,6 +69,20 @@ class AuthorType(StrEnum):
SYSTEM = "system" 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): class WSEventType(StrEnum):
AUTH = "auth" AUTH = "auth"
AUTH_OK = "auth.ok" AUTH_OK = "auth.ok"

View File

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

View File

@ -11,6 +11,7 @@ from tracker.enums import ChatKind
from tracker.models.base import Base from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from tracker.models.member import Member
from tracker.models.project import Project from tracker.models.project import Project
@ -41,7 +42,7 @@ class Message(Base):
# Author # Author
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system 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
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
@ -50,6 +51,7 @@ class Message(Base):
# Relationships # Relationships
chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id]) 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") attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
replies: Mapped[list["Message"]] = relationship(back_populates="parent") replies: Mapped[list["Message"]] = relationship(back_populates="parent")
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id") 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 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 status: Mapped[str] = mapped_column(String(20), default=MemberStatus.OFFLINE) # online | offline | busy
avatar_url: Mapped[str | None] = mapped_column(String(500)) avatar_url: Mapped[str | None] = mapped_column(String(500))
is_active: Mapped[bool] = mapped_column(default=True)
# Agent-specific config (nullable for humans) # Agent-specific config (nullable for humans)
agent_config: Mapped["AgentConfig | None"] = relationship( 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 from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from tracker.models.member import Member
from tracker.models.project import Project 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 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 priority: Mapped[str] = mapped_column(String(20), default=TaskPriority.MEDIUM) # critical | high | medium | low
labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
assignee_slug: Mapped[str | None] = mapped_column(String(255)) assignee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True)
reviewer_slug: Mapped[str | None] = mapped_column(String(255)) reviewer_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True)
watchers: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # slugs 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) 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 position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
time_spent: Mapped[int] = mapped_column(Integer, default=0) # minutes time_spent: Mapped[int] = mapped_column(Integer, default=0) # minutes
project: Mapped["Project"] = relationship(back_populates="tasks") 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") parent: Mapped["Task | None"] = relationship(remote_side="Task.id", back_populates="subtasks")
subtasks: Mapped[list["Task"]] = relationship(back_populates="parent") subtasks: Mapped[list["Task"]] = relationship(back_populates="parent")
steps: Mapped[list["Step"]] = relationship(back_populates="task", cascade="all, delete-orphan", order_by="Step.position") 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) position: Mapped[int] = mapped_column(Integer, default=0)
task: Mapped["Task"] = relationship(back_populates="steps") 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, chat_id=uuid.UUID(chat_id) if chat_id else None,
task_id=uuid.UUID(task_id) if task_id else None, task_id=uuid.UUID(task_id) if task_id else None,
author_type=member.type, author_type=member.type,
author_slug=member.slug, author_id=member.id,
content=content, content=content,
mentions=mentions, mentions=mentions,
) )
@ -288,6 +288,7 @@ async def _handle_chat_send(session_id: str, data: dict):
"chat_id": chat_id, "chat_id": chat_id,
"task_id": task_id, "task_id": task_id,
"author_type": member.type, "author_type": member.type,
"author_id": str(member.id),
"author_slug": member.slug, "author_slug": member.slug,
"author_name": member.name, "author_name": member.name,
"content": content, "content": content,