feat(coord): add todos system with per-user/machine/project scoping
New coord_todos table and API endpoints (GET/POST/PUT/DELETE /api/coord/todos) supporting manual and auto-created items, sub-tasks via parent_id, and inclusive for_user/for_machine filters (OR-null) for sync/save display. sync.sh Phase 7 now shows pending todos grouped by project after every sync. CLAUDE.md documents auto-creation behavior for unresolved follow-up. Web/email pricing doc updated: block time rate clarified, INKY reference removed, dates updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -203,6 +203,14 @@ Full protocol + inter-session messaging: `.claude/COORDINATION_PROTOCOL.md`
|
|||||||
- **Frontend Design:** Auto-invoke `/frontend-design` skill after ANY UI change (HTML/CSS/JSX/styling)
|
- **Frontend Design:** Auto-invoke `/frontend-design` skill after ANY UI change (HTML/CSS/JSX/styling)
|
||||||
- **Sequential Thinking:** Use for genuine complexity — rejection loops, 3+ critical issues, architectural decisions
|
- **Sequential Thinking:** Use for genuine complexity — rejection loops, 3+ critical issues, architectural decisions
|
||||||
- **Task Management:** Complex work (>3 steps) → TaskCreate. Persist to `.claude/active-tasks.json`.
|
- **Task Management:** Complex work (>3 steps) → TaskCreate. Persist to `.claude/active-tasks.json`.
|
||||||
|
- **Auto Todo Creation:** When wrapping up a task that has unresolved follow-up, open items, or deferred work, POST to `POST /api/coord/todos` with `auto_created: true` and `source_context` describing why. Assign `project_key` if project-scoped; assign `assigned_to_user` if only relevant to one tech. Sub-tasks: set `parent_id` to link under a parent todo. Never create a todo for something already being done in the current session.
|
||||||
|
|
||||||
|
### Querying Todos
|
||||||
|
|
||||||
|
- "What needs to be done with \<project\>?" → `GET /api/coord/todos?project_key=<key>&status_filter=pending`
|
||||||
|
- "What are my open todos?" → `GET /api/coord/todos?for_user=<user>&status_filter=pending`
|
||||||
|
- "Show all todos including done" → add `status_filter=all`
|
||||||
|
- "Mark done" → `PUT /api/coord/todos/<id>` with `{"status": "done", "completed_by": "<user>"}`
|
||||||
|
|
||||||
### Cross-Session Messages (MANDATORY)
|
### Cross-Session Messages (MANDATORY)
|
||||||
|
|
||||||
|
|||||||
@@ -417,7 +417,84 @@ else
|
|||||||
cd "$CLAUDETOOLS_DIR"
|
cd "$CLAUDETOOLS_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Phase 7: Summary
|
# Phase 7: Pending To-Do Review
|
||||||
|
echo ""
|
||||||
|
echo "=== Phase 7: Pending To-Dos ==="
|
||||||
|
|
||||||
|
COORD_API="http://172.16.3.30:8001"
|
||||||
|
TODO_USER=""
|
||||||
|
TODO_MACHINE="$MACHINE"
|
||||||
|
if [ -f ".claude/identity.json" ]; then
|
||||||
|
TODO_USER=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TODO_USER" ]; then
|
||||||
|
TODO_JSON=$(curl -s --max-time 5 \
|
||||||
|
"${COORD_API}/api/coord/todos?for_user=${TODO_USER}&for_machine=${TODO_MACHINE}&status_filter=pending&limit=200" \
|
||||||
|
2>/dev/null || echo "[]")
|
||||||
|
|
||||||
|
TODO_COUNT=$($PYTHON -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.loads('''$TODO_JSON''')
|
||||||
|
print(len(data))
|
||||||
|
except:
|
||||||
|
print(0)
|
||||||
|
" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ "$TODO_COUNT" -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW} $TODO_COUNT pending item(s) for ${TODO_USER}/${TODO_MACHINE}:${NC}"
|
||||||
|
$PYTHON - <<PYEOF
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
raw = """$TODO_JSON"""
|
||||||
|
try:
|
||||||
|
todos = json.loads(raw)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARNING] Could not parse todos: {e}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Group by project_key; None -> "Personal"
|
||||||
|
groups = {}
|
||||||
|
for t in todos:
|
||||||
|
if t.get("parent_id"):
|
||||||
|
continue # sub-tasks shown under parent
|
||||||
|
key = t.get("project_key") or "Personal"
|
||||||
|
groups.setdefault(key, []).append(t)
|
||||||
|
|
||||||
|
# Build sub-task lookup
|
||||||
|
subtask_map = {}
|
||||||
|
for t in todos:
|
||||||
|
pid = t.get("parent_id")
|
||||||
|
if pid:
|
||||||
|
subtask_map.setdefault(pid, []).append(t)
|
||||||
|
|
||||||
|
CYAN = "\033[0;36m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
YELLOW= "\033[1;33m"
|
||||||
|
|
||||||
|
for group_name in sorted(groups.keys(), key=lambda x: (x == "Personal", x)):
|
||||||
|
print(f"\n {CYAN}[{group_name}]{RESET}")
|
||||||
|
for t in groups[group_name]:
|
||||||
|
assigned = ""
|
||||||
|
if t.get("assigned_to_user") and t["assigned_to_user"] != "$TODO_USER":
|
||||||
|
assigned = f" -> {t['assigned_to_user']}"
|
||||||
|
if t.get("assigned_to_machine") and t["assigned_to_machine"].upper() != "$TODO_MACHINE".upper():
|
||||||
|
assigned += f"/{t['assigned_to_machine']}"
|
||||||
|
auto = " [auto]" if t.get("auto_created") else ""
|
||||||
|
print(f" [ ] {t['text']}{auto}{assigned} (id:{t['id'][:8]})")
|
||||||
|
for st in subtask_map.get(t["id"], []):
|
||||||
|
print(f" [ ] {st['text']} (id:{st['id'][:8]})")
|
||||||
|
PYEOF
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[OK]${NC} No pending to-dos."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}[INFO]${NC} No identity — skipping todo check."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Phase 8: Summary
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Sync Summary ==="
|
echo "=== Sync Summary ==="
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from api.routers import (
|
|||||||
coord_components,
|
coord_components,
|
||||||
coord_messages,
|
coord_messages,
|
||||||
coord_status,
|
coord_status,
|
||||||
|
coord_todos,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import middleware
|
# Import middleware
|
||||||
@@ -162,6 +163,7 @@ app.include_router(coord_locks.router, prefix="/api/coord/locks", tags=["Coordin
|
|||||||
app.include_router(coord_components.router, prefix="/api/coord/components", tags=["Coordination"])
|
app.include_router(coord_components.router, prefix="/api/coord/components", tags=["Coordination"])
|
||||||
app.include_router(coord_messages.router, prefix="/api/coord/messages", tags=["Coordination"])
|
app.include_router(coord_messages.router, prefix="/api/coord/messages", tags=["Coordination"])
|
||||||
app.include_router(coord_status.router, prefix="/api/coord/status", tags=["Coordination"])
|
app.include_router(coord_status.router, prefix="/api/coord/status", tags=["Coordination"])
|
||||||
|
app.include_router(coord_todos.router, prefix="/api/coord/todos", tags=["Coordination"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from api.models.coord_work_item import CoordWorkItem
|
|||||||
from api.models.coord_session_lock import CoordSessionLock
|
from api.models.coord_session_lock import CoordSessionLock
|
||||||
from api.models.coord_component_state import CoordComponentState
|
from api.models.coord_component_state import CoordComponentState
|
||||||
from api.models.coord_message import CoordMessage
|
from api.models.coord_message import CoordMessage
|
||||||
|
from api.models.coord_todo import CoordTodo
|
||||||
from api.models.backup_log import BackupLog
|
from api.models.backup_log import BackupLog
|
||||||
from api.models.base import Base, TimestampMixin, UUIDMixin
|
from api.models.base import Base, TimestampMixin, UUIDMixin
|
||||||
from api.models.billable_time import BillableTime
|
from api.models.billable_time import BillableTime
|
||||||
@@ -57,6 +58,7 @@ __all__ = [
|
|||||||
"CoordSessionLock",
|
"CoordSessionLock",
|
||||||
"CoordComponentState",
|
"CoordComponentState",
|
||||||
"CoordMessage",
|
"CoordMessage",
|
||||||
|
"CoordTodo",
|
||||||
"BackupLog",
|
"BackupLog",
|
||||||
"Base",
|
"Base",
|
||||||
"BillableTime",
|
"BillableTime",
|
||||||
|
|||||||
96
api/models/coord_todo.py
Normal file
96
api/models/coord_todo.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Coordination personal/project to-do item model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import CHAR, Boolean, CheckConstraint, DateTime, ForeignKey, Index, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CoordTodo(Base, UUIDMixin, TimestampMixin):
|
||||||
|
"""A personal or project-scoped to-do item, optionally nested under a parent."""
|
||||||
|
|
||||||
|
__tablename__ = "coord_todos"
|
||||||
|
|
||||||
|
text: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
doc="The to-do item text"
|
||||||
|
)
|
||||||
|
|
||||||
|
project_key: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
doc="Project scope; NULL means personal"
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
CHAR(36),
|
||||||
|
ForeignKey("coord_todos.id", ondelete="CASCADE"),
|
||||||
|
doc="Parent to-do ID for sub-tasks; NULL means top-level"
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_to_user: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
doc="Target user, e.g. 'mike' or 'howard'; NULL = any user"
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_to_machine: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
doc="Target hostname; NULL = any machine"
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_created: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
doc="True when Claude auto-generated this item"
|
||||||
|
)
|
||||||
|
|
||||||
|
source_context: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Why Claude auto-created this item"
|
||||||
|
)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
doc="Status: pending / done / cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
doc="When the item was marked done"
|
||||||
|
)
|
||||||
|
|
||||||
|
completed_by: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
doc="Session or user that completed the item"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by_user: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
doc="User who created the item"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by_machine: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
doc="Hostname where the item was created"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'done', 'cancelled')",
|
||||||
|
name="ck_coord_todos_status",
|
||||||
|
),
|
||||||
|
Index("idx_coord_todos_project_status", "project_key", "status"),
|
||||||
|
Index("idx_coord_todos_assigned", "assigned_to_user", "assigned_to_machine", "status"),
|
||||||
|
Index("idx_coord_todos_parent", "parent_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<CoordTodo(status='{self.status}', text='{self.text[:40]}')>"
|
||||||
81
api/routers/coord_todos.py
Normal file
81
api/routers/coord_todos.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Coordination to-do items router."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.database import get_db
|
||||||
|
from api.schemas.coord_todo import CoordTodoCreate, CoordTodoResponse, CoordTodoUpdate
|
||||||
|
from api.services import coord_todo_service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CoordTodoResponse], status_code=status.HTTP_200_OK)
|
||||||
|
def list_todos(
|
||||||
|
project_key: str | None = Query(default=None),
|
||||||
|
assigned_to_user: str | None = Query(default=None),
|
||||||
|
assigned_to_machine: str | None = Query(default=None),
|
||||||
|
for_user: str | None = Query(default=None, description="Return items for this user OR unassigned"),
|
||||||
|
for_machine: str | None = Query(default=None, description="Return items for this machine OR any machine"),
|
||||||
|
status_filter: str = Query(default="pending"),
|
||||||
|
include_subtasks: bool = Query(default=True),
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List to-do items with optional filters. Pass status_filter=all to include every status."""
|
||||||
|
todos = coord_todo_service.get_todos(
|
||||||
|
db,
|
||||||
|
project_key=project_key,
|
||||||
|
assigned_to_user=assigned_to_user,
|
||||||
|
assigned_to_machine=assigned_to_machine,
|
||||||
|
for_user=for_user,
|
||||||
|
for_machine=for_machine,
|
||||||
|
status_filter=status_filter,
|
||||||
|
include_subtasks=include_subtasks,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return [CoordTodoResponse.model_validate(t) for t in todos]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CoordTodoResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_todo(
|
||||||
|
data: CoordTodoCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new to-do item."""
|
||||||
|
todo = coord_todo_service.create_todo(db, data)
|
||||||
|
return CoordTodoResponse.model_validate(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK)
|
||||||
|
def get_todo(
|
||||||
|
todo_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a single to-do item including its sub-tasks."""
|
||||||
|
todo = coord_todo_service.get_todo_by_id(db, todo_id)
|
||||||
|
return CoordTodoResponse.model_validate(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK)
|
||||||
|
def update_todo(
|
||||||
|
todo_id: UUID,
|
||||||
|
data: CoordTodoUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a to-do item. Setting status to 'done' records completed_at automatically."""
|
||||||
|
todo = coord_todo_service.update_todo(db, todo_id, data)
|
||||||
|
return CoordTodoResponse.model_validate(todo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{todo_id}", response_model=dict, status_code=status.HTTP_200_OK)
|
||||||
|
def delete_todo(
|
||||||
|
todo_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a to-do item and all its sub-tasks."""
|
||||||
|
return coord_todo_service.delete_todo(db, todo_id)
|
||||||
57
api/schemas/coord_todo.py
Normal file
57
api/schemas/coord_todo.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Pydantic schemas for CoordTodo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CoordTodoCreate(BaseModel):
|
||||||
|
"""Input schema for creating a to-do item."""
|
||||||
|
|
||||||
|
text: str = Field(..., description="To-do item text")
|
||||||
|
project_key: Optional[str] = Field(None, description="Project scope; NULL = personal", max_length=100)
|
||||||
|
parent_id: Optional[UUID] = Field(None, description="Parent to-do ID for sub-tasks")
|
||||||
|
assigned_to_user: Optional[str] = Field(None, description="Target user, e.g. 'mike'", max_length=50)
|
||||||
|
assigned_to_machine: Optional[str] = Field(None, description="Target hostname", max_length=100)
|
||||||
|
auto_created: bool = Field(False, description="True when Claude auto-generated this item")
|
||||||
|
source_context: Optional[str] = Field(None, description="Why Claude auto-created this item")
|
||||||
|
created_by_user: str = Field(..., description="User creating the item", max_length=50)
|
||||||
|
created_by_machine: str = Field(..., description="Hostname where the item is created", max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CoordTodoUpdate(BaseModel):
|
||||||
|
"""Input schema for updating a to-do item."""
|
||||||
|
|
||||||
|
text: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, description="pending / done / cancelled")
|
||||||
|
assigned_to_user: Optional[str] = Field(None, max_length=50)
|
||||||
|
assigned_to_machine: Optional[str] = Field(None, max_length=100)
|
||||||
|
project_key: Optional[str] = Field(None, max_length=100)
|
||||||
|
completed_by: Optional[str] = Field(None, description="Session or user completing the item", max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class CoordTodoResponse(BaseModel):
|
||||||
|
"""Output schema for a to-do item."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
text: str
|
||||||
|
project_key: Optional[str]
|
||||||
|
parent_id: Optional[UUID]
|
||||||
|
assigned_to_user: Optional[str]
|
||||||
|
assigned_to_machine: Optional[str]
|
||||||
|
auto_created: bool
|
||||||
|
source_context: Optional[str]
|
||||||
|
status: str
|
||||||
|
completed_at: Optional[datetime]
|
||||||
|
completed_by: Optional[str]
|
||||||
|
created_by_user: str
|
||||||
|
created_by_machine: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
subtasks: list[CoordTodoResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
170
api/services/coord_todo_service.py
Normal file
170
api/services/coord_todo_service.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Service layer for CoordTodo."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.models.coord_todo import CoordTodo
|
||||||
|
from api.schemas.coord_todo import CoordTodoCreate, CoordTodoUpdate
|
||||||
|
|
||||||
|
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_todos(
|
||||||
|
db: Session,
|
||||||
|
project_key: Optional[str] = None,
|
||||||
|
assigned_to_user: Optional[str] = None,
|
||||||
|
assigned_to_machine: Optional[str] = None,
|
||||||
|
for_user: Optional[str] = None,
|
||||||
|
for_machine: Optional[str] = None,
|
||||||
|
status_filter: str = "pending",
|
||||||
|
include_subtasks: bool = True,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[CoordTodo]:
|
||||||
|
"""Return a flat list of to-do items with optional filters.
|
||||||
|
|
||||||
|
assigned_to_user/machine: exact match (admin queries).
|
||||||
|
for_user/for_machine: inclusive match — items for this user/machine OR unassigned.
|
||||||
|
Used by sync/save to surface relevant pending work.
|
||||||
|
|
||||||
|
When include_subtasks is False, only top-level items (parent_id IS NULL) are returned.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
q = db.query(CoordTodo)
|
||||||
|
|
||||||
|
if not include_subtasks:
|
||||||
|
q = q.filter(CoordTodo.parent_id.is_(None))
|
||||||
|
|
||||||
|
if project_key is not None:
|
||||||
|
q = q.filter(CoordTodo.project_key == project_key)
|
||||||
|
|
||||||
|
if assigned_to_user is not None:
|
||||||
|
q = q.filter(CoordTodo.assigned_to_user == assigned_to_user)
|
||||||
|
|
||||||
|
if assigned_to_machine is not None:
|
||||||
|
q = q.filter(CoordTodo.assigned_to_machine == assigned_to_machine)
|
||||||
|
|
||||||
|
if for_user is not None:
|
||||||
|
q = q.filter(or_(CoordTodo.assigned_to_user == for_user, CoordTodo.assigned_to_user.is_(None)))
|
||||||
|
|
||||||
|
if for_machine is not None:
|
||||||
|
q = q.filter(or_(CoordTodo.assigned_to_machine == for_machine, CoordTodo.assigned_to_machine.is_(None)))
|
||||||
|
|
||||||
|
if status_filter != "all":
|
||||||
|
q = q.filter(CoordTodo.status == status_filter)
|
||||||
|
|
||||||
|
return q.order_by(CoordTodo.created_at.asc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_todo_by_id(db: Session, todo_id: UUID) -> CoordTodo:
|
||||||
|
"""Return a single to-do item with its sub-tasks loaded, or raise 404."""
|
||||||
|
todo = db.query(CoordTodo).filter(CoordTodo.id == str(todo_id)).first()
|
||||||
|
if not todo:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Todo {todo_id} not found"
|
||||||
|
)
|
||||||
|
todo.subtasks = (
|
||||||
|
db.query(CoordTodo)
|
||||||
|
.filter(CoordTodo.parent_id == str(todo_id))
|
||||||
|
.order_by(CoordTodo.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return todo
|
||||||
|
|
||||||
|
|
||||||
|
def create_todo(db: Session, todo_in: CoordTodoCreate) -> CoordTodo:
|
||||||
|
"""Persist a new to-do item."""
|
||||||
|
if todo_in.parent_id is not None:
|
||||||
|
parent = db.query(CoordTodo).filter(CoordTodo.id == str(todo_in.parent_id)).first()
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Parent todo {todo_in.parent_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = todo_in.model_dump()
|
||||||
|
if data.get("parent_id") is not None:
|
||||||
|
data["parent_id"] = str(data["parent_id"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
todo = CoordTodo(**data)
|
||||||
|
db.add(todo)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
todo.subtasks = []
|
||||||
|
return todo
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create todo: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_todo(db: Session, todo_id: UUID, todo_in: CoordTodoUpdate) -> CoordTodo:
|
||||||
|
"""Apply a partial update to a to-do item.
|
||||||
|
|
||||||
|
When status changes to 'done', completed_at is set to the current UTC time.
|
||||||
|
When status changes away from 'done', completed_at is cleared.
|
||||||
|
"""
|
||||||
|
todo = get_todo_by_id(db, todo_id)
|
||||||
|
|
||||||
|
patch = todo_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "status" in patch and patch["status"] not in _VALID_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid status '{patch['status']}'; must be one of {sorted(_VALID_STATUSES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in patch.items():
|
||||||
|
setattr(todo, field, value)
|
||||||
|
|
||||||
|
if "status" in patch:
|
||||||
|
if patch["status"] == "done" and todo.completed_at is None:
|
||||||
|
todo.completed_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
elif patch["status"] != "done":
|
||||||
|
todo.completed_at = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(todo)
|
||||||
|
todo.subtasks = (
|
||||||
|
db.query(CoordTodo)
|
||||||
|
.filter(CoordTodo.parent_id == str(todo_id))
|
||||||
|
.order_by(CoordTodo.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return todo
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update todo: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_todo(db: Session, todo_id: UUID) -> dict:
|
||||||
|
"""Delete a to-do item and all its sub-tasks.
|
||||||
|
|
||||||
|
Sub-tasks are deleted first to satisfy FK constraints on databases that
|
||||||
|
don't enforce CASCADE at the application level.
|
||||||
|
"""
|
||||||
|
todo = get_todo_by_id(db, todo_id)
|
||||||
|
try:
|
||||||
|
db.query(CoordTodo).filter(CoordTodo.parent_id == str(todo_id)).delete(synchronize_session=False)
|
||||||
|
db.delete(todo)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Todo deleted", "todo_id": str(todo_id)}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete todo: {e}"
|
||||||
|
)
|
||||||
53
migrations/versions/20260526_120000_coord_todos.py
Normal file
53
migrations/versions/20260526_120000_coord_todos.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""coord_todos
|
||||||
|
|
||||||
|
Revision ID: 20260526_120000
|
||||||
|
Revises: 20260512_120000
|
||||||
|
Create Date: 2026-05-26 12:00:00
|
||||||
|
|
||||||
|
Creates the coord_todos table for personal and project-scoped to-do items,
|
||||||
|
supporting sub-tasks via a self-referencing parent_id FK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260526_120000"
|
||||||
|
down_revision: Union[str, None] = "20260512_120000"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create coord_todos table."""
|
||||||
|
op.create_table(
|
||||||
|
"coord_todos",
|
||||||
|
sa.Column("id", sa.CHAR(36), primary_key=True),
|
||||||
|
sa.Column("text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("project_key", sa.String(100), nullable=True),
|
||||||
|
sa.Column("parent_id", sa.CHAR(36), sa.ForeignKey("coord_todos.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("assigned_to_user", sa.String(50), nullable=True),
|
||||||
|
sa.Column("assigned_to_machine", sa.String(100), nullable=True),
|
||||||
|
sa.Column("auto_created", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("source_context", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("completed_by", sa.String(100), nullable=True),
|
||||||
|
sa.Column("created_by_user", sa.String(50), nullable=False),
|
||||||
|
sa.Column("created_by_machine", sa.String(100), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('pending', 'done', 'cancelled')",
|
||||||
|
name="ck_coord_todos_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_coord_todos_project_status", "coord_todos", ["project_key", "status"])
|
||||||
|
op.create_index("idx_coord_todos_assigned", "coord_todos", ["assigned_to_user", "assigned_to_machine", "status"])
|
||||||
|
op.create_index("idx_coord_todos_parent", "coord_todos", ["parent_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop coord_todos table."""
|
||||||
|
op.drop_table("coord_todos")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web & Email Hosting Pricing Structure
|
# Web & Email Hosting Pricing Structure
|
||||||
|
|
||||||
**Last Updated:** 2026-02-01
|
**Last Updated:** 2026-05-26
|
||||||
**Source:** MSP Pricing Chat - Web/Email Hosting Discussion
|
**Source:** MSP Pricing Chat - Web/Email Hosting Discussion
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
### Email Security & Filtering
|
### Email Security & Filtering
|
||||||
**Price:** $3/mailbox/month
|
**Price:** $3/mailbox/month
|
||||||
|
|
||||||
**Platforms:** MailProtector (Emailservice.io) / INKY (via Kaseya)
|
**Platform:** MailProtector (Emailservice.io)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- Anti-phishing protection
|
- Anti-phishing protection
|
||||||
@@ -315,7 +315,8 @@ Total Monthly: $280
|
|||||||
- **Website Maintenance:** $35-500/month (small/medium), $300-2,500/month (complex)
|
- **Website Maintenance:** $35-500/month (small/medium), $300-2,500/month (complex)
|
||||||
|
|
||||||
### ACG Position
|
### ACG Position
|
||||||
- **Hourly Rate:** $130-165/hour (in line with professional MSP/agency rates)
|
- **Standard Hourly Rate:** $175/hour
|
||||||
|
- **Block Time Effective Rate:** $130-150/hour (depending on block size — never expires, in line with professional MSP/agency rates)
|
||||||
- **GPS Support Plans:** $85-100/hour effective (significant value)
|
- **GPS Support Plans:** $85-100/hour effective (significant value)
|
||||||
- **Web Hosting:** Competitive with managed/specialty hosts
|
- **Web Hosting:** Competitive with managed/specialty hosts
|
||||||
- **Email Hosting:** Budget-friendly alternative to M365 for IMAP users
|
- **Email Hosting:** Budget-friendly alternative to M365 for IMAP users
|
||||||
@@ -350,5 +351,5 @@ Total Monthly: $280
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2026-02-01
|
**Last Updated:** 2026-05-26
|
||||||
**Protecting Tucson Businesses Since 2001**
|
**Protecting Tucson Businesses Since 2001**
|
||||||
|
|||||||
Reference in New Issue
Block a user