feat(issues): S1 schema — issue task_kind, System entity, associations
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m5s

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>
This commit is contained in:
2026-06-13 22:53:51 -04:00
co-authored by Claude Opus 4.8
parent 88106309f4
commit b91c447b0b
6 changed files with 468 additions and 3 deletions
+209
View File
@@ -0,0 +1,209 @@
"""System management + record<->system associations.
A System is a per-project, reusable, self-describing subsystem/area. Access is
governed by the project's permission via services/access.py (multi-user ACL
rule) — never a bare owner filter. Records (notes/tasks/issues) link to systems
many-to-many through record_systems, mutable over time.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.system import RecordSystem, System
from scribe.services import access
logger = logging.getLogger(__name__)
async def create_system(
user_id: int,
project_id: int,
name: str,
description: str | None = None,
color: str | None = None,
order_index: int = 0,
) -> System | None:
"""Create a System. None if the user can't write the project."""
if not await access.can_write_project(user_id, project_id):
return None
async with async_session() as session:
system = System(
user_id=user_id,
project_id=project_id,
name=name.strip(),
description=description,
color=color,
order_index=order_index,
)
session.add(system)
await session.commit()
await session.refresh(system)
return system
async def get_system(user_id: int, system_id: int) -> System | None:
"""Fetch a System if the user can read its project."""
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_read_project(user_id, system.project_id):
return None
return system
async def list_systems(
user_id: int, project_id: int, include_archived: bool = False
) -> list[System]:
"""A project's systems (active by default). [] if no read access."""
if not await access.can_read_project(user_id, project_id):
return []
async with async_session() as session:
query = select(System).where(
System.project_id == project_id,
System.deleted_at.is_(None),
)
if not include_archived:
query = query.where(System.status == "active")
query = query.order_by(System.order_index.asc(), System.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
"""Update a System if the user can write its project."""
allowed = {"name", "description", "color", "status", "order_index"}
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_write_project(user_id, system.project_id):
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(system, key, value)
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def archive_system(user_id: int, system_id: int) -> System | None:
return await update_system(user_id, system_id, status="archived")
async def delete_system(user_id: int, system_id: int) -> bool:
"""Soft-delete a System (recoverable). Requires project write access."""
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return False
if not await access.can_write_project(user_id, system.project_id):
return False
system.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- record <-> system associations (many-to-many, mutable) ---
async def set_record_systems(
user_id: int, note_id: int, system_ids: list[int]
) -> list[int] | None:
"""Replace a record's system associations with `system_ids` (set semantics).
Returns the resulting associated system ids, or None if the user can't write
the record. Silently drops ids that don't exist or the user can't read —
associations only link accessible systems.
"""
if not await access.can_write_note(user_id, note_id):
return None
async with async_session() as session:
wanted: list[int] = []
for sid in dict.fromkeys(system_ids): # de-dup, preserve order
system = await session.get(System, sid)
if system is None or system.deleted_at is not None:
continue
if not await access.can_read_project(user_id, system.project_id):
continue
wanted.append(sid)
existing = set(
(
await session.execute(
select(RecordSystem.system_id).where(RecordSystem.note_id == note_id)
)
).scalars().all()
)
wanted_set = set(wanted)
to_remove = existing - wanted_set
if to_remove:
await session.execute(
delete(RecordSystem).where(
RecordSystem.note_id == note_id,
RecordSystem.system_id.in_(to_remove),
)
)
for sid in wanted:
if sid not in existing:
session.add(RecordSystem(note_id=note_id, system_id=sid))
await session.commit()
return wanted
async def list_record_systems(user_id: int, note_id: int) -> list[System]:
"""Systems associated with a record (if the user can read it)."""
if not await access.can_read_note(user_id, note_id):
return []
async with async_session() as session:
result = await session.execute(
select(System)
.join(RecordSystem, RecordSystem.system_id == System.id)
.where(RecordSystem.note_id == note_id, System.deleted_at.is_(None))
.order_by(System.order_index.asc(), System.name.asc())
)
return list(result.scalars().all())
async def list_records_for_system(
user_id: int, system_id: int, kind: str | None = None, open_only: bool = False
) -> list[Note]:
"""Records associated with a System. `kind` filters task_kind (e.g. 'issue');
`open_only` limits to tasks not done/cancelled. [] if no read access."""
system = await get_system(user_id, system_id)
if system is None:
return []
async with async_session() as session:
query = (
select(Note)
.join(RecordSystem, RecordSystem.note_id == Note.id)
.where(RecordSystem.system_id == system_id, Note.deleted_at.is_(None))
)
if kind:
query = query.where(Note.task_kind == kind)
if open_only:
query = query.where(Note.status.not_in(["done", "cancelled"]))
query = query.order_by(Note.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def count_open_issues(user_id: int, project_id: int) -> int:
"""Count open (not done/cancelled) issues in a project. 0 if no read access."""
if not await access.can_read_project(user_id, project_id):
return 0
async with async_session() as session:
result = await session.execute(
select(func.count(Note.id)).where(
Note.project_id == project_id,
Note.task_kind == "issue",
Note.status.isnot(None),
Note.status.not_in(["done", "cancelled"]),
Note.deleted_at.is_(None),
)
)
return int(result.scalar() or 0)