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
+54
View File
@@ -0,0 +1,54 @@
"""
Migration: add a per-module `status` column to `project_modules`.
A combined project (sound + vibration) often finishes one kind of work before
the other. Rather than archiving the whole project, each module now carries its
own lifecycle so e.g. the sound side can read "Completed" while vibration stays
"Active".
Behavior:
- status = 'active' → module is live (default for all existing rows)
- status = 'on_hold' → paused; data/tabs stay visible
- status = 'completed' → wrapped up; surfaced as a done badge
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_module_status.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == column for row in cur.fetchall())
def migrate_database() -> None:
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
if not _has_column(cur, "project_modules", "status"):
cur.execute(
"ALTER TABLE project_modules ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
)
conn.commit()
print(" Added column project_modules.status (default 'active').")
else:
print(" project_modules already has status — nothing to do.")
conn.close()
if __name__ == "__main__":
print("Running migration: add status to project_modules")
migrate_database()
print("Done.")
+4
View File
@@ -225,6 +225,10 @@ class ProjectModule(Base):
project_id = Column(String, nullable=False, index=True) # FK to projects.id
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
enabled = Column(Boolean, default=True, nullable=False)
# Per-module lifecycle, independent of the parent project's status. Lets one
# part of a combined project wrap up (e.g. sound "completed") while another
# keeps running ("active"). Values: active | on_hold | completed.
status = Column(String, default="active", nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
+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),
})