Task dependencies (Jira-style links)
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
- TaskLink model: source_id, target_id, link_type (blocks|depends_on|relates_to)
- TaskLinkType enum in enums.py
- API: POST/GET/DELETE /tasks/{id}/links
- GET returns both outgoing and incoming links with reverse types (blocked_by, required_by)
This commit is contained in:
parent
0aab543826
commit
a65987a724
@ -12,9 +12,9 @@ from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from ..database import get_db
|
||||
from ..enums import (
|
||||
AuthorType, ChatKind, TaskActionType, TaskPriority, TaskStatus, TaskType, WSEventType,
|
||||
AuthorType, ChatKind, TaskActionType, TaskLinkType, TaskPriority, TaskStatus, TaskType, WSEventType,
|
||||
)
|
||||
from ..models import Task, Step, Project, Member, Message, Chat, TaskAction
|
||||
from ..models import Task, Step, Project, Member, Message, Chat, TaskAction, TaskLink
|
||||
from .auth import get_current_member
|
||||
from .schemas import TaskOut, MessageOut, MemberBrief
|
||||
from .converters import task_out, message_out, member_brief
|
||||
@ -575,3 +575,131 @@ async def unwatch_task(
|
||||
"watcher_ids", str(current_member.id), None)
|
||||
await db.commit()
|
||||
return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]}
|
||||
|
||||
|
||||
# --- Task Links (Dependencies) ---
|
||||
|
||||
class TaskLinkCreate(BaseModel):
|
||||
target_id: str
|
||||
link_type: TaskLinkType
|
||||
|
||||
|
||||
class TaskLinkOut(BaseModel):
|
||||
id: str
|
||||
source_id: str
|
||||
target_id: str
|
||||
link_type: str
|
||||
target_key: str | None = None
|
||||
target_title: str | None = None
|
||||
source_key: str | None = None
|
||||
source_title: str | None = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/links", response_model=TaskLinkOut)
|
||||
async def create_task_link(
|
||||
task_id: str,
|
||||
req: TaskLinkCreate,
|
||||
current_member: Member = Depends(get_current_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
source = await _get_task(task_id, db)
|
||||
target = await _get_task(req.target_id, db)
|
||||
|
||||
if source.id == target.id:
|
||||
raise HTTPException(400, "Cannot link task to itself")
|
||||
|
||||
# Check for duplicate
|
||||
existing = await db.execute(
|
||||
select(TaskLink).where(
|
||||
TaskLink.source_id == source.id,
|
||||
TaskLink.target_id == target.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "Link already exists")
|
||||
|
||||
link = TaskLink(
|
||||
source_id=source.id,
|
||||
target_id=target.id,
|
||||
link_type=req.link_type,
|
||||
)
|
||||
db.add(link)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
|
||||
return TaskLinkOut(
|
||||
id=str(link.id),
|
||||
source_id=str(link.source_id),
|
||||
target_id=str(link.target_id),
|
||||
link_type=link.link_type,
|
||||
target_key=f"{target.project.slug.upper()[:2]}-{target.number}" if hasattr(target, 'project') else None,
|
||||
target_title=target.title,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/links", response_model=list[TaskLinkOut])
|
||||
async def list_task_links(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _get_task(task_id, db)
|
||||
|
||||
# Links where this task is source
|
||||
outgoing = await db.execute(
|
||||
select(TaskLink).where(TaskLink.source_id == task.id)
|
||||
.options(joinedload(TaskLink.target))
|
||||
)
|
||||
# Links where this task is target
|
||||
incoming = await db.execute(
|
||||
select(TaskLink).where(TaskLink.target_id == task.id)
|
||||
.options(joinedload(TaskLink.source))
|
||||
)
|
||||
|
||||
result = []
|
||||
for link in outgoing.scalars():
|
||||
t = link.target
|
||||
result.append(TaskLinkOut(
|
||||
id=str(link.id),
|
||||
source_id=str(link.source_id),
|
||||
target_id=str(link.target_id),
|
||||
link_type=link.link_type,
|
||||
target_title=t.title if t else None,
|
||||
))
|
||||
for link in incoming.scalars():
|
||||
# Reverse: if source blocks this task, show as "blocked by"
|
||||
s = link.source
|
||||
reverse_type = link.link_type
|
||||
if link.link_type == TaskLinkType.BLOCKS:
|
||||
reverse_type = "blocked_by"
|
||||
elif link.link_type == TaskLinkType.DEPENDS_ON:
|
||||
reverse_type = "required_by"
|
||||
result.append(TaskLinkOut(
|
||||
id=str(link.id),
|
||||
source_id=str(link.source_id),
|
||||
target_id=str(link.target_id),
|
||||
link_type=reverse_type,
|
||||
source_title=s.title if s else None,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}/links/{link_id}")
|
||||
async def delete_task_link(
|
||||
task_id: str,
|
||||
link_id: str,
|
||||
current_member: Member = Depends(get_current_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
link = await db.execute(
|
||||
select(TaskLink).where(TaskLink.id == uuid.UUID(link_id))
|
||||
)
|
||||
link_obj = link.scalar_one_or_none()
|
||||
if not link_obj:
|
||||
raise HTTPException(404, "Link not found")
|
||||
if str(link_obj.source_id) != task_id and str(link_obj.target_id) != task_id:
|
||||
raise HTTPException(400, "Link does not belong to this task")
|
||||
|
||||
await db.delete(link_obj)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@ -3,6 +3,13 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TaskLinkType(StrEnum):
|
||||
"""Jira-style task link types. source → target relationship."""
|
||||
BLOCKS = "blocks" # source blocks target (target is blocked by source)
|
||||
DEPENDS_ON = "depends_on" # source depends on target (source is blocked by target)
|
||||
RELATES_TO = "relates_to" # bidirectional, informational
|
||||
|
||||
|
||||
class MemberType(StrEnum):
|
||||
HUMAN = "human"
|
||||
AGENT = "agent"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from .base import Base
|
||||
from .member import AgentConfig, Member
|
||||
from .project import Project, ProjectMember
|
||||
from .task import Step, Task, TaskAction
|
||||
from .task import Step, Task, TaskAction, TaskLink
|
||||
from .chat import Attachment, Chat, Message
|
||||
from .project_file import ProjectFile
|
||||
|
||||
@ -15,6 +15,7 @@ __all__ = [
|
||||
"ProjectMember",
|
||||
"Task",
|
||||
"TaskAction",
|
||||
"TaskLink",
|
||||
"Step",
|
||||
"Chat",
|
||||
"Message",
|
||||
|
||||
@ -53,6 +53,18 @@ class Step(Base):
|
||||
task: Mapped["Task"] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
class TaskLink(Base):
|
||||
"""Jira-style task dependencies/links."""
|
||||
__tablename__ = "task_links"
|
||||
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
|
||||
target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
|
||||
link_type: Mapped[str] = mapped_column(String(20), nullable=False) # blocks | depends_on | relates_to
|
||||
|
||||
source: Mapped["Task"] = relationship(foreign_keys=[source_id])
|
||||
target: Mapped["Task"] = relationship(foreign_keys=[target_id])
|
||||
|
||||
|
||||
class TaskAction(Base):
|
||||
__tablename__ = "task_actions"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user