generated from coulomb/repo-seed
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure: PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable Framework telemetry dashboard. - state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard) - 5 DB tables: topics, workstreams, tasks, decisions, progress_events - 11 MCP tools + 5 resources registered in .mcp.json - Observable dashboard: Overview, Workstreams, Decisions, Progress pages - CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual) - ~/.claude/CLAUDE.md: global cross-project reference to the hub - scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
api/config.py
Normal file
16
api/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
debug: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
24
api/database.py
Normal file
24
api/database.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from api.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
32
api/main.py
Normal file
32
api/main.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, progress, state, tasks, topics, workstreams
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Custodian State Hub",
|
||||
description="Local-first state API for the Custodian agent system.",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(progress.router)
|
||||
app.include_router(state.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return {"service": "state-hub", "docs": "/docs"}
|
||||
15
api/models/__init__.py
Normal file
15
api/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from api.models.base import Base
|
||||
from api.models.topic import Topic, TopicStatus, Domain
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Topic", "TopicStatus", "Domain",
|
||||
"Workstream", "WorkstreamStatus",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"ProgressEvent",
|
||||
]
|
||||
26
api/models/base.py
Normal file
26
api/models/base.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
63
api/models/decision.py
Normal file
63
api/models/decision.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class DecisionType(str, enum.Enum):
|
||||
made = "made"
|
||||
pending = "pending"
|
||||
|
||||
|
||||
class DecisionStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
resolved = "resolved"
|
||||
escalated = "escalated"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_type: Mapped[DecisionType] = mapped_column(
|
||||
Enum(DecisionType), nullable=False, default=DecisionType.pending
|
||||
)
|
||||
status: Mapped[DecisionStatus] = mapped_column(
|
||||
Enum(DecisionStatus), nullable=False, default=DecisionStatus.open
|
||||
)
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
superseded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
43
api/models/progress_event.py
Normal file
43
api/models/progress_event.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class ProgressEvent(Base):
|
||||
"""Append-only event log. No updated_at. No DELETE endpoint (constitution §5)."""
|
||||
|
||||
__tablename__ = "progress_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||
57
api/models/task.py
Normal file
57
api/models/task.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
blocked = "blocked"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
critical = "critical"
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
Enum(TaskStatus), nullable=False, default=TaskStatus.todo
|
||||
)
|
||||
priority: Mapped[TaskPriority] = mapped_column(
|
||||
Enum(TaskPriority), nullable=False, default=TaskPriority.medium
|
||||
)
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
||||
subtasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="task", lazy="selectin"
|
||||
)
|
||||
48
api/models/topic.py
Normal file
48
api/models/topic.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TopicStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Domain(str, enum.Enum):
|
||||
custodian = "custodian"
|
||||
railiance = "railiance"
|
||||
markitect = "markitect"
|
||||
coulomb_social = "coulomb_social"
|
||||
personhood = "personhood"
|
||||
foerster_capabilities = "foerster_capabilities"
|
||||
|
||||
|
||||
class Topic(Base, TimestampMixin):
|
||||
__tablename__ = "topics"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
domain: Mapped[Domain] = mapped_column(Enum(Domain), nullable=False)
|
||||
status: Mapped[TopicStatus] = mapped_column(
|
||||
Enum(TopicStatus), nullable=False, default=TopicStatus.active
|
||||
)
|
||||
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
46
api/models/workstream.py
Normal file
46
api/models/workstream.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkstreamStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
blocked = "blocked"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Workstream(Base, TimestampMixin):
|
||||
__tablename__ = "workstreams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[WorkstreamStatus] = mapped_column(
|
||||
Enum(WorkstreamStatus), nullable=False, default=WorkstreamStatus.active
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
0
api/routers/__init__.py
Normal file
0
api/routers/__init__.py
Normal file
110
api/routers/decisions.py
Normal file
110
api/routers/decisions.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionUpdate
|
||||
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
"financ", "legal", "payment", "purchas", "contract", "commit",
|
||||
"obligation", "external representation",
|
||||
)
|
||||
|
||||
|
||||
def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
if body.decision_type != DecisionType.pending:
|
||||
return None
|
||||
text = f"{body.title} {body.description or ''}".lower()
|
||||
for kw in _FINANCIAL_LEGAL_KEYWORDS:
|
||||
if kw in text:
|
||||
return (
|
||||
"Auto-escalated per constitution §4: this pending decision touches "
|
||||
"financial or legal territory and requires explicit human approval before action."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Decision]:
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(Decision.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
q = q.where(Decision.decision_type == decision_type)
|
||||
q = q.order_by(Decision.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
body: DecisionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
data = body.model_dump()
|
||||
note = _needs_escalation(body)
|
||||
if note:
|
||||
data["escalation_note"] = note
|
||||
data["status"] = DecisionStatus.escalated
|
||||
decision = Decision(**data)
|
||||
session.add(decision)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.get("/{decision_id}", response_model=DecisionRead)
|
||||
async def get_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
return decision
|
||||
|
||||
|
||||
@router.patch("/{decision_id}", response_model=DecisionRead)
|
||||
async def update_decision(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(decision, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.delete("/{decision_id}", response_model=DecisionRead)
|
||||
async def supersede_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
decision.status = DecisionStatus.superseded
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
50
api/routers/progress.py
Normal file
50
api/routers/progress.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
|
||||
router = APIRouter(prefix="/progress", tags=["progress"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProgressEventRead])
|
||||
async def list_progress(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
task_id: uuid.UUID | None = None,
|
||||
event_type: str | None = None,
|
||||
since: datetime | None = None,
|
||||
limit: int = 100,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ProgressEvent]:
|
||||
q = select(ProgressEvent)
|
||||
if topic_id:
|
||||
q = q.where(ProgressEvent.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(ProgressEvent.workstream_id == workstream_id)
|
||||
if task_id:
|
||||
q = q.where(ProgressEvent.task_id == task_id)
|
||||
if event_type:
|
||||
q = q.where(ProgressEvent.event_type == event_type)
|
||||
if since:
|
||||
q = q.where(ProgressEvent.created_at >= since)
|
||||
q = q.order_by(ProgressEvent.created_at.desc()).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=ProgressEventRead, status_code=status.HTTP_201_CREATED)
|
||||
async def append_progress(
|
||||
body: ProgressEventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProgressEvent:
|
||||
event = ProgressEvent(**body.model_dump())
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event
|
||||
132
api/routers/state.py
Normal file
132
api/routers/state.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session, engine
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.state import (
|
||||
DecisionTotals,
|
||||
StateSummary,
|
||||
TaskTotals,
|
||||
Totals,
|
||||
TopicTotals,
|
||||
WorkstreamTotals,
|
||||
)
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead
|
||||
|
||||
router = APIRouter(prefix="/state", tags=["state"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=StateSummary)
|
||||
async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSummary:
|
||||
# Run all queries sequentially on one session.
|
||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||
|
||||
topics_rows = await session.execute(
|
||||
select(Topic).where(Topic.status != TopicStatus.archived).order_by(Topic.created_at)
|
||||
)
|
||||
topics = list(topics_rows.scalars().all())
|
||||
|
||||
blocking_rows = await session.execute(
|
||||
select(Decision)
|
||||
.where(Decision.decision_type == DecisionType.pending)
|
||||
.where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated]))
|
||||
.order_by(Decision.deadline.asc().nullslast(), Decision.created_at)
|
||||
)
|
||||
blocking = list(blocking_rows.scalars().all())
|
||||
|
||||
blocked_rows = await session.execute(
|
||||
select(Task).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
|
||||
)
|
||||
blocked = list(blocked_rows.scalars().all())
|
||||
|
||||
recent_rows = await session.execute(
|
||||
select(ProgressEvent).order_by(ProgressEvent.created_at.desc()).limit(20)
|
||||
)
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.status.in_([WorkstreamStatus.active, WorkstreamStatus.blocked]))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
# Totals — one GROUP BY per table
|
||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
||||
)}
|
||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
)}
|
||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Decision.status, func.count()).group_by(Decision.status)
|
||||
)}
|
||||
|
||||
totals = Totals(
|
||||
topics=TopicTotals(
|
||||
active=topic_counts.get(TopicStatus.active, 0),
|
||||
paused=topic_counts.get(TopicStatus.paused, 0),
|
||||
archived=topic_counts.get(TopicStatus.archived, 0),
|
||||
total=sum(topic_counts.values()),
|
||||
),
|
||||
workstreams=WorkstreamTotals(
|
||||
active=ws_counts.get(WorkstreamStatus.active, 0),
|
||||
blocked=ws_counts.get(WorkstreamStatus.blocked, 0),
|
||||
completed=ws_counts.get(WorkstreamStatus.completed, 0),
|
||||
archived=ws_counts.get(WorkstreamStatus.archived, 0),
|
||||
total=sum(ws_counts.values()),
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
todo=task_counts.get(TaskStatus.todo, 0),
|
||||
in_progress=task_counts.get(TaskStatus.in_progress, 0),
|
||||
blocked=task_counts.get(TaskStatus.blocked, 0),
|
||||
done=task_counts.get(TaskStatus.done, 0),
|
||||
cancelled=task_counts.get(TaskStatus.cancelled, 0),
|
||||
total=sum(task_counts.values()),
|
||||
),
|
||||
decisions=DecisionTotals(
|
||||
open=dec_counts.get(DecisionStatus.open, 0),
|
||||
resolved=dec_counts.get(DecisionStatus.resolved, 0),
|
||||
escalated=dec_counts.get(DecisionStatus.escalated, 0),
|
||||
superseded=dec_counts.get(DecisionStatus.superseded, 0),
|
||||
total=sum(dec_counts.values()),
|
||||
),
|
||||
)
|
||||
|
||||
return StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
topics=[TopicWithWorkstreams.model_validate(t) for t in topics],
|
||||
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
open_workstreams=[WorkstreamRead.model_validate(w) for w in open_ws],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "db": str(exc)},
|
||||
)
|
||||
83
api/routers/tasks.py
Normal file
83
api/routers/tasks.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
assignee: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Task.status == status)
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
q = q.order_by(Task.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
body: TaskCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskRead)
|
||||
async def get_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskRead)
|
||||
async def update_task(
|
||||
task_id: uuid.UUID,
|
||||
body: TaskUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(task, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", response_model=TaskRead)
|
||||
async def cancel_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task.status = TaskStatus.cancelled
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
77
api/routers/topics.py
Normal file
77
api/routers/topics.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
|
||||
|
||||
router = APIRouter(prefix="/topics", tags=["topics"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TopicRead])
|
||||
async def list_topics(
|
||||
status: TopicStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Topic]:
|
||||
q = select(Topic)
|
||||
if status:
|
||||
q = q.where(Topic.status == status)
|
||||
q = q.order_by(Topic.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_topic(
|
||||
body: TopicCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = Topic(**body.model_dump())
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicWithWorkstreams)
|
||||
async def get_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
@router.patch("/{topic_id}", response_model=TopicRead)
|
||||
async def update_topic(
|
||||
topic_id: uuid.UUID,
|
||||
body: TopicUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(topic, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.delete("/{topic_id}", response_model=TopicRead)
|
||||
async def archive_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
topic.status = TopicStatus.archived
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
80
api/routers/workstreams.py
Normal file
80
api/routers/workstreams.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.workstream import Workstream, WorkstreamStatus
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamRead, WorkstreamUpdate
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
async def list_workstreams(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
status: WorkstreamStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
q = select(Workstream)
|
||||
if topic_id:
|
||||
q = q.where(Workstream.topic_id == topic_id)
|
||||
if status:
|
||||
q = q.where(Workstream.status == status)
|
||||
q = q.order_by(Workstream.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workstream(
|
||||
body: WorkstreamCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = Workstream(**body.model_dump())
|
||||
session.add(ws)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def get_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
return ws
|
||||
|
||||
|
||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def update_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ws, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def archive_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
ws.status = WorkstreamStatus.archived
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
15
api/schemas/__init__.py
Normal file
15
api/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
"ProgressEventCreate", "ProgressEventRead",
|
||||
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
|
||||
]
|
||||
58
api/schemas/decision.py
Normal file
58
api/schemas/decision.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.decision import DecisionStatus, DecisionType
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType = DecisionType.pending
|
||||
status: DecisionStatus = DecisionStatus.open
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workstream_id is None:
|
||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
decision_type: DecisionType | None = None
|
||||
status: DecisionStatus | None = None
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType
|
||||
status: DecisionStatus
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
32
api/schemas/progress_event.py
Normal file
32
api/schemas/progress_event.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ProgressEventCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class ProgressEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
created_at: datetime
|
||||
58
api/schemas/state.py
Normal file
58
api/schemas/state.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead
|
||||
|
||||
|
||||
class TopicTotals(BaseModel):
|
||||
active: int = 0
|
||||
paused: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class WorkstreamTotals(BaseModel):
|
||||
active: int = 0
|
||||
blocked: int = 0
|
||||
completed: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
todo: int = 0
|
||||
in_progress: int = 0
|
||||
blocked: int = 0
|
||||
done: int = 0
|
||||
cancelled: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class DecisionTotals(BaseModel):
|
||||
open: int = 0
|
||||
resolved: int = 0
|
||||
escalated: int = 0
|
||||
superseded: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class Totals(BaseModel):
|
||||
topics: TopicTotals
|
||||
workstreams: WorkstreamTotals
|
||||
tasks: TaskTotals
|
||||
decisions: DecisionTotals
|
||||
|
||||
|
||||
class StateSummary(BaseModel):
|
||||
generated_at: datetime
|
||||
totals: Totals
|
||||
topics: list[TopicWithWorkstreams]
|
||||
blocking_decisions: list[DecisionRead]
|
||||
blocked_tasks: list[TaskRead]
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workstreams: list[WorkstreamRead]
|
||||
51
api/schemas/task.py
Normal file
51
api/schemas/task.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.task import TaskPriority, TaskStatus
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
priority: TaskPriority = TaskPriority.medium
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
priority: TaskPriority | None = None
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
47
api/schemas/topic.py
Normal file
47
api/schemas/topic.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.topic import Domain, TopicStatus
|
||||
|
||||
|
||||
class TopicCreate(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain: Domain
|
||||
status: TopicStatus = TopicStatus.active
|
||||
|
||||
|
||||
class TopicUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
domain: Domain | None = None
|
||||
status: TopicStatus | None = None
|
||||
|
||||
|
||||
class WorkstreamStub(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
status: str
|
||||
owner: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class TopicRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain: Domain
|
||||
status: TopicStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TopicWithWorkstreams(TopicRead):
|
||||
workstreams: list[WorkstreamStub] = []
|
||||
38
api/schemas/workstream.py
Normal file
38
api/schemas/workstream.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.workstream import WorkstreamStatus
|
||||
|
||||
|
||||
class WorkstreamCreate(BaseModel):
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus = WorkstreamStatus.active
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
|
||||
|
||||
class WorkstreamUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus | None = None
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
|
||||
|
||||
class WorkstreamRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
Reference in New Issue
Block a user