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:
@@ -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}"'}
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user