06/05 Optimize app: add email notification
This commit is contained in:
@@ -11,6 +11,8 @@ class Budget(db.Model):
|
|||||||
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
limit_amount = db.Column(db.Numeric(15, 2), nullable=False)
|
||||||
rollover_enabled = db.Column(db.Boolean, default=False)
|
rollover_enabled = db.Column(db.Boolean, default=False)
|
||||||
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
rollover_amount = db.Column(db.Numeric(15, 2), default=0.00)
|
||||||
|
alert_sent_80 = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
alert_sent_100 = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
category = db.relationship('Category')
|
category = db.relationship('Category')
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class User(UserMixin, db.Model):
|
|||||||
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
|
groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile')
|
||||||
totp_secret = db.Column(db.String(64), nullable=True)
|
totp_secret = db.Column(db.String(64), nullable=True)
|
||||||
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
budget_alerts_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
last_login = db.Column(db.DateTime, nullable=True)
|
last_login = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -68,6 +68,7 @@ class ProfileForm(FlaskForm):
|
|||||||
])
|
])
|
||||||
currency = SelectField('Currency', choices=CURRENCIES)
|
currency = SelectField('Currency', choices=CURRENCIES)
|
||||||
groq_model = SelectField('AI Model', choices=GROQ_MODELS)
|
groq_model = SelectField('AI Model', choices=GROQ_MODELS)
|
||||||
|
budget_alerts_enabled = BooleanField('Email me when a budget category reaches 80% or 100%')
|
||||||
submit = SubmitField('Save Profile')
|
submit = SubmitField('Save Profile')
|
||||||
|
|
||||||
|
|
||||||
@@ -156,10 +157,28 @@ def profile():
|
|||||||
current_user.currency = form.currency.data
|
current_user.currency = form.currency.data
|
||||||
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
|
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
|
||||||
current_user.groq_model = form.groq_model.data
|
current_user.groq_model = form.groq_model.data
|
||||||
|
current_user.budget_alerts_enabled = form.budget_alerts_enabled.data
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Profile updated.', 'success')
|
flash('Profile updated.', 'success')
|
||||||
return redirect(url_for('settings.profile'))
|
return redirect(url_for('settings.profile'))
|
||||||
return render_template('settings/profile.html', form=form)
|
smtp_ok = all([
|
||||||
|
current_app.config.get('SMTP_HOST'),
|
||||||
|
current_app.config.get('SMTP_USER'),
|
||||||
|
current_app.config.get('SMTP_PASSWORD'),
|
||||||
|
current_app.config.get('ALERT_EMAIL'),
|
||||||
|
])
|
||||||
|
return render_template('settings/profile.html', form=form,
|
||||||
|
smtp_ok=smtp_ok,
|
||||||
|
alert_email=current_app.config.get('ALERT_EMAIL', ''))
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/test-email', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def test_email():
|
||||||
|
from app.services.alert_service import send_test_email
|
||||||
|
ok, msg = send_test_email()
|
||||||
|
flash(msg, 'success' if ok else 'danger')
|
||||||
|
return redirect(url_for('settings.profile'))
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/password', methods=['GET', 'POST'])
|
@settings_bp.route('/password', methods=['GET', 'POST'])
|
||||||
|
|||||||
+123
-43
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Alert Service — budget threshold alerts triggered after expense transactions.
|
Alert Service — budget threshold alerts triggered after expense transactions.
|
||||||
|
|
||||||
Checks all budgeted categories for the current month and returns alert dicts
|
Checks budgeted categories and flashes/emails when spending crosses 80% or 100%.
|
||||||
for any that just crossed 80% or 100%. Also sends an email if SMTP is configured.
|
Dedup: alert_sent_80 / alert_sent_100 flags on each Budget row prevent repeat
|
||||||
|
emails within the same month. Flags default to False on new Budget rows.
|
||||||
|
|
||||||
Usage (in routes after db.session.commit()):
|
Usage (in routes after db.session.commit()):
|
||||||
from app.services.alert_service import check_and_flash_budget_alerts
|
from app.services.alert_service import check_and_flash_budget_alerts
|
||||||
@@ -17,7 +18,9 @@ from email.mime.text import MIMEText
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
from app.extensions import db
|
||||||
|
|
||||||
|
log = logging.getLogger('app.alert_service')
|
||||||
|
|
||||||
|
|
||||||
def _smtp_configured():
|
def _smtp_configured():
|
||||||
@@ -35,7 +38,7 @@ def _send_email(subject, body_html):
|
|||||||
host = cfg['SMTP_HOST']
|
host = cfg['SMTP_HOST']
|
||||||
port = int(cfg.get('SMTP_PORT', 587))
|
port = int(cfg.get('SMTP_PORT', 587))
|
||||||
user = cfg['SMTP_USER']
|
user = cfg['SMTP_USER']
|
||||||
password = cfg['SMTP_PASSWORD']
|
pw = cfg['SMTP_PASSWORD']
|
||||||
to_addr = cfg['ALERT_EMAIL']
|
to_addr = cfg['ALERT_EMAIL']
|
||||||
|
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
@@ -45,31 +48,47 @@ def _send_email(subject, body_html):
|
|||||||
msg.attach(MIMEText(body_html, 'html'))
|
msg.attach(MIMEText(body_html, 'html'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
with smtplib.SMTP(host, port) as srv:
|
with smtplib.SMTP(host, port) as srv:
|
||||||
srv.ehlo()
|
srv.ehlo()
|
||||||
srv.starttls(context=context)
|
srv.starttls(context=ctx)
|
||||||
srv.login(user, password)
|
srv.login(user, pw)
|
||||||
srv.sendmail(user, to_addr, msg.as_string())
|
srv.sendmail(user, to_addr, msg.as_string())
|
||||||
log.info('[alert] email sent: %s → %s', subject, to_addr)
|
log.info('[alert] email sent: %s → %s', subject, to_addr)
|
||||||
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[alert] email failed: %s', exc, exc_info=True)
|
log.error('[alert] email failed: %s', exc, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_email():
|
||||||
|
"""Send a test email to verify SMTP config. Returns (ok: bool, message: str)."""
|
||||||
|
if not _smtp_configured():
|
||||||
|
return False, ('SMTP not configured. '
|
||||||
|
'Set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, and ALERT_EMAIL in .env.')
|
||||||
|
to_addr = current_app.config['ALERT_EMAIL']
|
||||||
|
body = (
|
||||||
|
'<p>This is a test email from your <strong>Personal Finance Management</strong> app.</p>'
|
||||||
|
'<p>Budget alerts are configured correctly.</p>'
|
||||||
|
)
|
||||||
|
ok = _send_email('PFM — Test Email', body)
|
||||||
|
if ok:
|
||||||
|
return True, f'Test email sent to {to_addr}.'
|
||||||
|
return False, 'SMTP error — check server logs for details.'
|
||||||
|
|
||||||
|
|
||||||
def get_budget_alerts(month_str=None):
|
def get_budget_alerts(month_str=None):
|
||||||
"""
|
"""
|
||||||
Return list of alert dicts for budget categories at or over threshold.
|
Return list of alert dicts for budget categories at or over threshold.
|
||||||
|
Does NOT check dedup flags — use for display purposes only.
|
||||||
|
|
||||||
Each dict: {category, spent, limit, pct, level}
|
Each dict: {category, spent, limit, pct, level}
|
||||||
level: 'over' (≥100%) | 'warning' (≥80%)
|
level: 'over' (≥100%) | 'warning' (≥80%)
|
||||||
|
|
||||||
Only returns categories that have a budget set.
|
|
||||||
"""
|
"""
|
||||||
from app.services.budget_service import get_budget_summary
|
from app.services.budget_service import get_budget_summary
|
||||||
|
|
||||||
if month_str is None:
|
if month_str is None:
|
||||||
today = date.today()
|
month_str = date.today().strftime('%Y-%m')
|
||||||
month_str = today.strftime('%Y-%m')
|
|
||||||
|
|
||||||
summary = get_budget_summary(month_str)
|
summary = get_budget_summary(month_str)
|
||||||
alerts = []
|
alerts = []
|
||||||
@@ -81,56 +100,117 @@ def get_budget_alerts(month_str=None):
|
|||||||
alerts.append({**item, 'level': 'over'})
|
alerts.append({**item, 'level': 'over'})
|
||||||
elif pct >= 80:
|
elif pct >= 80:
|
||||||
alerts.append({**item, 'level': 'warning'})
|
alerts.append({**item, 'level': 'warning'})
|
||||||
|
|
||||||
return alerts
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
def check_and_flash_budget_alerts(flash_fn):
|
def check_and_flash_budget_alerts(flash_fn):
|
||||||
"""
|
"""
|
||||||
Check current month's budgets and flash any threshold alerts.
|
Check current month's budgets. For each category that newly crossed 80% or
|
||||||
Optionally sends an email summary if SMTP is configured.
|
100%, flash a message and (if SMTP configured and user has alerts enabled)
|
||||||
|
send one email. Dedup flags on the Budget row prevent repeat sends.
|
||||||
|
|
||||||
Call this after committing an expense transaction.
|
Call this after committing an expense transaction.
|
||||||
|
flash_fn: Flask's flash() (passed in to avoid circular imports at module level)
|
||||||
flash_fn: Flask's flash() function (passed in to avoid circular imports)
|
|
||||||
"""
|
"""
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.budget_service import get_month_spending
|
||||||
|
|
||||||
|
user = User.query.first()
|
||||||
|
alerts_enabled = user.budget_alerts_enabled if user else False
|
||||||
|
symbol = (user.currency_symbol or '$') if user else '$'
|
||||||
|
|
||||||
|
month_str = date.today().strftime('%Y-%m')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
alerts = get_budget_alerts()
|
budgets = Budget.query.filter_by(month=month_str).all()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error('[alert] check failed: %s', exc, exc_info=True)
|
log.error('[alert] failed to query budgets: %s', exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not alerts:
|
dirty_budgets = []
|
||||||
return
|
|
||||||
|
|
||||||
email_lines = []
|
for b in budgets:
|
||||||
for a in alerts:
|
try:
|
||||||
cat_name = a['category'].name if a['category'] else 'Unknown'
|
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
|
||||||
pct = a['pct']
|
if limit <= 0:
|
||||||
spent = a['spent']
|
continue
|
||||||
limit = a['limit']
|
spent = get_month_spending(b.category_id, month_str)
|
||||||
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
|
pct = spent / limit * 100
|
||||||
|
cat = b.category
|
||||||
|
cat_name = cat.name if cat else 'Unknown'
|
||||||
|
|
||||||
if a['level'] == 'over':
|
# 100% threshold — only fire if not already sent this month
|
||||||
|
if pct >= 100 and not b.alert_sent_100:
|
||||||
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
|
msg = (f'Budget exceeded: <strong>{cat_name}</strong> — '
|
||||||
f'{symbol}{spent:,.2f} spent of {symbol}{limit:,.2f} limit ({pct:.0f}%)')
|
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}%)')
|
||||||
flash_fn(msg, 'danger')
|
flash_fn(msg, 'danger')
|
||||||
else:
|
if alerts_enabled and _smtp_configured():
|
||||||
|
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=100)
|
||||||
|
b.alert_sent_100 = True
|
||||||
|
dirty_budgets.append(b)
|
||||||
|
|
||||||
|
# 80% threshold — only fire if not already sent (and 100% not yet hit)
|
||||||
|
elif pct >= 80 and not b.alert_sent_80:
|
||||||
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
|
msg = (f'Budget warning: <strong>{cat_name}</strong> — '
|
||||||
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
|
f'{symbol}{spent:,.2f} of {symbol}{limit:,.2f} ({pct:.0f}% used)')
|
||||||
flash_fn(msg, 'warning')
|
flash_fn(msg, 'warning')
|
||||||
|
if alerts_enabled and _smtp_configured():
|
||||||
|
_send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold=80)
|
||||||
|
b.alert_sent_80 = True
|
||||||
|
dirty_budgets.append(b)
|
||||||
|
|
||||||
email_lines.append(
|
except Exception as exc:
|
||||||
f'<li><b>{cat_name}</b>: {symbol}{spent:,.2f} / {symbol}{limit:,.2f} '
|
log.error('[alert] error checking budget %s: %s', b.id, exc)
|
||||||
f'({pct:.0f}%) — <b>{"EXCEEDED" if a["level"]=="over" else "Warning"}</b></li>'
|
|
||||||
)
|
|
||||||
|
|
||||||
if _smtp_configured() and email_lines:
|
if dirty_budgets:
|
||||||
today = date.today().strftime('%B %Y')
|
try:
|
||||||
body = (
|
db.session.commit()
|
||||||
f'<h3>PFM Budget Alert — {today}</h3>'
|
except Exception as exc:
|
||||||
f'<p>The following budget categories need your attention:</p>'
|
log.error('[alert] failed to save alert flags: %s', exc)
|
||||||
f'<ul>{"".join(email_lines)}</ul>'
|
db.session.rollback()
|
||||||
f'<p><a href="{current_app.config.get("APP_URL","")}/budgets">View Budgets →</a></p>'
|
|
||||||
)
|
|
||||||
_send_email(f'PFM Budget Alert — {today}', body)
|
def _send_single_alert_email(cat_name, spent, limit, pct, symbol, threshold):
|
||||||
|
label = '100%+' if threshold >= 100 else '80%'
|
||||||
|
remaining = max(limit - spent, 0)
|
||||||
|
color = '#ef4444' if threshold >= 100 else '#f59e0b'
|
||||||
|
emoji = '🚨' if threshold >= 100 else '⚠️'
|
||||||
|
app_url = current_app.config.get('APP_URL', '')
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<html><body style="font-family:sans-serif;color:#0f172a;max-width:520px;margin:0 auto;">
|
||||||
|
<div style="background:#0f172a;padding:20px 24px;border-radius:8px 8px 0 0;">
|
||||||
|
<h2 style="color:#f1f5f9;margin:0;font-size:18px;">{emoji} Budget Alert — {label}</h2>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e2e8f0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
|
||||||
|
<p style="font-size:15px;margin-top:0;">
|
||||||
|
Your <strong>{cat_name}</strong> budget has reached
|
||||||
|
<strong style="color:{color};">{pct:.0f}%</strong>.
|
||||||
|
</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Spent</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;color:#ef4444;">{symbol}{spent:,.2f}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Budget limit</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">{symbol}{limit:,.2f}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">Remaining</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;color:{'#ef4444' if remaining==0 else '#10b981'};">{symbol}{remaining:,.2f}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<a href="{app_url}/budgets"
|
||||||
|
style="display:inline-block;background:#3b82f6;color:#fff;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:13px;">
|
||||||
|
View Budgets →
|
||||||
|
</a>
|
||||||
|
<p style="font-size:11px;color:#94a3b8;margin-top:20px;margin-bottom:0;">
|
||||||
|
Disable alerts in Settings → Profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
subject = f'Budget Alert: {cat_name} at {label} ({pct:.0f}% used)'
|
||||||
|
_send_email(subject, body)
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
|
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
|
||||||
{{ row.icon }} {{ row.name }}
|
<i class="bi {{ row.icon }}" style="color:{{ row.color }};"></i> {{ row.name }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end mono">{{ row.this_month | currency }}</td>
|
<td class="text-end mono">{{ row.this_month | currency }}</td>
|
||||||
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
|
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
|
||||||
|
|||||||
@@ -43,6 +43,36 @@
|
|||||||
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
|
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr style="border-color:var(--border);">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-label fw-medium" style="font-size:13px;">Budget Alerts</div>
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.budget_alerts_enabled(class="form-check-input") }}
|
||||||
|
{{ form.budget_alerts_enabled.label(class="form-check-label", style="font-size:13px;") }}
|
||||||
|
</div>
|
||||||
|
{% if smtp_ok %}
|
||||||
|
<small class="text-muted" style="font-size:11px;">
|
||||||
|
<i class="bi bi-check-circle-fill text-success me-1"></i>SMTP configured — alerts will go to <strong>{{ alert_email }}</strong>
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-danger" style="font-size:11px;">
|
||||||
|
<i class="bi bi-exclamation-circle me-1"></i>SMTP not configured — set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, ALERT_EMAIL in .env to enable emails.
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if smtp_ok %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<form method="POST" action="{{ url_for('settings.test_email') }}" style="display:inline;">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
|
||||||
|
<i class="bi bi-envelope me-1"></i>Send Test Email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ form.submit(class="btn btn-primary") }}
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
|
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
Migration: add budget alert columns.
|
||||||
|
|
||||||
|
Run once after deploying the budget alerts feature:
|
||||||
|
python scripts/add_budget_alert_columns.py
|
||||||
|
|
||||||
|
Adds to `budgets`:
|
||||||
|
alert_sent_80 BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
alert_sent_100 BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
|
||||||
|
Adds to `users`:
|
||||||
|
budget_alerts_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
# budgets table
|
||||||
|
for col, default in [('alert_sent_80', '0'), ('alert_sent_100', '0')]:
|
||||||
|
try:
|
||||||
|
conn.execute(db.text(
|
||||||
|
f"ALTER TABLE budgets ADD COLUMN {col} TINYINT(1) NOT NULL DEFAULT {default}"
|
||||||
|
))
|
||||||
|
print(f"Added budgets.{col}")
|
||||||
|
except Exception as e:
|
||||||
|
if 'Duplicate column' in str(e) or '1060' in str(e):
|
||||||
|
print(f"budgets.{col} already exists — skipping")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# users table
|
||||||
|
try:
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN budget_alerts_enabled TINYINT(1) NOT NULL DEFAULT 0"
|
||||||
|
))
|
||||||
|
print("Added users.budget_alerts_enabled")
|
||||||
|
except Exception as e:
|
||||||
|
if 'Duplicate column' in str(e) or '1060' in str(e):
|
||||||
|
print("users.budget_alerts_enabled already exists — skipping")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Daily cron safety-net: check all budgets for the current month and send any
|
||||||
|
unsent 80% / 100% alert emails.
|
||||||
|
|
||||||
|
Run daily (e.g. 9 AM) so alerts fire even if the triggering transaction was
|
||||||
|
imported via a provider sync rather than entered manually through the UI.
|
||||||
|
|
||||||
|
python scripts/check_budget_alerts.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.services.alert_service import (
|
||||||
|
_smtp_configured, _send_single_alert_email
|
||||||
|
)
|
||||||
|
from app.services.budget_service import get_month_spending
|
||||||
|
|
||||||
|
user = User.query.first()
|
||||||
|
if not user or not user.budget_alerts_enabled:
|
||||||
|
print("Budget alerts disabled — nothing to do.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not _smtp_configured():
|
||||||
|
print("SMTP not configured — nothing to do.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
month_str = date.today().strftime('%Y-%m')
|
||||||
|
symbol = user.currency_symbol or '$'
|
||||||
|
budgets = Budget.query.filter_by(month=month_str).all()
|
||||||
|
sent = 0
|
||||||
|
|
||||||
|
for b in budgets:
|
||||||
|
limit = float(b.limit_amount) + float(b.rollover_amount or 0)
|
||||||
|
if limit <= 0:
|
||||||
|
continue
|
||||||
|
spent = get_month_spending(b.category_id, month_str)
|
||||||
|
pct = spent / limit * 100
|
||||||
|
cat = b.category
|
||||||
|
name = cat.name if cat else 'Unknown'
|
||||||
|
|
||||||
|
if pct >= 100 and not b.alert_sent_100:
|
||||||
|
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=100)
|
||||||
|
b.alert_sent_100 = True
|
||||||
|
sent += 1
|
||||||
|
elif pct >= 80 and not b.alert_sent_80:
|
||||||
|
_send_single_alert_email(name, spent, limit, pct, symbol, threshold=80)
|
||||||
|
b.alert_sent_80 = True
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(f"Budget alert check complete — {sent} email(s) sent.")
|
||||||
Reference in New Issue
Block a user