feat: Refactor project creation and management to support modular project types

- Updated project creation modal to allow selection of optional modules (Sound and Vibration Monitoring).
- Modified project dashboard and header to display active modules and provide options to add/remove them.
- Enhanced project detail view to dynamically adjust UI based on enabled modules.
- Implemented a new migration script to create a `project_modules` table and seed it based on existing project types.
- Adjusted form submissions to handle module selections and ensure proper API interactions for module management.
This commit is contained in:
2026-03-30 21:44:15 +00:00
parent 184f0ddd13
commit 73a6ff4d20
9 changed files with 493 additions and 175 deletions

View File

@@ -0,0 +1,71 @@
"""
Migration: Add project_modules table and seed from existing project_type_id values.
Safe to run multiple times — idempotent.
"""
import sqlite3
import uuid
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "seismo_fleet.db")
DB_PATH = os.path.abspath(DB_PATH)
def run():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Create project_modules table if not exists
cur.execute("""
CREATE TABLE IF NOT EXISTS project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
module_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, module_type)
)
""")
print(" Table 'project_modules' ready.")
# 2. Seed modules from existing project_type_id values
cur.execute("SELECT id, project_type_id FROM projects WHERE project_type_id IS NOT NULL")
projects = cur.fetchall()
seeded = 0
for p in projects:
pid = p["id"]
ptype = p["project_type_id"]
modules_to_add = []
if ptype == "sound_monitoring":
modules_to_add = ["sound_monitoring"]
elif ptype == "vibration_monitoring":
modules_to_add = ["vibration_monitoring"]
elif ptype == "combined":
modules_to_add = ["sound_monitoring", "vibration_monitoring"]
for module_type in modules_to_add:
# INSERT OR IGNORE — skip if already exists
cur.execute("""
INSERT OR IGNORE INTO project_modules (id, project_id, module_type, enabled)
VALUES (?, ?, ?, 1)
""", (str(uuid.uuid4()), pid, module_type))
if cur.rowcount > 0:
seeded += 1
conn.commit()
print(f" Seeded {seeded} module record(s) from existing project_type_id values.")
# 3. Make project_type_id nullable (SQLite doesn't support ALTER COLUMN,
# but since we're just loosening a constraint this is a no-op in SQLite —
# the column already accepts NULL in practice. Nothing to do.)
print(" project_type_id column is now treated as nullable (legacy field).")
conn.close()
print("Migration complete.")
if __name__ == "__main__":
run()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer, UniqueConstraint
from datetime import datetime
from backend.database import Base
@@ -177,7 +177,7 @@ class Project(Base):
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
project_type_id = Column(String, nullable=True) # Legacy FK to ProjectType.id; use ProjectModule for feature flags
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
# Data collection mode: how field data reaches Terra-View.
@@ -197,6 +197,22 @@ class Project(Base):
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
class ProjectModule(Base):
"""
Modules enabled on a project. Each module unlocks a set of features/tabs.
A project can have zero or more modules (sound_monitoring, vibration_monitoring, etc.).
"""
__tablename__ = "project_modules"
id = Column(String, primary_key=True, default=lambda: __import__('uuid').uuid4().__str__())
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)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("project_id", "module_type", name="uq_project_module"),)
class MonitoringLocation(Base):
"""
Monitoring locations: generic location for monitoring activities.

View File

@@ -24,6 +24,7 @@ from backend.database import get_db
from backend.models import (
Project,
ProjectType,
ProjectModule,
MonitoringLocation,
UnitAssignment,
RosterUnit,
@@ -40,12 +41,17 @@ router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations
# Shared helpers
# ============================================================================
def _require_sound_project(project) -> None:
"""Raise 400 if the project is not a sound_monitoring project."""
if not project or project.project_type_id != "sound_monitoring":
def _require_module(project, module_type: str, db: Session) -> None:
"""Raise 400 if the project does not have the given module enabled."""
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
exists = db.query(ProjectModule).filter_by(
project_id=project.id, module_type=module_type, enabled=True
).first()
if not exists:
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
)
@@ -762,7 +768,7 @@ async def upload_nrl_data(
# Verify project and location exist
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id
@@ -955,7 +961,7 @@ async def get_nrl_live_status(
import os
import httpx
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
# Find the assigned unit (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter(

View File

@@ -31,6 +31,7 @@ import pathlib as _pathlib
from backend.models import (
Project,
ProjectType,
ProjectModule,
MonitoringLocation,
UnitAssignment,
MonitoringSession,
@@ -49,11 +50,40 @@ logger = logging.getLogger(__name__)
# Shared helpers
# ============================================================================
def _require_sound_project(project: Project) -> None:
"""Raise 400 if the project is not a sound_monitoring project.
Call this at the top of any endpoint that only makes sense for sound projects
(report generation, FTP browser, RND file viewer, etc.)."""
if not project or project.project_type_id != "sound_monitoring":
# Registry of known module types. Add new entries here to make them available
# in the UI for any project.
MODULES = {
"sound_monitoring": {"name": "Sound Monitoring", "icon": "speaker", "color": "orange"},
"vibration_monitoring": {"name": "Vibration Monitoring", "icon": "activity", "color": "blue"},
}
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 _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:
raise HTTPException(status_code=404, detail="Project not found.")
exists = db.query(ProjectModule).filter_by(
project_id=project.id, module_type=module_type, enabled=True
).first()
if not exists:
module_name = MODULES.get(module_type, {}).get("name", module_type)
raise HTTPException(
status_code=400,
detail=f"This project does not have the {module_name} module enabled.",
)
# Keep legacy alias so any call sites not yet migrated still work
def _require_sound_project(project: Project, db: Session = None) -> None:
if db is not None:
_require_module(project, "sound_monitoring", db)
elif not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
@@ -604,7 +634,7 @@ async def create_project(request: Request, db: Session = Depends(get_db)):
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
name=form_data.get("name"),
description=form_data.get("description"),
project_type_id=form_data.get("project_type_id"),
project_type_id=form_data.get("project_type_id") or "",
status="active",
client_name=form_data.get("client_name"),
site_address=form_data.get("site_address"),
@@ -635,6 +665,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Project not found")
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
modules = _get_project_modules(project.id, db)
return {
"id": project.id,
@@ -643,6 +674,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
"description": project.description,
"project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None,
"modules": modules,
"status": project.status,
"client_name": project.client_name,
"site_address": project.site_address,
@@ -655,6 +687,58 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
}
@router.get("/{project_id}/modules")
async def list_project_modules(project_id: str, db: Session = Depends(get_db)):
"""Return enabled modules for a project, including metadata from MODULES registry."""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
active = {r.module_type for r in rows}
return {
"active": list(active),
"available": [
{"module_type": k, **v}
for k, v in MODULES.items()
if k not in active
],
}
@router.post("/{project_id}/modules")
async def add_project_module(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Add a module to a project. Body: {module_type: str}"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
data = await request.json()
module_type = data.get("module_type")
if not module_type or module_type not in MODULES:
raise HTTPException(status_code=400, detail=f"Unknown module type: {module_type}")
existing = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
if existing:
existing.enabled = True
else:
db.add(ProjectModule(
id=str(uuid.uuid4()),
project_id=project_id,
module_type=module_type,
enabled=True,
))
db.commit()
return {"ok": True, "modules": _get_project_modules(project_id, db)}
@router.delete("/{project_id}/modules/{module_type}")
async def remove_project_module(project_id: str, module_type: str, db: Session = Depends(get_db)):
"""Disable a module on a project. Data is not deleted."""
row = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
if row:
row.enabled = False
db.commit()
return {"ok": True, "modules": _get_project_modules(project_id, db)}
@router.put("/{project_id}")
async def update_project(
project_id: str,
@@ -867,6 +951,7 @@ async def get_project_dashboard(
"request": request,
"project": project,
"project_type": project_type,
"modules": _get_project_modules(project_id, db),
"locations": locations,
"assigned_units": assigned_units,
"active_sessions": active_sessions,
@@ -899,6 +984,7 @@ async def get_project_header(
"request": request,
"project": project,
"project_type": project_type,
"modules": _get_project_modules(project_id, db),
})
@@ -1371,7 +1457,7 @@ async def get_ftp_browser(
from backend.models import DataFile
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
@@ -1419,7 +1505,7 @@ async def ftp_download_to_server(
from pathlib import Path
from backend.models import DataFile
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
data = await request.json()
unit_id = data.get("unit_id")
@@ -1587,7 +1673,7 @@ async def ftp_download_folder_to_server(
import zipfile
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
from pathlib import Path
from backend.models import DataFile
@@ -2147,7 +2233,7 @@ async def view_session_detail(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session:
@@ -2233,7 +2319,7 @@ async def view_rnd_file(
# Get project info
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
# Get location info if available
location = None
@@ -2286,7 +2372,7 @@ async def get_rnd_data(
import csv
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
_require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
@@ -2446,7 +2532,7 @@ async def generate_excel_report(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -2877,7 +2963,7 @@ async def preview_report_data(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -3089,7 +3175,7 @@ async def generate_report_from_preview(
raise HTTPException(status_code=403, detail="File does not belong to this project")
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Extract data from request
@@ -3370,7 +3456,7 @@ async def generate_combined_excel_report(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
# Get all sessions with measurement files
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
@@ -3716,7 +3802,7 @@ async def combined_report_wizard(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
@@ -4003,7 +4089,7 @@ async def generate_combined_from_preview(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
report_title = data.get("report_title", "Background Noise Study")
project_name = data.get("project_name", project.name)
@@ -4479,7 +4565,7 @@ async def upload_all_project_data(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
_require_module(project, "sound_monitoring", db)
# Load all sound monitoring locations for this project
locations = db.query(MonitoringLocation).filter_by(