06/05 Optimize app

This commit is contained in:
2026-06-05 15:51:57 -04:00
parent 458044201e
commit 025f3f8823
14 changed files with 425 additions and 64 deletions
+70 -51
View File
@@ -1,5 +1,10 @@
"""
Export Service — CSV, Excel, and PDF generation for transactions and reports.
Memory-efficient exports:
- CSV: streaming generator (rows written one at a time, never all in memory)
- Excel: openpyxl write-only mode + DB yield_per(500) avoids loading the full
result set into Python at once
"""
import io
@@ -11,13 +16,20 @@ from app.models.transaction import Transaction
# ── CSV export ────────────────────────────────────────────────────────────────
def transactions_to_csv(transactions):
"""Return a CSV string of transactions."""
output = io.StringIO()
writer = csv.writer(output)
def transactions_csv_stream(query):
"""
Generator that yields CSV text one row at a time.
Pass the SQLAlchemy *query* (not a list) — rows are fetched in 500-row batches.
Use with Flask's stream_with_context() for a true streaming response.
"""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['Date', 'Type', 'Description', 'Category', 'Account', 'Amount', 'Notes'])
for txn in transactions:
yield buf.getvalue()
buf.seek(0); buf.truncate()
for txn in query.yield_per(500):
writer.writerow([
txn.date.strftime('%Y-%m-%d'),
txn.transaction_type,
@@ -27,80 +39,87 @@ def transactions_to_csv(transactions):
float(txn.amount),
txn.notes or '',
])
output.seek(0)
return output.getvalue()
yield buf.getvalue()
buf.seek(0); buf.truncate()
# ── Excel export ──────────────────────────────────────────────────────────────
def transactions_to_excel(transactions, period_label='Transactions'):
"""Return Excel bytes for a list of transactions."""
def transactions_to_excel(query, period_label='Transactions'):
"""
Return Excel bytes built from a SQLAlchemy *query* using openpyxl write-only
mode. Rows are fetched 500 at a time so the full result set is never held in
Python memory simultaneously.
"""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.cell import WriteOnlyCell
from openpyxl.utils import get_column_letter
wb = Workbook()
ws = wb.active
ws.title = period_label[:31] # max 31 chars
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
# Header style
wb = Workbook(write_only=True)
ws = wb.create_sheet(title=period_label[:31])
col_widths = [12, 10, 40, 18, 18, 16, 30]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[get_column_letter(i)].width = w
header_fill = PatternFill(start_color='0F172A', end_color='0F172A', fill_type='solid')
header_font = Font(color='F1F5F9', bold=True, size=10)
thin = Side(style='thin', color='E2E8F0')
border = Border(bottom=Side(style='thin', color='E2E8F0'))
headers = ['Date', 'Type', 'Description', 'Category', 'Account',
f'Amount ({symbol})', 'Notes']
header_row = []
for h in headers:
c = WriteOnlyCell(ws, value=h)
c.font = header_font
c.fill = header_fill
header_row.append(c)
ws.append(header_row)
headers = ['Date', 'Type', 'Description', 'Category', 'Account', f'Amount ({symbol})', 'Notes']
col_widths = [12, 10, 40, 18, 18, 16, 30]
for col, (header, width) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='left', vertical='center')
ws.column_dimensions[get_column_letter(col)].width = width
ws.row_dimensions[1].height = 22
# Data rows
income_fill = PatternFill(start_color='F0FDF4', end_color='F0FDF4', fill_type='solid')
expense_fill = PatternFill(start_color='FFF7F7', end_color='FFF7F7', fill_type='solid')
row_font = Font(size=10)
amt_fmt = '#,##0.00'
for row_num, txn in enumerate(transactions, 2):
running_total = 0.0
for txn in query.yield_per(500):
fill = income_fill if txn.transaction_type == 'income' else expense_fill
data = [
amt = float(txn.amount)
running_total += amt
row_vals = [
txn.date.strftime('%Y-%m-%d'),
txn.transaction_type.title(),
txn.description,
txn.category.name if txn.category else '',
txn.account.name if txn.account else '',
float(txn.amount),
amt,
txn.notes or '',
]
for col, value in enumerate(data, 1):
cell = ws.cell(row=row_num, column=col, value=value)
cell.fill = fill
cell.border = border
cell.font = Font(size=10)
if col == 6:
cell.number_format = f'#,##0.00'
cell.alignment = Alignment(horizontal='right')
row = []
for col_idx, value in enumerate(row_vals, 1):
c = WriteOnlyCell(ws, value=value)
c.font = row_font
c.fill = fill
if col_idx == 6:
c.number_format = amt_fmt
c.alignment = Alignment(horizontal='right')
row.append(c)
ws.append(row)
# Totals row
total_row = len(transactions) + 2
ws.cell(row=total_row, column=5, value='TOTAL').font = Font(bold=True, size=10)
total_cell = ws.cell(row=total_row, column=6,
value=sum(float(t.amount) for t in transactions))
total_cell.font = Font(bold=True, size=10)
total_cell.number_format = f'#,##0.00'
total_cell.alignment = Alignment(horizontal='right')
# Totals row — plain cells (write-only, no random access)
total_lbl = WriteOnlyCell(ws, value='TOTAL')
total_lbl.font = Font(bold=True, size=10)
total_val = WriteOnlyCell(ws, value=running_total)
total_val.font = Font(bold=True, size=10)
total_val.number_format = amt_fmt
total_val.alignment = Alignment(horizontal='right')
ws.append(['', '', '', '', total_lbl, total_val, ''])
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.getvalue()
return output.read()
# ── PDF export ────────────────────────────────────────────────────────────────