Update main to 0.5.1. See changelog. #18

Merged
serversdown merged 16 commits from dev into main 2026-01-27 22:29:57 -05:00
5 changed files with 973 additions and 13 deletions
Showing only changes of commit a9c9b1fd48 - Show all commits

View File

@@ -10,7 +10,7 @@ Provides API endpoints for the Projects system:
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from datetime import datetime, timedelta
@@ -18,6 +18,7 @@ from typing import Optional
import uuid
import json
import logging
import io
from backend.database import get_db
from backend.models import (
@@ -1025,6 +1026,147 @@ async def download_project_file(
)
@router.get("/{project_id}/sessions/{session_id}/download-all")
async def download_session_files(
project_id: str,
session_id: str,
db: Session = Depends(get_db),
):
"""
Download all files from a session as a single zip archive.
"""
from backend.models import DataFile
from pathlib import Path
import zipfile
# Verify session belongs to this project
session = db.query(RecordingSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project")
# Get all files for this session
files = db.query(DataFile).filter_by(session_id=session_id).all()
if not files:
raise HTTPException(status_code=404, detail="No files found in this session")
# Create zip in memory
zip_buffer = io.BytesIO()
# Get session info for folder naming
session_date = session.started_at.strftime('%Y-%m-%d_%H%M') if session.started_at else 'unknown'
# Get unit and location for naming
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
unit_name = unit.id if unit else "unknown_unit"
location_name = location.name.replace(" ", "_") if location else ""
# Build folder name for zip contents
folder_name = f"{session_date}_{unit_name}"
if location_name:
folder_name += f"_{location_name}"
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for file_record in files:
file_path = Path("data") / file_record.file_path
if file_path.exists():
# Add file to zip with folder structure
arcname = f"{folder_name}/{file_path.name}"
zip_file.write(file_path, arcname)
zip_buffer.seek(0)
# Generate filename for the zip
zip_filename = f"{folder_name}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
)
@router.delete("/{project_id}/files/{file_id}")
async def delete_project_file(
project_id: str,
file_id: str,
db: Session = Depends(get_db),
):
"""
Delete a single data file from a project.
Removes both the database record and the file on disk.
"""
from backend.models import DataFile
from pathlib import Path
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
# Delete file from disk if it exists
file_path = Path("data") / file_record.file_path
if file_path.exists():
file_path.unlink()
# Delete database record
db.delete(file_record)
db.commit()
return JSONResponse({"status": "success", "message": "File deleted"})
@router.delete("/{project_id}/sessions/{session_id}")
async def delete_session(
project_id: str,
session_id: str,
db: Session = Depends(get_db),
):
"""
Delete an entire session and all its files.
Removes database records and files on disk.
"""
from backend.models import DataFile
from pathlib import Path
# Verify session belongs to this project
session = db.query(RecordingSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project")
# Get all files for this session
files = db.query(DataFile).filter_by(session_id=session_id).all()
# Delete files from disk
deleted_count = 0
for file_record in files:
file_path = Path("data") / file_record.file_path
if file_path.exists():
file_path.unlink()
deleted_count += 1
# Delete database record
db.delete(file_record)
# Delete the session record
db.delete(session)
db.commit()
return JSONResponse({
"status": "success",
"message": f"Session and {deleted_count} file(s) deleted"
})
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
async def view_rnd_file(
request: Request,
@@ -1195,6 +1337,586 @@ async def get_rnd_data(
raise HTTPException(status_code=500, detail=f"Error parsing file: {str(e)}")
@router.get("/{project_id}/files/{file_id}/generate-report")
async def generate_excel_report(
project_id: str,
file_id: str,
report_title: str = Query("Background Noise Study", description="Title for the report"),
location_name: str = Query("", description="Location name (e.g., 'NRL 1 - West Side')"),
db: Session = Depends(get_db),
):
"""
Generate an Excel report from an RND file.
Creates a formatted Excel workbook with:
- Title and location headers
- Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)
- Line chart visualization
- Time period summary statistics
Column mapping from RND to Report:
- Lmax(Main) -> LAmax (dBA)
- LN1(Main) -> LA01 (dBA) [L1 percentile]
- LN2(Main) -> LA10 (dBA) [L10 percentile]
"""
from backend.models import DataFile
from pathlib import Path
import csv
try:
import openpyxl
from openpyxl.chart import LineChart, Reference
from openpyxl.chart.label import DataLabelList
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
except ImportError:
raise HTTPException(
status_code=500,
detail="openpyxl is not installed. Run: pip install openpyxl"
)
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
# Verify file belongs to this project
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
if not session or session.project_id != project_id:
raise HTTPException(status_code=403, detail="File does not belong to this project")
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
file_path = Path("data") / file_record.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Validate this is a Leq file (contains '_Leq_' in path)
# Lp files (instantaneous 100ms readings) don't have the LN percentile data needed for reports
if '_Leq_' not in file_record.file_path:
raise HTTPException(
status_code=400,
detail="Reports can only be generated from Leq files (15-minute averaged data). This appears to be an Lp (instantaneous) file."
)
# Read and parse the Leq RND file
try:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
reader = csv.DictReader(io.StringIO(content))
rnd_rows = []
for row in reader:
cleaned_row = {}
for key, value in row.items():
if key:
cleaned_key = key.strip()
cleaned_value = value.strip() if value else ''
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
try:
cleaned_value = float(cleaned_value)
except ValueError:
pass
elif cleaned_value in ['-.-', '-']:
cleaned_value = None
cleaned_row[cleaned_key] = cleaned_value
rnd_rows.append(cleaned_row)
if not rnd_rows:
raise HTTPException(status_code=400, detail="No data found in RND file")
except Exception as e:
logger.error(f"Error reading RND file: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
# Create Excel workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sound Level Data"
# Define styles
title_font = Font(bold=True, size=14)
header_font = Font(bold=True, size=10)
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
# Row 1: Report title
final_title = report_title
if project:
final_title = f"{report_title} - {project.name}"
ws['A1'] = final_title
ws['A1'].font = title_font
ws.merge_cells('A1:G1')
# Row 3: Location name
final_location = location_name
if not final_location and location:
final_location = location.name
if final_location:
ws['A3'] = final_location
ws['A3'].font = Font(bold=True, size=11)
# Row 7: Headers
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=7, column=col, value=header)
cell.font = header_font
cell.border = thin_border
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Set column widths
column_widths = [16, 12, 10, 12, 12, 12, 40]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[get_column_letter(i)].width = width
# Data rows starting at row 8
data_start_row = 8
for idx, row in enumerate(rnd_rows, 1):
data_row = data_start_row + idx - 1
# Test Increment #
ws.cell(row=data_row, column=1, value=idx).border = thin_border
# Parse the Start Time to get Date and Time
start_time_str = row.get('Start Time', '')
if start_time_str:
try:
# Format: "2025/12/26 20:23:38"
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
ws.cell(row=data_row, column=2, value=dt.date())
ws.cell(row=data_row, column=3, value=dt.time())
except ValueError:
ws.cell(row=data_row, column=2, value=start_time_str)
ws.cell(row=data_row, column=3, value='')
else:
ws.cell(row=data_row, column=2, value='')
ws.cell(row=data_row, column=3, value='')
# LAmax - from Lmax(Main)
lmax = row.get('Lmax(Main)')
ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border
# LA01 - from LN1(Main)
ln1 = row.get('LN1(Main)')
ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border
# LA10 - from LN2(Main)
ln2 = row.get('LN2(Main)')
ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border
# Comments (empty for now, can be populated)
ws.cell(row=data_row, column=7, value='').border = thin_border
# Apply borders to date/time cells
ws.cell(row=data_row, column=2).border = thin_border
ws.cell(row=data_row, column=3).border = thin_border
data_end_row = data_start_row + len(rnd_rows) - 1
# Add Line Chart
chart = LineChart()
chart.title = f"{final_location or 'Sound Level Data'} - Background Noise Study"
chart.style = 10
chart.y_axis.title = "Sound Level (dBA)"
chart.x_axis.title = "Test Increment"
chart.height = 12
chart.width = 20
# Data references (LAmax, LA01, LA10 are columns D, E, F)
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
categories = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_end_row)
chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(categories)
# Style the series
if len(chart.series) >= 3:
chart.series[0].graphicalProperties.line.solidFill = "FF0000" # LAmax - Red
chart.series[1].graphicalProperties.line.solidFill = "00B050" # LA01 - Green
chart.series[2].graphicalProperties.line.solidFill = "0070C0" # LA10 - Blue
# Position chart to the right of data
ws.add_chart(chart, "I3")
# Add summary statistics section below the data
summary_row = data_end_row + 3
ws.cell(row=summary_row, column=1, value="Summary Statistics").font = Font(bold=True, size=12)
# Calculate time-period statistics
time_periods = {
'Evening (7PM-10PM)': [],
'Nighttime (10PM-7AM)': [],
'Morning (7AM-12PM)': [],
'Daytime (12PM-7PM)': []
}
for row in rnd_rows:
start_time_str = row.get('Start Time', '')
if start_time_str:
try:
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
hour = dt.hour
lmax = row.get('Lmax(Main)')
ln1 = row.get('LN1(Main)')
ln2 = row.get('LN2(Main)')
if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
data_point = {'lmax': lmax, 'ln1': ln1, 'ln2': ln2}
if 19 <= hour < 22:
time_periods['Evening (7PM-10PM)'].append(data_point)
elif hour >= 22 or hour < 7:
time_periods['Nighttime (10PM-7AM)'].append(data_point)
elif 7 <= hour < 12:
time_periods['Morning (7AM-12PM)'].append(data_point)
else: # 12-19
time_periods['Daytime (12PM-7PM)'].append(data_point)
except ValueError:
continue
# Summary table headers
summary_row += 2
summary_headers = ['Time Period', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
for col, header in enumerate(summary_headers, 1):
cell = ws.cell(row=summary_row, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = thin_border
# Summary data
summary_row += 1
for period_name, samples in time_periods.items():
ws.cell(row=summary_row, column=1, value=period_name).border = thin_border
ws.cell(row=summary_row, column=2, value=len(samples)).border = thin_border
if samples:
avg_lmax = sum(s['lmax'] for s in samples) / len(samples)
avg_ln1 = sum(s['ln1'] for s in samples) / len(samples)
avg_ln2 = sum(s['ln2'] for s in samples) / len(samples)
ws.cell(row=summary_row, column=3, value=round(avg_lmax, 1)).border = thin_border
ws.cell(row=summary_row, column=4, value=round(avg_ln1, 1)).border = thin_border
ws.cell(row=summary_row, column=5, value=round(avg_ln2, 1)).border = thin_border
else:
ws.cell(row=summary_row, column=3, value='-').border = thin_border
ws.cell(row=summary_row, column=4, value='-').border = thin_border
ws.cell(row=summary_row, column=5, value='-').border = thin_border
summary_row += 1
# Overall summary
summary_row += 1
ws.cell(row=summary_row, column=1, value='Overall').font = Font(bold=True)
ws.cell(row=summary_row, column=1).border = thin_border
ws.cell(row=summary_row, column=2, value=len(rnd_rows)).border = thin_border
all_lmax = [r.get('Lmax(Main)') for r in rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))]
all_ln1 = [r.get('LN1(Main)') for r in rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))]
all_ln2 = [r.get('LN2(Main)') for r in rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))]
if all_lmax:
ws.cell(row=summary_row, column=3, value=round(sum(all_lmax) / len(all_lmax), 1)).border = thin_border
if all_ln1:
ws.cell(row=summary_row, column=4, value=round(sum(all_ln1) / len(all_ln1), 1)).border = thin_border
if all_ln2:
ws.cell(row=summary_row, column=5, value=round(sum(all_ln2) / len(all_ln2), 1)).border = thin_border
# Save to buffer
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
filename = file_record.file_path.split('/')[-1].replace('.rnd', '')
if location:
filename = f"{location.name}_{filename}"
filename = f"{filename}_report.xlsx"
# Clean filename
filename = "".join(c for c in filename if c.isalnum() or c in ('_', '-', '.')).rstrip()
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/{project_id}/generate-combined-report")
async def generate_combined_excel_report(
project_id: str,
report_title: str = Query("Background Noise Study", description="Title for the report"),
db: Session = Depends(get_db),
):
"""
Generate a combined Excel report from all RND files in a project.
Creates a multi-sheet Excel workbook with:
- One sheet per location/RND file
- Data tables with LAmax, LA01, LA10
- Line charts for each location
- Summary sheet combining all locations
Column mapping from RND to Report:
- Lmax(Main) -> LAmax (dBA)
- LN1(Main) -> LA01 (dBA) [L1 percentile]
- LN2(Main) -> LA10 (dBA) [L10 percentile]
"""
from backend.models import DataFile
from pathlib import Path
import csv
try:
import openpyxl
from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
except ImportError:
raise HTTPException(
status_code=500,
detail="openpyxl is not installed. Run: pip install openpyxl"
)
# Get project
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Get all sessions with measurement files
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
# Collect all Leq RND files grouped by location
# Only include files with '_Leq_' in the path (15-minute averaged data)
# Exclude Lp files (instantaneous 100ms readings)
location_files = {}
for session in sessions:
files = db.query(DataFile).filter_by(session_id=session.id).all()
for file in files:
# Only include Leq files for reports (contain '_Leq_' in path)
is_leq_file = file.file_path and '_Leq_' in file.file_path and file.file_path.endswith('.rnd')
if is_leq_file:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
location_name = location.name if location else f"Session {session.id[:8]}"
if location_name not in location_files:
location_files[location_name] = []
location_files[location_name].append({
'file': file,
'session': session,
'location': location
})
if not location_files:
raise HTTPException(status_code=404, detail="No Leq measurement files found in project. Reports require Leq data (files with '_Leq_' in the name).")
# Define styles
title_font = Font(bold=True, size=14)
header_font = Font(bold=True, size=10)
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid")
# Create Excel workbook
wb = openpyxl.Workbook()
# Remove default sheet
wb.remove(wb.active)
# Track all data for summary
all_location_summaries = []
# Create a sheet for each location
for location_name, file_list in location_files.items():
# Sanitize sheet name (max 31 chars, no special chars)
safe_sheet_name = "".join(c for c in location_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_sheet_name)
# Row 1: Report title
final_title = f"{report_title} - {project.name}"
ws['A1'] = final_title
ws['A1'].font = title_font
ws.merge_cells('A1:G1')
# Row 3: Location name
ws['A3'] = location_name
ws['A3'].font = Font(bold=True, size=11)
# Row 7: Headers
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=7, column=col, value=header)
cell.font = header_font
cell.border = thin_border
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Set column widths
column_widths = [16, 12, 10, 12, 12, 12, 40]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[get_column_letter(i)].width = width
# Combine data from all files for this location
all_rnd_rows = []
for file_info in file_list:
file = file_info['file']
file_path = Path("data") / file.file_path
if not file_path.exists():
continue
try:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
reader = csv.DictReader(io.StringIO(content))
for row in reader:
cleaned_row = {}
for key, value in row.items():
if key:
cleaned_key = key.strip()
cleaned_value = value.strip() if value else ''
if cleaned_value and cleaned_value not in ['-.-', '-', '']:
try:
cleaned_value = float(cleaned_value)
except ValueError:
pass
elif cleaned_value in ['-.-', '-']:
cleaned_value = None
cleaned_row[cleaned_key] = cleaned_value
all_rnd_rows.append(cleaned_row)
except Exception as e:
logger.warning(f"Error reading file {file.file_path}: {e}")
continue
if not all_rnd_rows:
continue
# Sort by start time
all_rnd_rows.sort(key=lambda r: r.get('Start Time', ''))
# Data rows starting at row 8
data_start_row = 8
for idx, row in enumerate(all_rnd_rows, 1):
data_row = data_start_row + idx - 1
ws.cell(row=data_row, column=1, value=idx).border = thin_border
start_time_str = row.get('Start Time', '')
if start_time_str:
try:
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
ws.cell(row=data_row, column=2, value=dt.date())
ws.cell(row=data_row, column=3, value=dt.time())
except ValueError:
ws.cell(row=data_row, column=2, value=start_time_str)
ws.cell(row=data_row, column=3, value='')
else:
ws.cell(row=data_row, column=2, value='')
ws.cell(row=data_row, column=3, value='')
lmax = row.get('Lmax(Main)')
ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border
ln1 = row.get('LN1(Main)')
ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border
ln2 = row.get('LN2(Main)')
ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border
ws.cell(row=data_row, column=7, value='').border = thin_border
ws.cell(row=data_row, column=2).border = thin_border
ws.cell(row=data_row, column=3).border = thin_border
data_end_row = data_start_row + len(all_rnd_rows) - 1
# Add Line Chart
chart = LineChart()
chart.title = f"{location_name}"
chart.style = 10
chart.y_axis.title = "Sound Level (dBA)"
chart.x_axis.title = "Test Increment"
chart.height = 12
chart.width = 20
data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row)
categories = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_end_row)
chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(categories)
if len(chart.series) >= 3:
chart.series[0].graphicalProperties.line.solidFill = "FF0000"
chart.series[1].graphicalProperties.line.solidFill = "00B050"
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
ws.add_chart(chart, "I3")
# Calculate summary for this location
all_lmax = [r.get('Lmax(Main)') for r in all_rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))]
all_ln1 = [r.get('LN1(Main)') for r in all_rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))]
all_ln2 = [r.get('LN2(Main)') for r in all_rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))]
all_location_summaries.append({
'location': location_name,
'samples': len(all_rnd_rows),
'lmax_avg': round(sum(all_lmax) / len(all_lmax), 1) if all_lmax else None,
'ln1_avg': round(sum(all_ln1) / len(all_ln1), 1) if all_ln1 else None,
'ln2_avg': round(sum(all_ln2) / len(all_ln2), 1) if all_ln2 else None,
})
# Create Summary sheet at the beginning
summary_ws = wb.create_sheet(title="Summary", index=0)
summary_ws['A1'] = f"{report_title} - {project.name} - Summary"
summary_ws['A1'].font = title_font
summary_ws.merge_cells('A1:E1')
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
for col, header in enumerate(summary_headers, 1):
cell = summary_ws.cell(row=3, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = thin_border
for i, width in enumerate([30, 10, 12, 12, 12], 1):
summary_ws.column_dimensions[get_column_letter(i)].width = width
for idx, loc_summary in enumerate(all_location_summaries, 4):
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
# Save to buffer
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
project_name_clean = "".join(c for c in project.name if c.isalnum() or c in ('_', '-', ' ')).strip()
filename = f"{project_name_clean}_combined_report.xlsx"
filename = filename.replace(' ', '_')
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/types/list", response_class=HTMLResponse)
async def get_project_types(request: Request, db: Session = Depends(get_db)):
"""

View File

@@ -7,3 +7,4 @@ jinja2==3.1.2
aiofiles==23.2.1
Pillow==10.1.0
httpx==0.25.2
openpyxl==3.1.2

View File

@@ -1,14 +1,30 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ project.status|title }}
</span>
{% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %}
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ project.status|title }}
</span>
{% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %}
</div>
</div>
<!-- Project Actions -->
<div class="flex items-center gap-3">
{% if project_type and project_type.id == 'sound_monitoring' %}
<a href="/api/projects/{{ project.id }}/generate-combined-report"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
{% endif %}
</div>
</div>
</div>

View File

@@ -42,6 +42,24 @@
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }}
</span>
<!-- Download All Files in Session -->
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
title="Download all files in this session">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download All
</button>
<!-- Delete Session -->
<button onclick="event.stopPropagation(); confirmDeleteSession('{{ session.id }}', '{{ files|length }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-1"
title="Delete session and all files">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete
</button>
</div>
</div>
</div>
@@ -107,6 +125,17 @@
{{ file.file_type or 'unknown' }}
</span>
{# Leq vs Lp badge for RND files #}
{% if file.file_path and '_Leq_' in file.file_path %}
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Leq (15-min avg)
</span>
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
Lp (instant)
</span>
{% endif %}
<!-- File Size -->
<span class="mx-1"></span>
{% if file.file_size_bytes %}
@@ -159,6 +188,18 @@
View
</a>
{% endif %}
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if '_Leq_' in file.file_path %}
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
onclick="event.stopPropagation();"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
title="Generate Excel Report">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Report
</a>
{% endif %}
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -166,6 +207,13 @@
</svg>
Download
</button>
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
title="Delete this file">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
{% endif %}
</div>
@@ -212,4 +260,56 @@ function toggleSession(sessionId, headerElement) {
function downloadFile(fileId) {
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
function downloadSessionFiles(sessionId) {
window.location.href = `/api/projects/{{ project_id }}/sessions/${sessionId}/download-all`;
}
function confirmDeleteFile(fileId, fileName) {
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
deleteFile(fileId);
}
}
async function deleteFile(fileId) {
try {
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
method: 'DELETE'
});
if (response.ok) {
// Reload the files list
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error deleting file: ${error.message}`);
}
}
function confirmDeleteSession(sessionId, fileCount) {
if (confirm(`Are you sure you want to delete this session and all ${fileCount} file(s)?\n\nThis action cannot be undone.`)) {
deleteSession(sessionId);
}
}
async function deleteSession(sessionId) {
try {
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'DELETE'
});
if (response.ok) {
// Reload the files list
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to delete session: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error deleting session: ${error.message}`);
}
}
</script>

View File

@@ -59,12 +59,23 @@
</p>
</div>
<div class="flex items-center gap-3">
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if file and '_Leq_' in file.file_path %}
<!-- Generate Excel Report Button -->
<button onclick="openReportModal()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Excel Report
</button>
{% endif %}
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/download"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download File
Download RND
</a>
<a href="/projects/{{ project_id }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
@@ -441,5 +452,115 @@ function escapeHtml(str) {
div.textContent = str;
return div.innerHTML;
}
// Report Generation Modal Functions
function openReportModal() {
document.getElementById('report-modal').classList.remove('hidden');
// Pre-fill location name if available
const locationInput = document.getElementById('report-location');
if (locationInput && !locationInput.value) {
locationInput.value = '{{ location.name if location else "" }}';
}
}
function closeReportModal() {
document.getElementById('report-modal').classList.add('hidden');
}
function generateReport() {
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
const locationName = document.getElementById('report-location').value || '';
// Build the URL with query parameters
const params = new URLSearchParams({
report_title: reportTitle,
location_name: locationName
});
// Trigger download
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
// Close modal
closeReportModal();
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeReportModal();
}
});
</script>
<!-- Report Generation Modal -->
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
Generate Excel Report
</h3>
<div class="mt-4 space-y-4">
<!-- Report Title -->
<div>
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="e.g., Background Noise Study - Commercial Street Bridge">
</div>
<!-- Location Name -->
<div>
<label for="report-location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Location Name
</label>
<input type="text" id="report-location" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="e.g., NRL 1 - West Side">
</div>
<!-- Info about what's included -->
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
<strong>Report will include:</strong>
</p>
<ul class="mt-1 text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
<li>Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)</li>
<li>Line chart visualization</li>
<li>Time period summary (Evening, Night, Morning, Daytime)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2">
<button type="button" onclick="generateReport()"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Generate & Download
</button>
<button type="button" onclick="closeReportModal()"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %}