06/05 Optimize app
This commit is contained in:
@@ -148,6 +148,22 @@ def create_app(config_name=None):
|
|||||||
from app.models.app_log import AppLog
|
from app.models.app_log import AppLog
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# ── Context processor — global template vars ─────────────────────────────
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
from flask_login import current_user as _u
|
||||||
|
ctx = {'plaid_review_count': 0}
|
||||||
|
if _u.is_authenticated:
|
||||||
|
try:
|
||||||
|
from app.models.transaction import Transaction as _T
|
||||||
|
ctx['plaid_review_count'] = _T.query.filter(
|
||||||
|
_T.notes.like('Plaid:%'),
|
||||||
|
_T.category_id.is_(None),
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ctx
|
||||||
|
|
||||||
# ── Session idle timeout ──────────────────────────────────────────────────
|
# ── Session idle timeout ──────────────────────────────────────────────────
|
||||||
from flask import session as _session, request as _request
|
from flask import session as _session, request as _request
|
||||||
from flask_login import current_user as _cu
|
from flask_login import current_user as _cu
|
||||||
|
|||||||
+12
-1
@@ -5,6 +5,7 @@ from app.extensions import db
|
|||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
|
from app.models.recurring_rule import RecurringRule
|
||||||
from app.services.fx_service import get_today_rate, get_rate_history, force_refresh
|
from app.services.fx_service import get_today_rate, get_rate_history, force_refresh
|
||||||
from app.services.ai_service import get_latest_daily_insight
|
from app.services.ai_service import get_latest_daily_insight
|
||||||
from app.services.account_service import get_total_assets, get_total_liabilities
|
from app.services.account_service import get_total_assets, get_total_liabilities
|
||||||
@@ -145,6 +146,14 @@ def index():
|
|||||||
chart_income.append(float(inc))
|
chart_income.append(float(inc))
|
||||||
chart_expense.append(float(exp))
|
chart_expense.append(float(exp))
|
||||||
|
|
||||||
|
# ── Upcoming bills (recurring rules due within 14 days) ──────────────────
|
||||||
|
upcoming_bills = RecurringRule.query.filter(
|
||||||
|
RecurringRule.is_active == True,
|
||||||
|
RecurringRule.next_run != None,
|
||||||
|
RecurringRule.next_run >= today,
|
||||||
|
RecurringRule.next_run <= today + timedelta(days=14),
|
||||||
|
).order_by(RecurringRule.next_run).limit(8).all()
|
||||||
|
|
||||||
# ── Recent transactions ───────────────────────────
|
# ── Recent transactions ───────────────────────────
|
||||||
recent_txns = Transaction.query\
|
recent_txns = Transaction.query\
|
||||||
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
|
.filter(Transaction.transaction_type.in_(['income', 'expense']))\
|
||||||
@@ -185,7 +194,9 @@ def index():
|
|||||||
fx=fx,
|
fx=fx,
|
||||||
fx_history_data=fx_history_data,
|
fx_history_data=fx_history_data,
|
||||||
ai_insight=ai_insight,
|
ai_insight=ai_insight,
|
||||||
schwab_warning=schwab_warning)
|
schwab_warning=schwab_warning,
|
||||||
|
upcoming_bills=upcoming_bills,
|
||||||
|
today=today)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/api/reconcile')
|
@dashboard_bp.route('/api/reconcile')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.models.investment import Investment, InvestmentTransaction
|
|||||||
from app.services.investment_service import (
|
from app.services.investment_service import (
|
||||||
get_portfolio_summary, update_prices, fetch_price,
|
get_portfolio_summary, update_prices, fetch_price,
|
||||||
fetch_price_history, fetch_day_change,
|
fetch_price_history, fetch_day_change,
|
||||||
|
get_price_alerts,
|
||||||
ASSET_COLORS, ASSET_TYPE_LABELS
|
ASSET_COLORS, ASSET_TYPE_LABELS
|
||||||
)
|
)
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -341,6 +342,13 @@ def api_day_change(ticker):
|
|||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@investments_bp.route('/api/price-alerts')
|
||||||
|
@login_required
|
||||||
|
def api_price_alerts():
|
||||||
|
"""Return today's price-alert list (holdings that moved >= 5% intraday)."""
|
||||||
|
return jsonify({'alerts': get_price_alerts()})
|
||||||
|
|
||||||
|
|
||||||
@investments_bp.route('/api/history/<ticker>')
|
@investments_bp.route('/api/history/<ticker>')
|
||||||
@login_required
|
@login_required
|
||||||
def api_price_history(ticker):
|
def api_price_history(ticker):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||||
Response, flash, send_file)
|
Response, flash, send_file, stream_with_context)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.services.report_service import (
|
from app.services.report_service import (
|
||||||
@@ -8,7 +8,7 @@ from app.services.report_service import (
|
|||||||
take_net_worth_snapshot, category_mom_comparison,
|
take_net_worth_snapshot, category_mom_comparison,
|
||||||
)
|
)
|
||||||
from app.services.export_service import (
|
from app.services.export_service import (
|
||||||
transactions_to_csv, transactions_to_excel,
|
transactions_csv_stream, transactions_to_excel,
|
||||||
report_to_pdf, build_report_html,
|
report_to_pdf, build_report_html,
|
||||||
)
|
)
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -141,14 +141,13 @@ def export_csv():
|
|||||||
)
|
)
|
||||||
if txn_type != 'all':
|
if txn_type != 'all':
|
||||||
query = query.filter(Transaction.transaction_type == txn_type)
|
query = query.filter(Transaction.transaction_type == txn_type)
|
||||||
transactions = query.order_by(Transaction.date.desc()).all()
|
query = query.order_by(Transaction.date.desc())
|
||||||
|
|
||||||
csv_data = transactions_to_csv(transactions)
|
|
||||||
label = f'{year}-{month:02d}' if month else str(year)
|
label = f'{year}-{month:02d}' if month else str(year)
|
||||||
filename = f'pfm_transactions_{label}.csv'
|
filename = f'pfm_transactions_{label}.csv'
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
csv_data,
|
stream_with_context(transactions_csv_stream(query)),
|
||||||
mimetype='text/csv',
|
mimetype='text/csv',
|
||||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
||||||
)
|
)
|
||||||
@@ -167,11 +166,11 @@ def export_excel():
|
|||||||
__import__('calendar').monthrange(year, month)[1]),
|
__import__('calendar').monthrange(year, month)[1]),
|
||||||
Transaction.transaction_type.in_(['income', 'expense']),
|
Transaction.transaction_type.in_(['income', 'expense']),
|
||||||
)
|
)
|
||||||
transactions = query.order_by(Transaction.date.desc()).all()
|
query = query.order_by(Transaction.date.desc())
|
||||||
|
|
||||||
label = f'{year}-{month:02d}' if month else str(year)
|
label = f'{year}-{month:02d}' if month else str(year)
|
||||||
period_label = f'{year} Month {month}' if month else str(year)
|
period_label = f'{year} Month {month}' if month else str(year)
|
||||||
excel_bytes = transactions_to_excel(transactions, period_label)
|
excel_bytes = transactions_to_excel(query, period_label)
|
||||||
filename = f'pfm_transactions_{label}.xlsx'
|
filename = f'pfm_transactions_{label}.xlsx'
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
|
|||||||
@@ -572,9 +572,115 @@ def _groq_parse_statement(text):
|
|||||||
return raw_rows
|
return raw_rows
|
||||||
|
|
||||||
|
|
||||||
|
_TABLE_DATE_HDRS = {'date', 'posted', 'transaction date', 'trans date',
|
||||||
|
'posting date', 'value date', 'effective date', 'settled'}
|
||||||
|
_TABLE_DESC_HDRS = {'description', 'payee', 'merchant', 'memo', 'transaction',
|
||||||
|
'details', 'name', 'narrative', 'particulars', 'reference'}
|
||||||
|
_TABLE_DEBIT_HDRS = {'debit', 'withdrawal', 'withdrawals', 'charge', 'charges',
|
||||||
|
'amount debited', 'payment', 'dr'}
|
||||||
|
_TABLE_CRED_HDRS = {'credit', 'deposit', 'deposits', 'amount credited',
|
||||||
|
'cr', 'inflow'}
|
||||||
|
_TABLE_AMT_HDRS = {'amount', 'transaction amount', 'net amount'}
|
||||||
|
|
||||||
|
|
||||||
|
def _pdfplumber_table_parse(pdf_handle):
|
||||||
|
"""
|
||||||
|
Try to extract transactions directly from pdfplumber table structures.
|
||||||
|
|
||||||
|
Iterates every page, finds tables whose headers match bank-statement
|
||||||
|
patterns, and converts rows to raw transaction dicts.
|
||||||
|
|
||||||
|
Returns a (possibly empty) list of raw dicts; never raises.
|
||||||
|
"""
|
||||||
|
all_rows = []
|
||||||
|
|
||||||
|
for page in pdf_handle.pages:
|
||||||
|
try:
|
||||||
|
tables = page.extract_tables()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for table in tables:
|
||||||
|
if not table or len(table) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalise headers (lower-case, strip)
|
||||||
|
raw_headers = [str(h).strip().lower() if h else '' for h in table[0]]
|
||||||
|
|
||||||
|
date_col = next((i for i, h in enumerate(raw_headers)
|
||||||
|
if h in _TABLE_DATE_HDRS), None)
|
||||||
|
desc_col = next((i for i, h in enumerate(raw_headers)
|
||||||
|
if h in _TABLE_DESC_HDRS), None)
|
||||||
|
amt_col = next((i for i, h in enumerate(raw_headers)
|
||||||
|
if h in _TABLE_AMT_HDRS), None)
|
||||||
|
debit_col = next((i for i, h in enumerate(raw_headers)
|
||||||
|
if h in _TABLE_DEBIT_HDRS), None)
|
||||||
|
cred_col = next((i for i, h in enumerate(raw_headers)
|
||||||
|
if h in _TABLE_CRED_HDRS), None)
|
||||||
|
|
||||||
|
# Need at least date + description + one amount column
|
||||||
|
if date_col is None or desc_col is None:
|
||||||
|
continue
|
||||||
|
if amt_col is None and debit_col is None and cred_col is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
col_max = max(c for c in [date_col, desc_col, amt_col, debit_col, cred_col]
|
||||||
|
if c is not None)
|
||||||
|
|
||||||
|
for row in table[1:]:
|
||||||
|
if not row or len(row) <= col_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = str(row[date_col]).strip() if row[date_col] else ''
|
||||||
|
if not date_str or date_str.lower() in ('', 'none', '-', '--', 'n/a'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txn_date = _parse_date(date_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = str(row[desc_col]).strip() if row[desc_col] else ''
|
||||||
|
if not description or description.lower() in ('', 'none'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if debit_col is not None or cred_col is not None:
|
||||||
|
debit = abs(_clean_amount(row[debit_col] if debit_col is not None else ''))
|
||||||
|
credit = abs(_clean_amount(row[cred_col] if cred_col is not None else ''))
|
||||||
|
if debit > 0:
|
||||||
|
amount, txn_type = debit, 'expense'
|
||||||
|
elif credit > 0:
|
||||||
|
amount, txn_type = credit, 'income'
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raw_amt = _clean_amount(row[amt_col] if row[amt_col] else '')
|
||||||
|
if raw_amt == 0.0:
|
||||||
|
continue
|
||||||
|
txn_type = 'expense' if raw_amt < 0 else 'income'
|
||||||
|
amount = abs(raw_amt)
|
||||||
|
|
||||||
|
all_rows.append({
|
||||||
|
'date': txn_date,
|
||||||
|
'transaction_type': txn_type,
|
||||||
|
'amount': amount,
|
||||||
|
'description': description,
|
||||||
|
'notes': '',
|
||||||
|
'source_id': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return all_rows
|
||||||
|
|
||||||
|
|
||||||
def _parse_pdf(file_bytes):
|
def _parse_pdf(file_bytes):
|
||||||
"""
|
"""
|
||||||
Extract text from a digital PDF using pdfplumber, then parse with Groq.
|
Extract transactions from a digital bank-statement PDF.
|
||||||
|
|
||||||
|
Strategy (in order):
|
||||||
|
1. pdfplumber table extraction — fast, free, no API call needed.
|
||||||
|
Used when structured tables with recognisable headers are found and
|
||||||
|
yield at least 3 rows.
|
||||||
|
2. pdfplumber text extraction → Groq LLM — handles unstructured
|
||||||
|
or narrative-style statements.
|
||||||
|
|
||||||
Returns (raw_rows, warnings_list).
|
Returns (raw_rows, warnings_list).
|
||||||
Raises RuntimeError for unrecoverable problems (scanned PDF, bad file, etc.).
|
Raises RuntimeError for unrecoverable problems (scanned PDF, bad file, etc.).
|
||||||
@@ -593,10 +699,27 @@ def _parse_pdf(file_bytes):
|
|||||||
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
||||||
num_pages = len(pdf.pages)
|
num_pages = len(pdf.pages)
|
||||||
log.info('[bank_import] PDF has %d page(s)', num_pages)
|
log.info('[bank_import] PDF has %d page(s)', num_pages)
|
||||||
|
|
||||||
|
# ── Strategy 1: structured table extraction ──────────────────────
|
||||||
|
table_rows = _pdfplumber_table_parse(pdf)
|
||||||
|
if len(table_rows) >= 3:
|
||||||
|
log.info(
|
||||||
|
'[bank_import] PDF table extraction: %d rows (skipping Groq)',
|
||||||
|
len(table_rows),
|
||||||
|
)
|
||||||
|
return table_rows, warnings
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'[bank_import] PDF table extraction yielded %d row(s) — falling back to Groq',
|
||||||
|
len(table_rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Strategy 2: text extraction → Groq ──────────────────────────
|
||||||
for page in pdf.pages:
|
for page in pdf.pages:
|
||||||
text = page.extract_text(x_tolerance=2, y_tolerance=2)
|
text = page.extract_text(x_tolerance=2, y_tolerance=2)
|
||||||
if text:
|
if text:
|
||||||
text_parts.append(text)
|
text_parts.append(text)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[bank_import] pdfplumber failed: %s', exc, exc_info=True)
|
log.error('[bank_import] pdfplumber failed: %s', exc, exc_info=True)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Export Service — CSV, Excel, and PDF generation for transactions and reports.
|
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
|
import io
|
||||||
@@ -11,13 +16,20 @@ from app.models.transaction import Transaction
|
|||||||
|
|
||||||
# ── CSV export ────────────────────────────────────────────────────────────────
|
# ── CSV export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def transactions_to_csv(transactions):
|
def transactions_csv_stream(query):
|
||||||
"""Return a CSV string of transactions."""
|
"""
|
||||||
output = io.StringIO()
|
Generator that yields CSV text one row at a time.
|
||||||
writer = csv.writer(output)
|
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'])
|
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([
|
writer.writerow([
|
||||||
txn.date.strftime('%Y-%m-%d'),
|
txn.date.strftime('%Y-%m-%d'),
|
||||||
txn.transaction_type,
|
txn.transaction_type,
|
||||||
@@ -27,80 +39,87 @@ def transactions_to_csv(transactions):
|
|||||||
float(txn.amount),
|
float(txn.amount),
|
||||||
txn.notes or '',
|
txn.notes or '',
|
||||||
])
|
])
|
||||||
|
yield buf.getvalue()
|
||||||
output.seek(0)
|
buf.seek(0); buf.truncate()
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Excel export ──────────────────────────────────────────────────────────────
|
# ── Excel export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def transactions_to_excel(transactions, period_label='Transactions'):
|
def transactions_to_excel(query, period_label='Transactions'):
|
||||||
"""Return Excel bytes for a list of 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 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
|
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', '$')
|
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_fill = PatternFill(start_color='0F172A', end_color='0F172A', fill_type='solid')
|
||||||
header_font = Font(color='F1F5F9', bold=True, size=10)
|
header_font = Font(color='F1F5F9', bold=True, size=10)
|
||||||
thin = Side(style='thin', color='E2E8F0')
|
headers = ['Date', 'Type', 'Description', 'Category', 'Account',
|
||||||
border = Border(bottom=Side(style='thin', color='E2E8F0'))
|
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')
|
income_fill = PatternFill(start_color='F0FDF4', end_color='F0FDF4', fill_type='solid')
|
||||||
expense_fill = PatternFill(start_color='FFF7F7', end_color='FFF7F7', 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
|
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.date.strftime('%Y-%m-%d'),
|
||||||
txn.transaction_type.title(),
|
txn.transaction_type.title(),
|
||||||
txn.description,
|
txn.description,
|
||||||
txn.category.name if txn.category else '',
|
txn.category.name if txn.category else '',
|
||||||
txn.account.name if txn.account else '',
|
txn.account.name if txn.account else '',
|
||||||
float(txn.amount),
|
amt,
|
||||||
txn.notes or '',
|
txn.notes or '',
|
||||||
]
|
]
|
||||||
for col, value in enumerate(data, 1):
|
row = []
|
||||||
cell = ws.cell(row=row_num, column=col, value=value)
|
for col_idx, value in enumerate(row_vals, 1):
|
||||||
cell.fill = fill
|
c = WriteOnlyCell(ws, value=value)
|
||||||
cell.border = border
|
c.font = row_font
|
||||||
cell.font = Font(size=10)
|
c.fill = fill
|
||||||
if col == 6:
|
if col_idx == 6:
|
||||||
cell.number_format = f'#,##0.00'
|
c.number_format = amt_fmt
|
||||||
cell.alignment = Alignment(horizontal='right')
|
c.alignment = Alignment(horizontal='right')
|
||||||
|
row.append(c)
|
||||||
|
ws.append(row)
|
||||||
|
|
||||||
# Totals row
|
# Totals row — plain cells (write-only, no random access)
|
||||||
total_row = len(transactions) + 2
|
total_lbl = WriteOnlyCell(ws, value='TOTAL')
|
||||||
ws.cell(row=total_row, column=5, value='TOTAL').font = Font(bold=True, size=10)
|
total_lbl.font = Font(bold=True, size=10)
|
||||||
total_cell = ws.cell(row=total_row, column=6,
|
total_val = WriteOnlyCell(ws, value=running_total)
|
||||||
value=sum(float(t.amount) for t in transactions))
|
total_val.font = Font(bold=True, size=10)
|
||||||
total_cell.font = Font(bold=True, size=10)
|
total_val.number_format = amt_fmt
|
||||||
total_cell.number_format = f'#,##0.00'
|
total_val.alignment = Alignment(horizontal='right')
|
||||||
total_cell.alignment = Alignment(horizontal='right')
|
ws.append(['', '', '', '', total_lbl, total_val, ''])
|
||||||
|
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
wb.save(output)
|
wb.save(output)
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
return output.getvalue()
|
return output.read()
|
||||||
|
|
||||||
|
|
||||||
# ── PDF export ────────────────────────────────────────────────────────────────
|
# ── PDF export ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -323,6 +323,99 @@ def update_prices(investment_ids=None):
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_save_price_alerts(threshold: float = 5.0) -> int:
|
||||||
|
"""
|
||||||
|
Fetch today's day-change for every unique ticker that has an active holding.
|
||||||
|
For any ticker where |day_change_pct| >= threshold, write an AiInsight row
|
||||||
|
with insight_type='alert' so the investments page can surface a banner.
|
||||||
|
|
||||||
|
Deduplicates by ticker so each ticker's Groq/Yahoo call happens only once.
|
||||||
|
Returns the number of alerts saved.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from app.models.ai_insight import AiInsight
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
|
||||||
|
investments = Investment.query.filter(
|
||||||
|
Investment.ticker != None,
|
||||||
|
Investment.ticker != '',
|
||||||
|
Investment.is_active == True,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not investments:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Collect unique tickers and their holding names
|
||||||
|
ticker_map = {} # ticker → asset_name (first one found)
|
||||||
|
for inv in investments:
|
||||||
|
t = inv.ticker.upper()
|
||||||
|
if t not in ticker_map:
|
||||||
|
ticker_map[t] = inv.asset_name
|
||||||
|
|
||||||
|
alerts = []
|
||||||
|
for ticker, asset_name in ticker_map.items():
|
||||||
|
try:
|
||||||
|
change = fetch_day_change(ticker)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not change:
|
||||||
|
continue
|
||||||
|
pct = change.get('day_change_pct') or 0
|
||||||
|
if abs(pct) >= threshold:
|
||||||
|
alerts.append({
|
||||||
|
'ticker': ticker,
|
||||||
|
'asset_name': asset_name,
|
||||||
|
'day_change_pct': round(pct, 2),
|
||||||
|
'current_price': change.get('current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not alerts:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Upsert: overwrite any earlier alert from today
|
||||||
|
existing = AiInsight.query.filter_by(
|
||||||
|
insight_date=today, insight_type='alert'
|
||||||
|
).first()
|
||||||
|
content_json = json.dumps(alerts)
|
||||||
|
if existing:
|
||||||
|
existing.content = content_json
|
||||||
|
else:
|
||||||
|
db.session.add(AiInsight(
|
||||||
|
insight_date=today,
|
||||||
|
insight_type='alert',
|
||||||
|
content=content_json,
|
||||||
|
prompt_summary=f'price_alert threshold={threshold}%',
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
log.info('[investment] saved %d price alert(s) (threshold=%.1f%%)', len(alerts), threshold)
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log.error('[investment] failed to save price alerts: %s', e)
|
||||||
|
|
||||||
|
return len(alerts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_alerts():
|
||||||
|
"""
|
||||||
|
Return today's price alert list (from ai_insights) or [] if none exist.
|
||||||
|
Each item: {ticker, asset_name, day_change_pct, current_price}
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from app.models.ai_insight import AiInsight
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
row = AiInsight.query.filter_by(insight_date=today, insight_type='alert').first()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(row.content)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_portfolio_summary():
|
def get_portfolio_summary():
|
||||||
"""
|
"""
|
||||||
Return portfolio-level aggregates across all active investments.
|
Return portfolio-level aggregates across all active investments.
|
||||||
|
|||||||
+11
-1
@@ -189,7 +189,13 @@
|
|||||||
.btn-label { display: none; }
|
.btn-label { display: none; }
|
||||||
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
|
.tb-right .btn { padding-left: 8px; padding-right: 8px; }
|
||||||
.stat-card .stat-value { font-size: 16px; }
|
.stat-card .stat-value { font-size: 16px; }
|
||||||
|
/* AI chat: shorter on small screens so it doesn't eat the whole viewport */
|
||||||
|
#chatMessages { height: 300px !important; }
|
||||||
|
/* Projection / combo charts: cap height so they're not giant on phones */
|
||||||
|
.proj-chart-wrap { height: 200px !important; }
|
||||||
}
|
}
|
||||||
|
/* table-wrap inside a regular (padded) pcard also needs overflow-x */
|
||||||
|
.pcard .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
/* Keyboard shortcut cheatsheet modal */
|
/* Keyboard shortcut cheatsheet modal */
|
||||||
#kbd-modal .kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
#kbd-modal .kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
#kbd-modal .kbd-row:last-child { border: none; }
|
#kbd-modal .kbd-row:last-child { border: none; }
|
||||||
@@ -304,7 +310,11 @@
|
|||||||
class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}"
|
class="sb-link {% if request.blueprint == 'transactions' %}active{% endif %}"
|
||||||
>
|
>
|
||||||
<i class="bi bi-arrow-left-right"></i
|
<i class="bi bi-arrow-left-right"></i
|
||||||
><span class="lt">Transactions</span>
|
><span class="lt">Transactions
|
||||||
|
{% if plaid_review_count > 0 %}
|
||||||
|
<span style="margin-left:6px;background:#7c3aed;color:#fff;font-size:10px;font-weight:700;padding:1px 5px;border-radius:10px;line-height:1.4;">{{ plaid_review_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('transactions.new', type='income') }}"
|
href="{{ url_for('transactions.new', type='income') }}"
|
||||||
|
|||||||
@@ -362,6 +362,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Bills -->
|
||||||
|
{% if upcoming_bills %}
|
||||||
|
<div class="pcard mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="pcard-title mb-0">Upcoming Bills <span style="font-size:11px;font-weight:400;color:var(--muted);">(next 14 days)</span></span>
|
||||||
|
<a href="{{ url_for('settings.recurring') }}" style="font-size:12px;color:#3b82f6;">Manage →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="pfm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="padding-left:16px;">Rule</th>
|
||||||
|
<th class="d-mob-none">Frequency</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
<th class="text-end d-mob-none" style="padding-right:16px;">Account</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rule in upcoming_bills %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left:16px;">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if rule.category %}
|
||||||
|
<div style="width:26px;height:26px;border-radius:6px;background:{{ rule.category.color }}22;color:{{ rule.category.color }};display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;">
|
||||||
|
<i class="bi {{ rule.category.icon }}"></i>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="width:26px;height:26px;border-radius:6px;background:#f1f5f9;color:#94a3b8;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span style="font-size:13px;font-weight:500;">{{ rule.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ rule.frequency | title }}</td>
|
||||||
|
<td>
|
||||||
|
{% set days_until = (rule.next_run - today).days %}
|
||||||
|
<span style="font-size:12px;" class="{% if days_until == 0 %}text-expense fw-semibold{% elif days_until <= 3 %}text-warning{% endif %}">
|
||||||
|
{% if days_until == 0 %}Today
|
||||||
|
{% elif days_until == 1 %}Tomorrow
|
||||||
|
{% else %}{{ rule.next_run.strftime('%b %d') }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end mono {% if rule.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">
|
||||||
|
{% if rule.transaction_type=='income' %}+{% else %}-{% endif %}{{ rule.amount | currency }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end d-mob-none" style="font-size:12px;color:var(--muted);padding-right:16px;">{{ rule.account.name if rule.account else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Custom Range Modal -->
|
<!-- Custom Range Modal -->
|
||||||
<div class="modal fade" id="customModal" tabindex="-1">
|
<div class="modal fade" id="customModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-sm">
|
<div class="modal-dialog modal-sm">
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
{% if completed_goals %}
|
{% if completed_goals %}
|
||||||
<div class="pcard">
|
<div class="pcard">
|
||||||
<div class="pcard-title mb-3">Completed Goals 🎉</div>
|
<div class="pcard-title mb-3">Completed Goals 🎉</div>
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="pfm-table">
|
<table class="pfm-table">
|
||||||
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
|
<thead><tr><th>Goal</th><th>Target</th><th>Completed</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -138,5 +139,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -78,6 +78,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Price alerts banner (populated by AJAX) -->
|
||||||
|
<div id="price-alert-banner" style="display:none;" class="alert d-flex align-items-start gap-2 mb-3" style="background:#fef3c7;border:1px solid #fcd34d;color:#78350f;font-size:13px;border-radius:8px;padding:10px 14px;">
|
||||||
|
<i class="bi bi-graph-up-arrow flex-shrink-0 mt-1" style="color:#d97706;"></i>
|
||||||
|
<div class="flex-grow-1" id="price-alert-text"></div>
|
||||||
|
<button type="button" onclick="document.getElementById('price-alert-banner').style.display='none';"
|
||||||
|
style="background:none;border:none;color:#92400e;cursor:pointer;font-size:16px;line-height:1;padding:0 4px;" title="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-xl-3">
|
<div class="col-6 col-xl-3">
|
||||||
@@ -134,7 +142,8 @@
|
|||||||
|
|
||||||
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
||||||
{% macro holdings_table(inv_list) %}
|
{% macro holdings_table(inv_list) %}
|
||||||
<table class="pfm-table">
|
<div class="table-wrap">
|
||||||
|
<table class="pfm-table wide">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="padding-left:20px;">Asset</th>
|
<th style="padding-left:20px;">Asset</th>
|
||||||
@@ -219,6 +228,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Chart + Allocation -->
|
<!-- Chart + Allocation -->
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<div id="proj-chart-wrap" style="position:relative;height:200px;">
|
<div id="proj-chart-wrap" class="proj-chart-wrap" style="position:relative;height:200px;">
|
||||||
<canvas id="projChart"></canvas>
|
<canvas id="projChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if plaid_review_count > 0 %}
|
||||||
|
<div class="alert d-flex align-items-center gap-2 mb-3" style="background:#f5f3ff;border:1px solid #ddd6fe;color:#5b21b6;font-size:13px;border-radius:8px;padding:10px 14px;">
|
||||||
|
<i class="bi bi-cloud-download flex-shrink-0"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>{{ plaid_review_count }} Plaid transaction{{ 's' if plaid_review_count != 1 else '' }}</strong>
|
||||||
|
imported via webhook without a category.
|
||||||
|
<a href="{{ url_for('transactions.index') }}" style="color:#7c3aed;font-weight:600;" class="ms-1">Review & categorize →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="d-flex gap-1 mb-3">
|
<div class="d-flex gap-1 mb-3">
|
||||||
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app.services.investment_service import update_prices
|
from app.services.investment_service import update_prices, check_and_save_price_alerts
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
@@ -23,3 +23,7 @@ if __name__ == '__main__':
|
|||||||
print(f'[fetch_prices] Updated {len(updated)} ticker(s).')
|
print(f'[fetch_prices] Updated {len(updated)} ticker(s).')
|
||||||
else:
|
else:
|
||||||
print('[fetch_prices] No tickers updated.')
|
print('[fetch_prices] No tickers updated.')
|
||||||
|
|
||||||
|
print('[fetch_prices] Checking price alerts (threshold=5%)...')
|
||||||
|
n = check_and_save_price_alerts(threshold=5.0)
|
||||||
|
print(f'[fetch_prices] {n} alert(s) saved.')
|
||||||
|
|||||||
Reference in New Issue
Block a user