Files
leocrm/alembic/versions/0124_unified_task_system.py
T
2026-08-23 21:36:56 +02:00

135 lines
5.9 KiB
Python

"""Unified Task System (F.14).
Adds polymorphic assignment/entity/creator fields, subtasks, dependencies,
goals/milestones and agent-subtask support to the tasks table. Migrates
legacy ``contact_id``/``assigned_to`` values into the polymorphic fields and
migrates existing ``agent_subtasks`` rows into tasks with
``task_type='agent_subtask'``.
Revision ID: 0124
Revises: 0123
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
revision = "0124"
down_revision = "0123"
branch_labels = None
depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the tasks plugin SQL migration has not run yet when
Alembic reaches this revision — skip instead of failing. The plugin-side
convergence migration adds the same columns/indexes. The legacy-data
backfills below only matter for pre-existing rows and are correctly
empty on a fresh install.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# ── Add new columns to tasks ────────────────────────────────────────────
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
op.add_column("tasks", sa.Column("entity_type", sa.String(80), nullable=True))
op.add_column("tasks", sa.Column("entity_id", PGUUID(as_uuid=True), nullable=True))
op.add_column("tasks", sa.Column("creator_type", sa.String(20), nullable=False, server_default="user"))
op.add_column("tasks", sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True))
op.add_column("tasks", sa.Column("parent_task_id", PGUUID(as_uuid=True), nullable=True))
op.add_column("tasks", sa.Column("depends_on", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")))
op.add_column("tasks", sa.Column("task_type", sa.String(30), nullable=False, server_default="todo"))
op.add_column("tasks", sa.Column("success_criteria", JSONB, nullable=True))
op.add_column("tasks", sa.Column("target_date", sa.DateTime(timezone=True), nullable=True))
op.add_column("tasks", sa.Column("progress", sa.Integer(), nullable=False, server_default="0"))
# ── Migrate legacy data into polymorphic fields ─────────────────────────
# contact_id → entity_type='contact' + entity_id
op.execute(
"""
UPDATE tasks
SET entity_type = 'contact', entity_id = contact_id
WHERE contact_id IS NOT NULL AND entity_type IS NULL
"""
)
# assigned_to → assignee_type='user' + assignee_id
op.execute(
"""
UPDATE tasks
SET assignee_type = 'user', assignee_id = assigned_to
WHERE assigned_to IS NOT NULL AND assignee_id IS NULL
"""
)
# created_by → creator_type='user' + creator_id
op.execute(
"""
UPDATE tasks
SET creator_type = 'user', creator_id = created_by
WHERE created_by IS NOT NULL AND creator_id IS NULL
"""
)
# ── Migrate AgentSubtask rows into tasks ────────────────────────────────
op.execute(
"""
INSERT INTO tasks (
id, tenant_id, title, description, status, priority,
assignee_type, assignee_id, entity_type, entity_id,
creator_type, creator_id, task_type, depends_on, progress,
created_at, updated_at
)
SELECT
asub.id, asub.tenant_id,
asub.task_description, asub.task_description, asub.status, 'medium',
'agent', asub.child_agent_id, 'agent', asub.parent_agent_id,
'agent', asub.parent_agent_id, 'agent_subtask', '[]'::jsonb, 0,
asub.created_at, asub.updated_at
FROM agent_subtasks asub
WHERE NOT EXISTS (
SELECT 1 FROM tasks t WHERE t.id = asub.id
)
"""
)
# ── Indexes ─────────────────────────────────────────────────────────────
op.create_index("ix_tasks_tenant_entity", "tasks", ["tenant_id", "entity_type", "entity_id"])
op.create_index("ix_tasks_tenant_assignee", "tasks", ["tenant_id", "assignee_type", "assignee_id"])
op.create_index("ix_tasks_tenant_parent", "tasks", ["tenant_id", "parent_task_id"])
op.create_index("ix_tasks_tenant_type", "tasks", ["tenant_id", "task_type"])
op.create_foreign_key(
"fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey")
op.drop_index("ix_tasks_tenant_type", table_name="tasks")
op.drop_index("ix_tasks_tenant_parent", table_name="tasks")
op.drop_index("ix_tasks_tenant_assignee", table_name="tasks")
op.drop_index("ix_tasks_tenant_entity", table_name="tasks")
op.drop_column("tasks", "progress")
op.drop_column("tasks", "target_date")
op.drop_column("tasks", "success_criteria")
op.drop_column("tasks", "task_type")
op.drop_column("tasks", "depends_on")
op.drop_column("tasks", "parent_task_id")
op.drop_column("tasks", "creator_id")
op.drop_column("tasks", "creator_type")
op.drop_column("tasks", "entity_id")
op.drop_column("tasks", "entity_type")
op.drop_column("tasks", "assignee_id")
op.drop_column("tasks", "assignee_type")