b91c447b0b
First slice of the Issues + Systems feature (spec #825, plan #819 T2). Schema (migration 0065): - task_kind CHECK expands work|plan -> work|plan|issue (same-change, rule 36) - notes.arose_from_id: optional self-FK for issue->originating-task provenance (distinct from parent_id sub-task hierarchy) - systems: per-project, self-describing (name + description) subsystem/area - record_systems: M2M join linking any note/task/issue to systems (mutable) Models: System + RecordSystem; note.py gains arose_from_id (+ index, to_dict). Service services/systems.py: CRUD, archive, soft-delete, set/list associations, records-for-system, open-issue count — all gated via services/access.py project permissions (rule 78, no bare-owner filters). Unit tests lock the ACL gating; the migration is exercised by CI's integration lane (alembic upgrade head). is_task stays a derived property (status is not None) — unchanged. T1 (typing- axis rationalization) intentionally NOT bundled; this only adds the enum value. Refs plan 825 (S1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
|
|
|
|
Revision ID: 0065
|
|
Revises: 0064
|
|
Create Date: 2026-06-14
|
|
|
|
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
|
|
'new CHECK-enum values need a same-change migration' rule), a per-project
|
|
self-describing System entity, a many-to-many record<->system join (any
|
|
note/task/issue), and an issue->originating-task provenance FK.
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
revision = "0065"
|
|
down_revision = "0064"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
|
|
# same change that introduces the value.
|
|
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
|
op.create_check_constraint(
|
|
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
|
|
)
|
|
|
|
# 2. Provenance: an issue can point back at the task/feature it arose from.
|
|
# Distinct from parent_id (sub-task hierarchy).
|
|
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
|
|
op.create_foreign_key(
|
|
"fk_notes_arose_from_id", "notes", "notes",
|
|
["arose_from_id"], ["id"], ondelete="SET NULL",
|
|
)
|
|
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
|
|
|
|
# 3. systems: per-project, reusable, self-describing subsystem/area.
|
|
op.create_table(
|
|
"systems",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(
|
|
"user_id", sa.Integer(),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
|
),
|
|
sa.Column(
|
|
"project_id", sa.Integer(),
|
|
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
|
|
),
|
|
sa.Column("name", sa.Text(), nullable=False, server_default=""),
|
|
sa.Column("description", sa.Text(), nullable=True),
|
|
sa.Column("color", sa.Text(), nullable=True),
|
|
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
|
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
|
sa.Column(
|
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column(
|
|
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
|
)
|
|
op.create_index("ix_systems_project_id", "systems", ["project_id"])
|
|
op.create_check_constraint(
|
|
"systems_status_check", "systems", "status IN ('active','archived')",
|
|
)
|
|
|
|
# 4. record_systems: M2M join — any note/task/issue <-> system.
|
|
op.create_table(
|
|
"record_systems",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(
|
|
"note_id", sa.Integer(),
|
|
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
|
|
),
|
|
sa.Column(
|
|
"system_id", sa.Integer(),
|
|
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
|
|
),
|
|
sa.Column(
|
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.text("now()"),
|
|
),
|
|
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
|
|
)
|
|
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
|
|
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_table("record_systems")
|
|
op.drop_table("systems")
|
|
op.drop_index("ix_notes_arose_from_id", table_name="notes")
|
|
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
|
|
op.drop_column("notes", "arose_from_id")
|
|
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
|
op.create_check_constraint(
|
|
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
|
|
)
|