From 73a6ff4d2093077f4c0f928d8be844b9ef97f8fd Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 30 Mar 2026 21:44:15 +0000 Subject: [PATCH] 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. --- backend/migrate_add_project_modules.py | 71 +++++ backend/models.py | 20 +- backend/routers/project_locations.py | 18 +- backend/routers/projects.py | 124 +++++++-- templates/partials/project_create_modal.html | 43 ++- .../partials/projects/project_dashboard.html | 8 +- .../partials/projects/project_header.html | 95 ++++++- templates/projects/detail.html | 38 +-- templates/projects/overview.html | 251 ++++++++++-------- 9 files changed, 493 insertions(+), 175 deletions(-) create mode 100644 backend/migrate_add_project_modules.py diff --git a/backend/migrate_add_project_modules.py b/backend/migrate_add_project_modules.py new file mode 100644 index 0000000..404da49 --- /dev/null +++ b/backend/migrate_add_project_modules.py @@ -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() diff --git a/backend/models.py b/backend/models.py index b45f12f..b3c3665 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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. diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index efa21a8..701ffcf 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -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( diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 43c4d95..53a4bf5 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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( diff --git a/templates/partials/project_create_modal.html b/templates/partials/project_create_modal.html index bd34fad..53cf3a9 100644 --- a/templates/partials/project_create_modal.html +++ b/templates/partials/project_create_modal.html @@ -63,16 +63,25 @@ Include this modal in pages that use the project picker.
- +
+ + +
@@ -222,6 +231,20 @@ if (typeof openCreateProjectModal === 'undefined') { const result = await response.json(); if (response.ok && result.success) { + const projectId = result.project_id; + + // Add selected modules + const moduleMap = { module_sound: 'sound_monitoring', module_vibration: 'vibration_monitoring' }; + for (const [field, moduleType] of Object.entries(moduleMap)) { + if (formData.get(field)) { + await fetch(`/api/projects/${projectId}/modules`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ module_type: moduleType }), + }); + } + } + // Build display text from form values const parts = []; const projectNumber = formData.get('project_number'); @@ -235,7 +258,7 @@ if (typeof openCreateProjectModal === 'undefined') { const displayText = parts.join(' - '); // Select the newly created project in the picker - selectProject(result.project_id, displayText, pickerId); + selectProject(projectId, displayText, pickerId); // Close modal closeCreateProjectModal(); diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 6f04129..aaa3eea 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -52,14 +52,14 @@

- {% if project_type and project_type.id == 'sound_monitoring' %} + {% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %} NRLs {% else %} Locations {% endif %}

-
diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index ed21c4a..f3e3f2e 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -23,9 +23,30 @@
- {% if project_type %} - {{ project_type.name }} - {% endif %} + +
+ {% for m in modules %} + + {% if m == 'sound_monitoring' %} + + Sound Monitoring + {% elif m == 'vibration_monitoring' %} + + Vibration Monitoring + {% else %}{{ m }}{% endif %} + + + {% endfor %} + +
{% if project.data_collection_mode == 'remote' %} @@ -45,7 +66,7 @@
- {% if project_type and project_type.id == 'sound_monitoring' %} + {% if 'sound_monitoring' in modules %} @@ -57,3 +78,69 @@
+ + + + + diff --git a/templates/projects/detail.html b/templates/projects/detail.html index e3131dc..9e42246 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -770,7 +770,7 @@