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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user