diff --git a/backend/routers/projects.py b/backend/routers/projects.py index f748d6e..ea5dc5d 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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)): """ diff --git a/requirements.txt b/requirements.txt index 9c7ba93..542f015 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ jinja2==3.1.2 aiofiles==23.2.1 Pillow==10.1.0 httpx==0.25.2 +openpyxl==3.1.2 diff --git a/templates/partials/projects/project_header.html b/templates/partials/projects/project_header.html index 3232aba..14d0e12 100644 --- a/templates/partials/projects/project_header.html +++ b/templates/partials/projects/project_header.html @@ -1,14 +1,30 @@
+ Report will include: +
+