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 ..database import get_db
|
||||||
from ..enums import (
|
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 .auth import get_current_member
|
||||||
from .schemas import TaskOut, MessageOut, MemberBrief
|
from .schemas import TaskOut, MessageOut, MemberBrief
|
||||||
from .converters import task_out, message_out, member_brief
|
from .converters import task_out, message_out, member_brief
|
||||||
@ -575,3 +575,131 @@ async def unwatch_task(
|
|||||||
"watcher_ids", str(current_member.id), None)
|
"watcher_ids", str(current_member.id), None)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"ok": True, "watcher_ids": [str(w) for w in (task.watcher_ids or [])]}
|
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
|
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):
|
class MemberType(StrEnum):
|
||||||
HUMAN = "human"
|
HUMAN = "human"
|
||||||
AGENT = "agent"
|
AGENT = "agent"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
from .member import AgentConfig, Member
|
from .member import AgentConfig, Member
|
||||||
from .project import Project, ProjectMember
|
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 .chat import Attachment, Chat, Message
|
||||||
from .project_file import ProjectFile
|
from .project_file import ProjectFile
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ __all__ = [
|
|||||||
"ProjectMember",
|
"ProjectMember",
|
||||||
"Task",
|
"Task",
|
||||||
"TaskAction",
|
"TaskAction",
|
||||||
|
"TaskLink",
|
||||||
"Step",
|
"Step",
|
||||||
"Chat",
|
"Chat",
|
||||||
"Message",
|
"Message",
|
||||||
|
|||||||
@ -53,6 +53,18 @@ class Step(Base):
|
|||||||
task: Mapped["Task"] = relationship(back_populates="steps")
|
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):
|
class TaskAction(Base):
|
||||||
__tablename__ = "task_actions"
|
__tablename__ = "task_actions"
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user