import json import logging from odoo import http from odoo.http import request _logger = logging.getLogger(__name__) class FusionAccountingChatController(http.Controller): def _check_session_ownership(self, session): """S1-S3: Verify the current user owns the session.""" if session.user_id.id != request.env.user.id: # Allow managers to access any session if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Access denied: you do not own this session'} return None @http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user') def create_session(self, context_domain=None, **kwargs): session = request.env['fusion.accounting.session'].create({ 'user_id': request.env.user.id, 'company_id': request.env.company.id, 'context_domain': context_domain, }) return {'session_id': session.id, 'name': session.name} @http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user') def close_session(self, session_id, **kwargs): session = request.env['fusion.accounting.session'].browse(int(session_id)) if not session.exists(): return {'status': 'closed'} # S2: Ownership check error = self._check_session_ownership(session) if error: return error if session.state == 'active': session.action_close_session() return {'status': 'closed'} @http.route('/fusion_accounting/chat', type='jsonrpc', auth='user') def chat(self, session_id, message, context=None, image=None, **kwargs): if not message and not image: return {'error': 'Message or image is required'} # S3: Ownership check session = request.env['fusion.accounting.session'].browse(int(session_id)) if session.exists(): error = self._check_session_ownership(session) if error: return error agent = request.env['fusion.accounting.agent'] result = agent.chat(int(session_id), message or '', context=context, image=image) return result @http.route('/fusion_accounting/approve', type='jsonrpc', auth='user') def approve_action(self, match_history_id, **kwargs): if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to approve actions'} agent = request.env['fusion.accounting.agent'] result = agent.approve_action(int(match_history_id)) return result @http.route('/fusion_accounting/reject', type='jsonrpc', auth='user') def reject_action(self, match_history_id, reason='', **kwargs): if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to reject actions'} agent = request.env['fusion.accounting.agent'] result = agent.reject_action(int(match_history_id), reason) return result @http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user') def dashboard_data(self, **kwargs): # E2: Wrap in try/except so dashboard doesn't return 500 try: dashboard = request.env['fusion.accounting.dashboard'].new({ 'company_id': request.env.company.id, }) return { 'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount}, 'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count}, 'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week}, 'hst': {'balance': dashboard.hst_balance}, 'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count}, 'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items}, # E1: Include needs_attention and recent_activity 'needs_attention': json.loads(dashboard.needs_attention_json or '[]'), 'recent_activity': json.loads(dashboard.recent_activity_json or '[]'), } except Exception as e: _logger.exception("Dashboard data computation failed") return { 'error': 'Dashboard data could not be computed', 'bank_recon': {'count': 0, 'amount': 0}, 'ar': {'total': 0, 'overdue_count': 0}, 'ap': {'total': 0, 'due_this_week': 0}, 'hst': {'balance': 0}, 'audit': {'score': 0, 'flags': 0}, 'month_end': {'status': 'Unknown', 'open_items': 0}, 'needs_attention': [], 'recent_activity': [], } @http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user') def approve_all(self, match_history_ids, **kwargs): if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to approve actions'} agent = request.env['fusion.accounting.agent'] results = [] for mid in match_history_ids: try: result = agent.approve_action(int(mid)) results.append({'id': mid, 'status': 'approved', 'result': result}) except Exception as e: # S4: Sanitize exception — log full error, return generic message _logger.exception("Error approving match history %s", mid) results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'}) return {'results': results} @http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user') def reject_all(self, match_history_ids, reason='', **kwargs): if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): return {'error': 'Insufficient permissions to reject actions'} agent = request.env['fusion.accounting.agent'] results = [] for mid in match_history_ids: try: result = agent.reject_action(int(mid), reason) # E3: Consistent return shape with approve_all results.append({'id': mid, 'status': 'rejected', 'result': result}) except Exception as e: # S4: Sanitize exception _logger.exception("Error rejecting match history %s", mid) results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'}) return {'results': results} @http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user') def chat_status(self, session_id, **kwargs): """Poll the live execution state of a running chat — returns thinking text, tool calls in progress, and current status. Called every 500ms by the frontend while a chat request is in flight.""" from ..services.agent import get_execution_state state = get_execution_state(int(session_id)) return state @http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user') def search_matches(self, statement_line_id, query='', **kwargs): """Live search for matching journal items — called directly by the reconciliation table search bar (no AI round-trip).""" from ..services.tools.bank_reconciliation import search_matching_entries try: result = search_matching_entries(request.env, { 'statement_line_id': int(statement_line_id), 'query': query, }) return result except Exception as e: _logger.exception("Search matches failed") return {'candidates': [], 'error': str(e)} @http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user') def session_list(self, limit=20, **kwargs): """List recent sessions for the session picker dropdown.""" sessions = request.env['fusion.accounting.session'].search([ ('user_id', '=', request.env.user.id), ], order='write_date desc', limit=int(limit)) return { 'sessions': [{ 'id': s.id, 'name': s.name, 'state': s.state, 'date': s.write_date.isoformat() if s.write_date else '', 'message_count': len(json.loads(s.message_ids_json or '[]')), 'ai_model': s.ai_model or '', } for s in sessions], } @http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user') def session_latest(self, **kwargs): # Find the most recent active session that has messages first, # fall back to any active session (including empty ones) sessions = request.env['fusion.accounting.session'].search([ ('user_id', '=', request.env.user.id), ('state', '=', 'active'), ], order='write_date desc', limit=10) if not sessions: return {'session_id': None, 'messages': [], 'name': None} # Prefer a session with actual messages session = None for s in sessions: msg_json = s.message_ids_json or '[]' if msg_json != '[]' and len(msg_json) > 5: session = s break # If no session has messages, use the newest one if not session: session = sessions[0] # Clean up empty stale sessions (created but never used) for s in sessions: if s.id != session.id and (s.message_ids_json or '[]') == '[]': s.write({'state': 'closed'}) messages = json.loads(session.message_ids_json or '[]') display_messages = [] for msg in messages: if isinstance(msg.get('content'), str) and msg['content'].strip(): display_messages.append(msg) elif isinstance(msg.get('content'), list): for block in msg['content']: if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip(): display_messages.append({'role': msg['role'], 'content': block['text']}) # Include any pending approvals so they show on page load agent = request.env['fusion.accounting.agent'] pending = request.env['fusion.accounting.match.history'].search([ ('session_id', '=', session.id), ('decision', '=', 'pending'), ]) pending_approvals = [agent._format_pending_approval(p) for p in pending] return { 'session_id': session.id, 'messages': display_messages, 'name': session.name, 'pending_approvals': pending_approvals, } @http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user') def session_history(self, session_id, **kwargs): session = request.env['fusion.accounting.session'].browse(int(session_id)) if not session.exists(): return {'error': 'Session not found'} # S1: Ownership check error = self._check_session_ownership(session) if error: return error return { 'messages': json.loads(session.message_ids_json or '[]'), 'session_id': session.id, 'state': session.state, }