fix: separate days now are in separate .xlsx files, NRLs still 1 per sheet.

add: rebuild script for prod.

fix: Improved data parsing, now filters out unneeded Lp files and .xlsx files.
This commit is contained in:
2026-03-06 23:37:24 +00:00
parent 14856e61ef
commit 67a2faa2d3
6 changed files with 372 additions and 169 deletions

View File

@@ -3379,12 +3379,17 @@ async def generate_combined_from_preview(
data: dict,
db: Session = Depends(get_db),
):
"""Generate combined Excel report from wizard-edited spreadsheet data."""
"""Generate combined Excel report from wizard-edited spreadsheet data.
Produces one .xlsx per day (each with one sheet per location) packaged
into a single .zip file for download.
"""
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
from openpyxl.worksheet.properties import PageSetupProperties
except ImportError:
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
@@ -3400,12 +3405,13 @@ async def generate_combined_from_preview(
if not locations:
raise HTTPException(status_code=400, detail="No location data provided")
# Styles
# Shared styles
f_title = Font(name='Arial', bold=True, size=12)
f_bold = Font(name='Arial', bold=True, size=10)
f_data = Font(name='Arial', size=10)
thin = Side(style='thin')
dbl = Side(style='double')
med = Side(style='medium')
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
@@ -3415,34 +3421,28 @@ async def generate_combined_from_preview(
data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
data_left = Border(left=dbl, right=thin, top=thin, bottom=thin)
data_right = Border(left=thin, right=dbl, top=thin, bottom=thin)
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
left_a = Alignment(horizontal='left', vertical='center')
right_a = Alignment(horizontal='right', vertical='center')
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
left_a = Alignment(horizontal='left', vertical='center')
thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
from openpyxl.worksheet.properties import PageSetupProperties
tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin)
tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin)
tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin)
tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin)
tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin)
tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin)
tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med)
tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med)
tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med)
wb = openpyxl.Workbook()
wb.remove(wb.active)
col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]
all_location_summaries = []
for loc_info in locations:
loc_name = loc_info.get("location_name", "Unknown")
rows = loc_info.get("spreadsheet_data", [])
if not rows:
continue
safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_sheet_name)
# Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
# A B C D E F G H I J K L M N O P
for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
def _build_location_sheet(ws, loc_name, day_rows, final_title):
"""Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
for col_i, col_w in zip(range(1, 17), col_widths):
ws.column_dimensions[get_column_letter(col_i)].width = col_w
final_title = f"{report_title} - {project_name}"
ws.merge_cells('A1:G1')
ws['A1'] = final_title
ws['A1'].font = f_title; ws['A1'].alignment = center_a
@@ -3463,16 +3463,13 @@ async def generate_combined_from_preview(
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
ws.row_dimensions[6].height = 39
# Data rows starting at row 7
data_start_row = 7
parsed_rows_p = []
lmax_vals = []
ln1_vals = []
ln2_vals = []
parsed_rows = []
lmax_vals, ln1_vals, ln2_vals = [], [], []
for row_idx, row in enumerate(rows):
for row_idx, row in enumerate(day_rows):
dr = data_start_row + row_idx
is_last = (row_idx == len(rows) - 1)
is_last = (row_idx == len(day_rows) - 1)
b_left = last_left if is_last else data_left
b_inner = last_inner if is_last else data_inner
b_right = last_right if is_last else data_right
@@ -3508,20 +3505,18 @@ async def generate_combined_from_preview(
if isinstance(ln2, (int, float)):
ln2_vals.append(ln2)
# Parse time for evening/nighttime stats
if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
try:
try:
row_dt = datetime.strptime(str(time_val), '%H:%M')
except ValueError:
row_dt = datetime.strptime(str(time_val), '%H:%M:%S')
parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2)))
parsed_rows.append((row_dt, float(lmax), float(ln1), float(ln2)))
except (ValueError, TypeError):
pass
data_end_row = data_start_row + len(rows) - 1
data_end_row = data_start_row + len(day_rows) - 1
# Chart anchored at H4
chart = LineChart()
chart.title = f"{loc_name} - {final_title}"
chart.style = 2
@@ -3545,7 +3540,6 @@ async def generate_combined_from_preview(
ws.add_chart(chart, "H4")
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
note1.font = f_data; note1.alignment = left_a
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
@@ -3553,21 +3547,7 @@ async def generate_combined_from_preview(
note2.font = f_data; note2.alignment = left_a
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
# Table header row 31
med = Side(style='medium')
tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
# Header row: blank | Evening | Nighttime
c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
@@ -3579,13 +3559,13 @@ async def generate_combined_from_preview(
ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
ws.row_dimensions[31].height = 15
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22]
nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7]
evening = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if 19 <= dt.hour < 22]
nighttime = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if dt.hour >= 22 or dt.hour < 7]
def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None
def _max_p(vals): return round(max(vals), 1) if vals else None
def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
def _max(vals): return round(max(vals), 1) if vals else None
def write_stat_p(row_num, label, eve_val, night_val, is_last=False):
def write_stat(row_num, label, eve_val, night_val, is_last=False):
bl = tbl_bot_left if is_last else tbl_mid_left
bm = tbl_bot_mid if is_last else tbl_mid_mid
br = tbl_bot_right if is_last else tbl_mid_right
@@ -3603,11 +3583,10 @@ async def generate_combined_from_preview(
ni.alignment = Alignment(horizontal='center', vertical='center')
ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)
write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p]))
write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p]))
write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True)
write_stat(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime]))
write_stat(33, "LA01 Average", _avg([v[1] for v in evening]), _avg([v[1] for v in nighttime]))
write_stat(34, "LA10 Average", _avg([v[2] for v in evening]), _avg([v[2] for v in nighttime]), is_last=True)
# Page setup: portrait, letter
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
ws.page_setup.orientation = 'portrait'
ws.page_setup.paperSize = 1
@@ -3618,50 +3597,94 @@ async def generate_combined_from_preview(
ws.page_margins.header = 0.5
ws.page_margins.footer = 0.5
all_location_summaries.append({
return {
'location': loc_name,
'samples': len(rows),
'samples': len(day_rows),
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
})
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
}
# Summary sheet
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
top=Side(style='thin'), bottom=Side(style='thin'))
summary_ws = wb.create_sheet(title="Summary", index=0)
summary_ws['A1'] = f"{report_title} - {project_name} - Summary"
summary_ws['A1'].font = f_title
summary_ws.merge_cells('A1:E1')
def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
summary_ws = wb.create_sheet(title="Summary", index=0)
summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
summary_ws['A1'].font = f_title
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 = f_bold; cell.fill = hdr_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, s in enumerate(loc_summaries, 4):
summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border
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 = f_bold
cell.fill = hdr_fill
cell.border = thin_border
# ----------------------------------------------------------------
# Split each location's rows by date, collect all unique dates
# ----------------------------------------------------------------
# Structure: dates_map[date_str][loc_name] = [row, ...]
dates_map: dict = {}
for loc_info in locations:
loc_name = loc_info.get("location_name", "Unknown")
rows = loc_info.get("spreadsheet_data", [])
for row in rows:
date_val = str(row[1]).strip() if len(row) > 1 else ''
if not date_val:
date_val = "Unknown Date"
dates_map.setdefault(date_val, {}).setdefault(loc_name, []).append(row)
for i, width in enumerate([30, 10, 12, 12, 12], 1):
summary_ws.column_dimensions[get_column_letter(i)].width = width
if not dates_map:
raise HTTPException(status_code=400, detail="No data rows found in provided location data")
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
sorted_dates = sorted(dates_map.keys())
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
output = io.BytesIO()
wb.save(output)
output.seek(0)
# ----------------------------------------------------------------
# Build one workbook per day, zip them
# ----------------------------------------------------------------
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for date_str in sorted_dates:
loc_data_for_day = dates_map[date_str]
final_title = f"{report_title} - {project_name}"
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".replace(' ', '_')
wb = openpyxl.Workbook()
wb.remove(wb.active)
loc_summaries = []
for loc_name in sorted(loc_data_for_day.keys()):
day_rows = loc_data_for_day[loc_name]
# Re-number interval # sequentially for this day
for i, row in enumerate(day_rows):
if len(row) > 0:
row[0] = i + 1
safe_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_name)
summary = _build_location_sheet(ws, loc_name, day_rows, final_title)
loc_summaries.append(summary)
_build_summary_sheet(wb, date_str, project_name, loc_summaries)
xlsx_buf = io.BytesIO()
wb.save(xlsx_buf)
xlsx_buf.seek(0)
date_clean = date_str.replace('/', '-').replace(' ', '_')
xlsx_name = f"{project_name_clean}_{date_clean}_report.xlsx"
zf.writestr(xlsx_name, xlsx_buf.read())
zip_buffer.seek(0)
zip_filename = f"{project_name_clean}_reports.zip"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
)