fina/app/routes/search.py
2025-12-26 00:52:56 +00:00

285 lines
9.8 KiB
Python

"""
Global Search API
Provides unified search across all app content and features
Security: All searches filtered by user_id to prevent data leakage
"""
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from app.models import Expense, Document, Category, RecurringExpense, Tag
from sqlalchemy import or_, func
from datetime import datetime
bp = Blueprint('search', __name__, url_prefix='/api/search')
# App features/pages for navigation
APP_FEATURES = [
{
'id': 'dashboard',
'name': 'Dashboard',
'name_ro': 'Tablou de bord',
'description': 'View your financial overview',
'description_ro': 'Vezi prezentarea generală financiară',
'icon': 'dashboard',
'url': '/dashboard',
'keywords': ['dashboard', 'tablou', 'bord', 'overview', 'home', 'start']
},
{
'id': 'transactions',
'name': 'Transactions',
'name_ro': 'Tranzacții',
'description': 'Manage your expenses and transactions',
'description_ro': 'Gestionează cheltuielile și tranzacțiile',
'icon': 'receipt_long',
'url': '/transactions',
'keywords': ['transactions', 'tranzactii', 'expenses', 'cheltuieli', 'spending']
},
{
'id': 'recurring',
'name': 'Recurring Expenses',
'name_ro': 'Cheltuieli recurente',
'description': 'Manage subscriptions and recurring bills',
'description_ro': 'Gestionează abonamente și facturi recurente',
'icon': 'repeat',
'url': '/recurring',
'keywords': ['recurring', 'recurente', 'subscriptions', 'abonamente', 'bills', 'facturi', 'monthly']
},
{
'id': 'reports',
'name': 'Reports',
'name_ro': 'Rapoarte',
'description': 'View detailed financial reports',
'description_ro': 'Vezi rapoarte financiare detaliate',
'icon': 'analytics',
'url': '/reports',
'keywords': ['reports', 'rapoarte', 'analytics', 'analize', 'statistics', 'statistici']
},
{
'id': 'documents',
'name': 'Documents',
'name_ro': 'Documente',
'description': 'Upload and manage your documents',
'description_ro': 'Încarcă și gestionează documentele',
'icon': 'description',
'url': '/documents',
'keywords': ['documents', 'documente', 'files', 'fisiere', 'upload', 'receipts', 'chitante']
},
{
'id': 'settings',
'name': 'Settings',
'name_ro': 'Setări',
'description': 'Configure your account settings',
'description_ro': 'Configurează setările contului',
'icon': 'settings',
'url': '/settings',
'keywords': ['settings', 'setari', 'preferences', 'preferinte', 'account', 'cont', 'profile', 'profil']
}
]
# Admin-only features
ADMIN_FEATURES = [
{
'id': 'admin',
'name': 'Admin Panel',
'name_ro': 'Panou Admin',
'description': 'Manage users and system settings',
'description_ro': 'Gestionează utilizatori și setări sistem',
'icon': 'admin_panel_settings',
'url': '/admin',
'keywords': ['admin', 'administration', 'users', 'utilizatori', 'system', 'sistem']
}
]
@bp.route('/', methods=['GET'])
@login_required
def global_search():
"""
Global search across all content and app features
Security: All data searches filtered by current_user.id
Query params:
- q: Search query string
- limit: Max results per category (default 5)
Returns:
- features: Matching app features/pages
- expenses: Matching expenses (by description or OCR text)
- documents: Matching documents (by filename or OCR text)
- categories: Matching categories
- recurring: Matching recurring expenses
"""
query = request.args.get('q', '').strip()
limit = request.args.get('limit', 5, type=int)
if not query or len(query) < 2:
return jsonify({
'success': False,
'message': 'Query must be at least 2 characters'
}), 400
results = {
'features': [],
'expenses': [],
'documents': [],
'categories': [],
'recurring': [],
'tags': []
}
# Search app features
query_lower = query.lower()
for feature in APP_FEATURES:
# Check if query matches any keyword
if any(query_lower in keyword.lower() for keyword in feature['keywords']):
results['features'].append({
'id': feature['id'],
'type': 'feature',
'name': feature['name'],
'name_ro': feature['name_ro'],
'description': feature['description'],
'description_ro': feature['description_ro'],
'icon': feature['icon'],
'url': feature['url']
})
# Add admin features if user is admin
if current_user.is_admin:
for feature in ADMIN_FEATURES:
if any(query_lower in keyword.lower() for keyword in feature['keywords']):
results['features'].append({
'id': feature['id'],
'type': 'feature',
'name': feature['name'],
'name_ro': feature['name_ro'],
'description': feature['description'],
'description_ro': feature['description_ro'],
'icon': feature['icon'],
'url': feature['url']
})
# Search expenses - Security: filter by user_id
expense_query = Expense.query.filter_by(user_id=current_user.id)
expense_query = expense_query.filter(
or_(
Expense.description.ilike(f'%{query}%'),
Expense.receipt_ocr_text.ilike(f'%{query}%')
)
)
expenses = expense_query.order_by(Expense.date.desc()).limit(limit).all()
for expense in expenses:
# Check if match is from OCR text
ocr_match = expense.receipt_ocr_text and query_lower in expense.receipt_ocr_text.lower()
results['expenses'].append({
'id': expense.id,
'type': 'expense',
'description': expense.description,
'amount': expense.amount,
'currency': expense.currency,
'category_name': expense.category.name if expense.category else None,
'category_color': expense.category.color if expense.category else None,
'date': expense.date.isoformat(),
'has_receipt': bool(expense.receipt_path),
'ocr_match': ocr_match,
'url': '/transactions'
})
# Search documents - Security: filter by user_id
doc_query = Document.query.filter_by(user_id=current_user.id)
doc_query = doc_query.filter(
or_(
Document.original_filename.ilike(f'%{query}%'),
Document.ocr_text.ilike(f'%{query}%')
)
)
documents = doc_query.order_by(Document.created_at.desc()).limit(limit).all()
for doc in documents:
# Check if match is from OCR text
ocr_match = doc.ocr_text and query_lower in doc.ocr_text.lower()
results['documents'].append({
'id': doc.id,
'type': 'document',
'filename': doc.original_filename,
'file_type': doc.file_type,
'file_size': doc.file_size,
'category': doc.document_category,
'created_at': doc.created_at.isoformat(),
'ocr_match': ocr_match,
'url': '/documents'
})
# Search categories - Security: filter by user_id
categories = Category.query.filter_by(user_id=current_user.id).filter(
Category.name.ilike(f'%{query}%')
).order_by(Category.display_order).limit(limit).all()
for category in categories:
results['categories'].append({
'id': category.id,
'type': 'category',
'name': category.name,
'color': category.color,
'icon': category.icon,
'url': '/transactions'
})
# Search recurring expenses - Security: filter by user_id
recurring = RecurringExpense.query.filter_by(user_id=current_user.id).filter(
or_(
RecurringExpense.name.ilike(f'%{query}%'),
RecurringExpense.notes.ilike(f'%{query}%')
)
).order_by(RecurringExpense.next_due_date).limit(limit).all()
for rec in recurring:
results['recurring'].append({
'id': rec.id,
'type': 'recurring',
'name': rec.name,
'amount': rec.amount,
'currency': rec.currency,
'frequency': rec.frequency,
'category_name': rec.category.name if rec.category else None,
'category_color': rec.category.color if rec.category else None,
'next_due_date': rec.next_due_date.isoformat(),
'is_active': rec.is_active,
'url': '/recurring'
})
# Search tags
# Security: Filtered by user_id
tags = Tag.query.filter(
Tag.user_id == current_user.id,
Tag.name.ilike(f'%{query}%')
).limit(limit).all()
for tag in tags:
results['tags'].append({
'id': tag.id,
'type': 'tag',
'name': tag.name,
'color': tag.color,
'icon': tag.icon,
'use_count': tag.use_count,
'is_auto': tag.is_auto
})
# Calculate total results
total_results = sum([
len(results['features']),
len(results['expenses']),
len(results['documents']),
len(results['categories']),
len(results['recurring']),
len(results['tags'])
])
return jsonify({
'success': True,
'query': query,
'total_results': total_results,
'results': results
})