diff --git a/backend/migrate_add_module_status.py b/backend/migrate_add_module_status.py new file mode 100644 index 0000000..61685c7 --- /dev/null +++ b/backend/migrate_add_module_status.py @@ -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.") diff --git a/backend/models.py b/backend/models.py index 888ca24..4852de7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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"),) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index e195590..b564c7c 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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), })