06/01 Optimize codes
This commit is contained in:
@@ -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
@@ -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
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user