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

@@ -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(