feat(projects): per-module status (independent sound/vibration lifecycle)

Each ProjectModule now carries its own status (active|on_hold|completed)
so one half of a combined project can wrap up while the other keeps
running — e.g. mark Sound "completed" while Vibration stays "active",
without archiving the whole project.

- models.py: ProjectModule.status column (default 'active')
- migrate_add_module_status.py: idempotent ALTER (run on prod before deploy)
- projects.py: _get_module_statuses() helper, MODULE_STATUSES, and a
  PUT /{id}/modules/{type}/status endpoint; module_status now included in
  the project GET, header, and /list contexts so the UI can render it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1
This commit is contained in:
2026-06-22 20:24:27 +00:00
parent 5dc0aa4064
commit 092b72f63c
3 changed files with 102 additions and 0 deletions
+44
View File
@@ -58,12 +58,25 @@ MODULES = {
}
MODULE_STATUSES = {"active", "on_hold", "completed"}
def _get_project_modules(project_id: str, db: Session) -> list[str]:
"""Return list of enabled module_type strings for a project."""
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
return [r.module_type for r in rows]
def _get_module_statuses(project_id: str, db: Session) -> dict[str, str]:
"""Return {module_type: status} for a project's enabled modules.
Per-module lifecycle is independent of the parent project's status — lets
one half of a combined project be "completed" while the other stays "active".
"""
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
return {r.module_type: (r.status or "active") for r in rows}
def _require_module(project: Project, module_type: str, db: Session) -> None:
"""Raise 400 if the project does not have the given module enabled."""
if not project:
@@ -482,6 +495,8 @@ async def get_projects_list(
projects_data.append({
"project": project,
"project_type": project_type,
"modules": _get_project_modules(project.id, db),
"module_status": _get_module_statuses(project.id, db),
"location_count": location_count,
"unit_count": unit_count,
"active_session_count": active_session_count,
@@ -838,6 +853,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
"project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None,
"modules": modules,
"module_status": _get_module_statuses(project.id, db),
"status": project.status,
"client_name": project.client_name,
"site_address": project.site_address,
@@ -902,6 +918,33 @@ async def remove_project_module(project_id: str, module_type: str, db: Session =
return {"ok": True, "modules": _get_project_modules(project_id, db)}
@router.put("/{project_id}/modules/{module_type}/status")
async def set_project_module_status(
project_id: str, module_type: str, request: Request, db: Session = Depends(get_db)
):
"""Set a module's lifecycle status. Body: {status: active|on_hold|completed}.
Independent of the parent project's status — used to wrap up one half of a
combined project (e.g. sound "completed") while the other stays "active".
"""
data = await request.json()
status = (data.get("status") or "").strip()
if status not in MODULE_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{status}'. Expected one of: {', '.join(sorted(MODULE_STATUSES))}.",
)
row = db.query(ProjectModule).filter_by(
project_id=project_id, module_type=module_type, enabled=True
).first()
if not row:
raise HTTPException(status_code=404, detail="Module not enabled on this project.")
row.status = status
db.commit()
return {"ok": True, "module_type": module_type, "status": status,
"module_status": _get_module_statuses(project_id, db)}
@router.put("/{project_id}")
async def update_project(
project_id: str,
@@ -1255,6 +1298,7 @@ async def get_project_header(
"project": project,
"project_type": project_type,
"modules": _get_project_modules(project_id, db),
"module_status": _get_module_statuses(project_id, db),
})