06/01 Optimize codes

This commit is contained in:
2026-06-01 17:07:00 -04:00
parent 25b15d8432
commit 4406d9734c
7 changed files with 231 additions and 10 deletions
+8
View File
@@ -30,6 +30,14 @@ class Config:
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem') TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '') TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
# Budget alert emails (optional — all four must be set to enable)
SMTP_HOST = os.environ.get('SMTP_HOST', '')
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
ALERT_EMAIL = os.environ.get('ALERT_EMAIL', '') # recipient address
APP_URL = os.environ.get('APP_URL', 'https://pfm.ngodanguyen.tech')
# Application log file (rotating, shared by all app.* loggers) # Application log file (rotating, shared by all app.* loggers)
LOG_FILE_PATH = os.environ.get( LOG_FILE_PATH = os.environ.get(
'LOG_FILE_PATH', 'LOG_FILE_PATH',
+34 -2
View File
@@ -322,12 +322,36 @@ def import_confirm():
ALLOWED_RECEIPT_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'} ALLOWED_RECEIPT_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
# Magic bytes → (canonical_extension, mime_type)
_MAGIC = [
(b'\xff\xd8\xff', 'jpg', 'image/jpeg'),
(b'\x89PNG\r\n\x1a\n', 'png', 'image/png'),
(b'GIF87a', 'gif', 'image/gif'),
(b'GIF89a', 'gif', 'image/gif'),
(b'%PDF-', 'pdf', 'application/pdf'),
# WEBP: RIFF????WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
]
def _allowed_receipt(filename): def _allowed_receipt(filename):
return '.' in filename and \ return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_RECEIPT_EXTENSIONS filename.rsplit('.', 1)[1].lower() in ALLOWED_RECEIPT_EXTENSIONS
def _check_magic(header_bytes):
"""
Inspect the first 12 bytes of a file and return (ext, mime) if recognised,
or (None, None) if the bytes don't match any allowed type.
"""
for magic, ext, mime in _MAGIC:
if header_bytes[:len(magic)] == magic:
return ext, mime
# WEBP special case: RIFF at 0, WEBP at 8
if header_bytes[:4] == b'RIFF' and header_bytes[8:12] == b'WEBP':
return 'webp', 'image/webp'
return None, None
@settings_bp.route('/receipt/upload/<int:txn_id>', methods=['POST']) @settings_bp.route('/receipt/upload/<int:txn_id>', methods=['POST'])
@login_required @login_required
def upload_receipt(txn_id): def upload_receipt(txn_id):
@@ -350,7 +374,15 @@ def upload_receipt(txn_id):
flash('File too large. Maximum 10MB.', 'warning') flash('File too large. Maximum 10MB.', 'warning')
return redirect(request.referrer or url_for('transactions.index')) return redirect(request.referrer or url_for('transactions.index'))
ext = f.filename.rsplit('.', 1)[1].lower() # Validate actual file content via magic bytes (prevents renamed-file attacks)
header = f.read(12)
f.seek(0)
magic_ext, magic_mime = _check_magic(header)
if magic_ext is None:
flash('File content does not match an allowed type (PNG, JPG, GIF, PDF, WEBP).', 'warning')
return redirect(request.referrer or url_for('transactions.index'))
ext = magic_ext # use the extension we detected, not what the user claimed
unique_name = f'{uuid.uuid4().hex}.{ext}' unique_name = f'{uuid.uuid4().hex}.{ext}'
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
@@ -367,7 +399,7 @@ def upload_receipt(txn_id):
filename=unique_name, filename=unique_name,
original_filename=secure_filename(f.filename), original_filename=secure_filename(f.filename),
file_size=file_size, file_size=file_size,
mime_type=f.content_type, mime_type=magic_mime, # use validated type, not the browser-supplied header
) )
db.session.add(receipt) db.session.add(receipt)
db.session.flush() db.session.flush()
+20 -3
View File
@@ -226,14 +226,30 @@ def sync_confirm():
parsed.append(r) parsed.append(r)
imported, skipped = import_transactions(parsed, ta) imported, skipped = import_transactions(parsed, ta)
flash(f'Imported {imported} transaction(s). Skipped {skipped} duplicate(s).', 'success') flash(f'Imported {imported} transaction(s) from {ta.account_name}. '
f'Skipped {skipped} duplicate(s).', 'success')
# Advance the sync queue if this came from a "Sync All" run
from flask import session as flask_session
queue = flask_session.pop('teller_sync_queue', [])
if queue:
next_id = queue[0]
flask_session['teller_sync_queue'] = queue[1:]
flash(f'{len(queue)} account(s) remaining in sync queue.', 'info')
return redirect(url_for('teller.sync_preview_view', teller_account_id=next_id))
return redirect(url_for('transactions.index')) return redirect(url_for('transactions.index'))
@teller_bp.route('/sync/all', methods=['POST']) @teller_bp.route('/sync/all', methods=['POST'])
@login_required @login_required
def sync_all(): def sync_all():
"""Sync all mapped accounts — redirects to first account's preview.""" """
Queue all mapped accounts for sequential sync.
Stores the full list in session, then redirects to the first account's preview.
After each confirm the queue advances automatically.
"""
from flask import session as flask_session
enrollments = TellerEnrollment.query.filter_by(is_active=True).all() enrollments = TellerEnrollment.query.filter_by(is_active=True).all()
mapped = [] mapped = []
for e in enrollments: for e in enrollments:
@@ -245,7 +261,8 @@ def sync_all():
flash('No accounts mapped for sync.', 'warning') flash('No accounts mapped for sync.', 'warning')
return redirect(url_for('teller.index')) return redirect(url_for('teller.index'))
# For simplicity, sync first account; user can chain through others # Store the full queue; first item will be previewed immediately
flask_session['teller_sync_queue'] = [ta.id for ta in mapped[1:]]
return redirect(url_for('teller.sync_preview_view', teller_account_id=mapped[0].id)) return redirect(url_for('teller.sync_preview_view', teller_account_id=mapped[0].id))
+6
View File
@@ -140,6 +140,9 @@ def new():
db.session.commit() db.session.commit()
calc_balance(txn.account_id) calc_balance(txn.account_id)
flash(f'{"Income" if txn_type == "income" else "Expense"} added.', 'success') flash(f'{"Income" if txn_type == "income" else "Expense"} added.', 'success')
if txn_type == 'expense':
from app.services.alert_service import check_and_flash_budget_alerts
check_and_flash_budget_alerts(flash)
return redirect(url_for('transactions.index', tab=txn_type)) return redirect(url_for('transactions.index', tab=txn_type))
return render_template('transactions/form.html', return render_template('transactions/form.html',
@@ -174,6 +177,9 @@ def edit(id):
calc_balance(old_account_id) calc_balance(old_account_id)
calc_balance(txn.account_id) calc_balance(txn.account_id)
flash('Transaction updated.', 'success') flash('Transaction updated.', 'success')
if txn.transaction_type == 'expense':
from app.services.alert_service import check_and_flash_budget_alerts
check_and_flash_budget_alerts(flash)
return redirect(url_for('transactions.index', tab=txn.transaction_type)) return redirect(url_for('transactions.index', tab=txn.transaction_type))
return render_template('transactions/form.html', return render_template('transactions/form.html',
+136
View File
@@ -0,0 +1,136 @@
"""
Alert Service — budget threshold alerts triggered after expense transactions.
Checks all budgeted categories for the current month and returns alert dicts
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured.
Usage (in routes after db.session.commit()):
from app.services.alert_service import check_and_flash_budget_alerts
check_and_flash_budget_alerts(flash_fn)
"""
import logging
import smtplib
import ssl
from datetime import date
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app
log = logging.getLogger(__name__)
def _smtp_configured():
cfg = current_app.config
return all([
cfg.get('SMTP_HOST'),
cfg.get('SMTP_USER'),
cfg.get('SMTP_PASSWORD'),
cfg.get('ALERT_EMAIL'),
])
def _send_email(subject, body_html):
cfg = current_app.config
host = cfg['SMTP_HOST']
port = int(cfg.get('SMTP_PORT', 587))
user = cfg['SMTP_USER']
password = cfg['SMTP_PASSWORD']
to_addr = cfg['ALERT_EMAIL']
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = user
msg['To'] = to_addr
msg.attach(MIMEText(body_html, 'html'))
try:
context = ssl.create_default_context()
with smtplib.SMTP(host, port) as srv:
srv.ehlo()
srv.starttls(context=context)
srv.login(user, password)
srv.sendmail(user, to_addr, msg.as_string())
log.info('[alert] email sent: %s%s', subject, to_addr)
except Exception as exc:
log.error('[alert] email failed: %s', exc, exc_info=True)
def get_budget_alerts(month_str=None):
"""
Return list of alert dicts for budget categories at or over threshold.
Each dict: {category, spent, limit, pct, level}
level: 'over' (≥100%) | 'warning' (≥80%)
Only returns categories that have a budget set.
"""
from app.services.budget_service import get_budget_summary
if month_str is None:
today = date.today()
month_str = today.strftime('%Y-%m')
summary = get_budget_summary(month_str)
alerts = []
for item in summary:
if not item['has_budget'] or item['pct'] is None:
continue
pct = item['pct']
if pct >= 100:
alerts.append({**item, 'level': 'over'})
elif pct >= 80:
alerts.append({**item, 'level': 'warning'})
return alerts
def check_and_flash_budget_alerts(flash_fn):
"""
Check current month's budgets and flash any threshold alerts.
Optionally sends an email summary if SMTP is configured.
Call this after committing an expense transaction.
flash_fn: Flask's flash() function (passed in to avoid circular imports)
"""
try:
alerts = get_budget_alerts()
except Exception as exc:
log.error('[alert] check failed: %s', exc, exc_info=True)
return
if not alerts:
return
email_lines = []
for a in alerts:
cat_name = a['category'].name if a['category'] else 'Unknown'
pct = a['pct']
spent = a['spent']
limit = a['limit']
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
if a['level'] == 'over':
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)')
flash_fn(msg, 'danger')
else:
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
flash_fn(msg, 'warning')
email_lines.append(
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} '
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
)
if _smtp_configured() and email_lines:
today = date.today().strftime('%B %Y')
body = (
f'<h3>PFM Budget Alert — {today}</h3>'
f'<p>The following budget categories need your attention:</p>'
f'<ul>{"".join(email_lines)}</ul>'
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
)
_send_email(f'PFM Budget Alert — {today}', body)
+10 -1
View File
@@ -161,7 +161,16 @@
#sidebar { transform: translateX(-100%); width: var(--sidebar-width) !important; } #sidebar { transform: translateX(-100%); width: var(--sidebar-width) !important; }
#sidebar.mob-open { transform: translateX(0); } #sidebar.mob-open { transform: translateX(0); }
#topbar, #sidebar.collapsed ~ #topbar { left: 0 !important; } #topbar, #sidebar.collapsed ~ #topbar { left: 0 !important; }
#main, #sidebar.collapsed ~ #main { margin-left: 0 !important; } #main, #sidebar.collapsed ~ #main { margin-left: 0 !important; padding: 14px; }
.pcard { padding: 14px; }
.stat-card { padding: 14px 16px; }
.stat-card .stat-value { font-size: 20px; }
/* Tables: scroll horizontally on small screens.
Targets both manual .table-wrap and the common .pcard.p-0 pattern. */
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.pfm-table { min-width: 560px; }
/* Hide low-priority columns */
.d-mob-none { display: none !important; }
} }
.sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
.sb-overlay.on { display: block; } .sb-overlay.on { display: block; }
+17 -4
View File
@@ -60,6 +60,7 @@
</div> </div>
<!-- Table --> <!-- Table -->
{% set has_filter = search or category_id or account_id or date_from or date_to %}
<div class="pcard p-0"> <div class="pcard p-0">
{% if transactions %} {% if transactions %}
<table class="pfm-table"> <table class="pfm-table">
@@ -120,10 +121,22 @@
{% endif %} {% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5 px-3">
<i class="bi bi-inbox text-muted" style="font-size:2.5rem;"></i> {% if has_filter %}
<p class="text-muted mt-2 mb-3">No {{ tab }} transactions found.</p> <i class="bi bi-funnel text-muted" style="font-size:2.5rem;opacity:.4;"></i>
<a href="{{ url_for('transactions.new', type=tab) }}" class="btn btn-sm btn-primary">Add {{ tab | title }}</a> <p class="fw-semibold mt-3 mb-1">No matching transactions</p>
<p class="text-muted small mb-3">Try adjusting your filters or search term.</p>
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-lg me-1"></i>Clear filters
</a>
{% else %}
<i class="bi bi-arrow-left-right text-muted" style="font-size:2.5rem;opacity:.4;"></i>
<p class="fw-semibold mt-3 mb-1">No {{ tab }} transactions yet</p>
<p class="text-muted small mb-3">Record your first {{ tab }} to start tracking.</p>
<a href="{{ url_for('transactions.new', type=tab) }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-1"></i>Add {{ tab | title }}
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>