06/05 Optimize app

This commit is contained in:
2026-06-05 17:50:46 -04:00
parent 025f3f8823
commit e4007348f8
6 changed files with 497 additions and 32 deletions
+191 -31
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, Response, stream_with_context
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
@@ -61,39 +61,10 @@ class TransferForm(FlaskForm):
submit = SubmitField('Transfer')
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
account_id = request.args.get('account_id', '', type=str)
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
from datetime import timedelta
today = date.today()
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
this_month_to = today.strftime('%Y-%m-%d')
last_month_last = today.replace(day=1) - timedelta(days=1)
last_month_first = last_month_last.replace(day=1)
last_month_from = last_month_first.strftime('%Y-%m-%d')
last_month_to = last_month_last.strftime('%Y-%m-%d')
if date_from == this_month_from and date_to == this_month_to:
active_quick = 'this_month'
elif date_from == last_month_from and date_to == last_month_to:
active_quick = 'last_month'
else:
active_quick = ''
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
query = Transaction.query.filter(
Transaction.transaction_type == tab
).order_by(Transaction.date.desc(), Transaction.id.desc())
if search:
query = query.filter(
or_(
@@ -125,6 +96,39 @@ def index():
query = query.filter(Transaction.amount <= float(amount_max))
except (ValueError, TypeError):
pass
return query
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
account_id = request.args.get('account_id', '', type=str)
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
from datetime import timedelta
today = date.today()
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
this_month_to = today.strftime('%Y-%m-%d')
last_month_last = today.replace(day=1) - timedelta(days=1)
last_month_first = last_month_last.replace(day=1)
last_month_from = last_month_first.strftime('%Y-%m-%d')
last_month_to = last_month_last.strftime('%Y-%m-%d')
if date_from == this_month_from and date_to == this_month_to:
active_quick = 'this_month'
elif date_from == last_month_from and date_to == last_month_to:
active_quick = 'last_month'
else:
active_quick = ''
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
pagination = query.paginate(page=page, per_page=30, error_out=False)
@@ -483,3 +487,159 @@ def ocr_receipt_file():
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})
# ── Export filtered transactions ──────────────────────────────────────────────
@transactions_bp.route('/export/csv')
@login_required
def export_csv():
from app.services.export_service import transactions_csv_stream
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.csv'
return Response(
stream_with_context(transactions_csv_stream(query)),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@transactions_bp.route('/export/excel')
@login_required
def export_excel():
from app.services.export_service import transactions_to_excel
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
label = f'{tab.title()} {date_from or "all"}{"-" + date_to if date_to else ""}'[:31]
excel_bytes = transactions_to_excel(query, label)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.xlsx'
return Response(
excel_bytes,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
# ── Duplicate detection ───────────────────────────────────────────────────────
@transactions_bp.route('/api/check-duplicate')
@login_required
def check_duplicate():
txn_date = request.args.get('date', '')
amount = request.args.get('amount', '')
txn_type = request.args.get('type', 'expense')
exclude_id = request.args.get('exclude_id', '', type=str)
if not txn_date or not amount:
return jsonify({'duplicates': []})
try:
d = datetime.strptime(txn_date, '%Y-%m-%d').date()
amt = float(amount)
except (ValueError, TypeError):
return jsonify({'duplicates': []})
q = Transaction.query.filter(
Transaction.transaction_type == txn_type,
Transaction.date == d,
Transaction.amount == amt,
)
if exclude_id:
try:
q = q.filter(Transaction.id != int(exclude_id))
except (ValueError, TypeError):
pass
dupes = q.limit(5).all()
return jsonify({'duplicates': [
{'id': t.id, 'description': t.description, 'date': t.date.strftime('%b %d, %Y'),
'account': t.account.name if t.account else ''}
for t in dupes
]})
# ── Split transaction ─────────────────────────────────────────────────────────
@transactions_bp.route('/<int:id>/split', methods=['GET', 'POST'])
@login_required
def split(id):
txn = db.get_or_404(Transaction, id)
all_cats = Category.query.filter(
Category.category_type.in_([txn.transaction_type, 'both']),
Category.is_active == True,
).order_by(Category.name).all()
if request.method == 'POST':
amounts = request.form.getlist('split_amount')
cat_ids = request.form.getlist('split_category')
descs = request.form.getlist('split_description')
parts = []
total_split = 0.0
for amt_str, cid_str, desc_str in zip(amounts, cat_ids, descs):
try:
amt = float(amt_str)
except (ValueError, TypeError):
flash('Invalid amount in split.', 'danger')
return redirect(url_for('transactions.split', id=id))
if amt <= 0:
continue
parts.append({
'amount': amt,
'category_id': int(cid_str) if cid_str else None,
'description': desc_str.strip() or txn.description,
})
total_split += amt
if not parts:
flash('Add at least one split row.', 'danger')
return redirect(url_for('transactions.split', id=id))
if abs(total_split - float(txn.amount)) > 0.005:
flash(f'Split total {total_split:.2f} must equal original {float(txn.amount):.2f}.', 'danger')
return redirect(url_for('transactions.split', id=id))
account_id = txn.account_id
txn_type = txn.transaction_type
txn_date = txn.date
txn_notes = txn.notes
db.session.delete(txn)
db.session.flush()
for part in parts:
new_txn = Transaction(
transaction_type=txn_type,
account_id=account_id,
category_id=part['category_id'],
amount=part['amount'],
description=part['description'],
date=txn_date,
notes=txn_notes,
)
db.session.add(new_txn)
db.session.commit()
calc_balance(account_id)
flash(f'Transaction split into {len(parts)} part(s).', 'success')
return redirect(url_for('transactions.index', tab=txn_type))
return render_template('transactions/split.html', txn=txn, categories=all_cats)