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:
@@ -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.")
|
||||||
@@ -225,6 +225,10 @@ class ProjectModule(Base):
|
|||||||
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
project_id = Column(String, nullable=False, index=True) # FK to projects.id
|
||||||
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
module_type = Column(String, nullable=False) # sound_monitoring | vibration_monitoring | ...
|
||||||
enabled = Column(Boolean, default=True, nullable=False)
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
|
||||||
|
|||||||
@@ -58,12 +58,25 @@ MODULES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_STATUSES = {"active", "on_hold", "completed"}
|
||||||
|
|
||||||
|
|
||||||
def _get_project_modules(project_id: str, db: Session) -> list[str]:
|
def _get_project_modules(project_id: str, db: Session) -> list[str]:
|
||||||
"""Return list of enabled module_type strings for a project."""
|
"""Return list of enabled module_type strings for a project."""
|
||||||
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
|
||||||
return [r.module_type for r in rows]
|
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:
|
def _require_module(project: Project, module_type: str, db: Session) -> None:
|
||||||
"""Raise 400 if the project does not have the given module enabled."""
|
"""Raise 400 if the project does not have the given module enabled."""
|
||||||
if not project:
|
if not project:
|
||||||
@@ -482,6 +495,8 @@ async def get_projects_list(
|
|||||||
projects_data.append({
|
projects_data.append({
|
||||||
"project": project,
|
"project": project,
|
||||||
"project_type": project_type,
|
"project_type": project_type,
|
||||||
|
"modules": _get_project_modules(project.id, db),
|
||||||
|
"module_status": _get_module_statuses(project.id, db),
|
||||||
"location_count": location_count,
|
"location_count": location_count,
|
||||||
"unit_count": unit_count,
|
"unit_count": unit_count,
|
||||||
"active_session_count": active_session_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_id": project.project_type_id,
|
||||||
"project_type_name": project_type.name if project_type else None,
|
"project_type_name": project_type.name if project_type else None,
|
||||||
"modules": modules,
|
"modules": modules,
|
||||||
|
"module_status": _get_module_statuses(project.id, db),
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
"client_name": project.client_name,
|
"client_name": project.client_name,
|
||||||
"site_address": project.site_address,
|
"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)}
|
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}")
|
@router.put("/{project_id}")
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -1255,6 +1298,7 @@ async def get_project_header(
|
|||||||
"project": project,
|
"project": project,
|
||||||
"project_type": project_type,
|
"project_type": project_type,
|
||||||
"modules": _get_project_modules(project_id, db),
|
"modules": _get_project_modules(project_id, db),
|
||||||
|
"module_status": _get_module_statuses(project_id, db),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user