From 41f4bc5ebc87705cec85d29406c99886a68f9725 Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 00:13:30 +0100 Subject: [PATCH] refactor: UUID-based member identification + task audit log + soft delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 записей при изменениях --- src/tracker/api/members.py | 29 ++++++++++--- src/tracker/api/messages.py | 36 +++++++++------- src/tracker/api/tasks.py | 79 ++++++++++++++++++++++------------ src/tracker/enums.py | 14 ++++++ src/tracker/models/__init__.py | 3 +- src/tracker/models/chat.py | 4 +- src/tracker/models/member.py | 1 + src/tracker/models/task.py | 23 ++++++++-- src/tracker/ws/handler.py | 3 +- 9 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py index 3fbdfed..0e01dea 100644 --- a/src/tracker/api/members.py +++ b/src/tracker/api/members.py @@ -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} diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py index f27a685..abd2bc3 100644 --- a/src/tracker/api/messages.py +++ b/src/tracker/api/messages.py @@ -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()] diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index 999fe39..8ff567f 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -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: diff --git a/src/tracker/enums.py b/src/tracker/enums.py index 2fbd468..f9ed9bb 100644 --- a/src/tracker/enums.py +++ b/src/tracker/enums.py @@ -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" diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index 7c2c35b..b423cbe 100644 --- a/src/tracker/models/__init__.py +++ b/src/tracker/models/__init__.py @@ -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", diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py index 86946c4..aa2a64d 100644 --- a/src/tracker/models/chat.py +++ b/src/tracker/models/chat.py @@ -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") diff --git a/src/tracker/models/member.py b/src/tracker/models/member.py index 63cd7b7..c1e2a67 100644 --- a/src/tracker/models/member.py +++ b/src/tracker/models/member.py @@ -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( diff --git a/src/tracker/models/task.py b/src/tracker/models/task.py index 79a8855..603e263 100644 --- a/src/tracker/models/task.py +++ b/src/tracker/models/task.py @@ -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() diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index c7448da..aebae95 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -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,