diff --git a/TriNetra/backend/api/chronos_api.py b/TriNetra/backend/api/chronos_api.py index 1124a0c..4590143 100644 --- a/TriNetra/backend/api/chronos_api.py +++ b/TriNetra/backend/api/chronos_api.py @@ -12,6 +12,7 @@ fetch_transactions_since, fetch_all_transactions, search_transactions as db_search_transactions, + append_transactions, ) chronos_bp = Blueprint('chronos', __name__) @@ -154,7 +155,100 @@ def search_transactions(): except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 -# Helper Functions +@chronos_bp.route('/import-csv', methods=['POST']) +def import_csv(): + """Import transactions from an uploaded CSV file into the database.""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file provided'}), 400 + + file = request.files['file'] + if not file or file.filename == '': + return jsonify({'status': 'error', 'message': 'No file selected'}), 400 + if not file.filename.lower().endswith('.csv'): + return jsonify({'status': 'error', 'message': 'Only CSV files are accepted'}), 400 + + import io + raw = file.read() + # Attempt UTF-8 then fall back to latin-1 for wider compatibility + try: + content = raw.decode('utf-8') + except UnicodeDecodeError: + try: + content = raw.decode('latin-1') + except Exception: + return jsonify({ + 'status': 'error', + 'message': 'File encoding not supported. Please save the CSV as UTF-8.' + }), 400 + df = pd.read_csv(io.StringIO(content)) + + # Validate required columns + required_cols = ['transaction_id', 'from_account', 'to_account', 'amount'] + missing = [c for c in required_cols if c not in df.columns] + if missing: + return jsonify({ + 'status': 'error', + 'message': f'Missing required columns: {", ".join(missing)}' + }), 400 + + now = datetime.now().isoformat() + + # Fill optional columns with sensible defaults + if 'timestamp' not in df.columns: + df['timestamp'] = now + else: + df['timestamp'] = df['timestamp'].fillna(now) + + if 'suspicious_score' not in df.columns: + df['suspicious_score'] = 0.0 + else: + df['suspicious_score'] = pd.to_numeric(df['suspicious_score'], errors='coerce').fillna(0.0) + + # Validate that 'amount' column contains numeric values + df['amount'] = pd.to_numeric(df['amount'], errors='coerce') + invalid_amounts = df['amount'].isna().sum() + if invalid_amounts > 0: + return jsonify({ + 'status': 'error', + 'message': f'{invalid_amounts} row(s) have invalid non-numeric values in the "amount" column.' + }), 400 + + if 'pattern_type' not in df.columns: + df['pattern_type'] = 'imported' + else: + df['pattern_type'] = df['pattern_type'].fillna('imported') + + if 'scenario' not in df.columns: + df['scenario'] = 'imported' + else: + df['scenario'] = df['scenario'].fillna('imported') + + if 'transaction_type' not in df.columns: + df['transaction_type'] = 'IMPORT' + else: + df['transaction_type'] = df['transaction_type'].fillna('IMPORT') + + # Keep only the columns the database expects + keep_cols = ['transaction_id', 'from_account', 'to_account', 'amount', + 'timestamp', 'suspicious_score', 'pattern_type', 'scenario', + 'transaction_type'] + records = df[keep_cols].to_dict('records') + + inserted = append_transactions(records) + + return jsonify({ + 'status': 'success', + 'message': f'{inserted} transaction(s) imported successfully ' + f'({len(records) - inserted} duplicate(s) skipped)', + 'imported_count': inserted, + 'total_rows': len(records) + }) + + except Exception as e: + import logging + logging.getLogger(__name__).error('CSV import error: %s', e, exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred while processing the file.'}), 500 def generate_aadhar_location(): """Generate realistic Aadhar-based location data""" diff --git a/TriNetra/backend/database/db_utils.py b/TriNetra/backend/database/db_utils.py index a201297..7b32b39 100644 --- a/TriNetra/backend/database/db_utils.py +++ b/TriNetra/backend/database/db_utils.py @@ -207,7 +207,7 @@ def count_transactions() -> int: def bulk_insert_transactions(records: list[dict]) -> None: - """Insert a list of transaction dicts.""" + """Insert a list of transaction dicts (replaces table – used for initial seeding).""" if not records: return sb = _supabase() @@ -226,6 +226,58 @@ def bulk_insert_transactions(records: list[dict]) -> None: conn.close() +def append_transactions(records: list[dict]) -> int: + """Append new transaction records without replacing existing data. + + Duplicate ``transaction_id`` values are silently skipped. + Returns the number of rows actually inserted. + """ + if not records: + return 0 + sb = _supabase() + if sb is not None: + chunk_size = 500 + inserted = 0 + for i in range(0, len(records), chunk_size): + chunk = records[i:i + chunk_size] + res = sb.table('transactions').upsert(chunk, on_conflict='transaction_id').execute() + inserted += len(res.data) if res.data else 0 + return inserted + # SQLite fallback + cfg = _get_config() + conn = sqlite3.connect(cfg.DATABASE_PATH) + cursor = conn.cursor() + inserted = 0 + failed = 0 + for rec in records: + try: + cursor.execute( + "INSERT OR IGNORE INTO transactions " + "(transaction_id, from_account, to_account, amount, timestamp, " + " transaction_type, suspicious_score, pattern_type, scenario) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + rec.get('transaction_id'), + rec.get('from_account'), + rec.get('to_account'), + rec.get('amount'), + rec.get('timestamp'), + rec.get('transaction_type'), + rec.get('suspicious_score', 0.0), + rec.get('pattern_type'), + rec.get('scenario'), + ), + ) + inserted += cursor.rowcount + except Exception as exc: + failed += 1 + import logging + logging.getLogger(__name__).warning('Failed to insert record %s: %s', rec.get('transaction_id'), exc) + conn.commit() + conn.close() + return inserted + + # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- diff --git a/TriNetra/frontend-react/src/pages/ChronosPage.jsx b/TriNetra/frontend-react/src/pages/ChronosPage.jsx index f994742..7c6e0e1 100644 --- a/TriNetra/frontend-react/src/pages/ChronosPage.jsx +++ b/TriNetra/frontend-react/src/pages/ChronosPage.jsx @@ -11,6 +11,7 @@ import { TimelineIcon } from '../components/Icons' export default function ChronosPage() { const navigate = useNavigate() const timelineRef = useRef(null) + const csvInputRef = useRef(null) const [speed, setSpeed] = useState(1) const [viewMode, setViewMode] = useState('timeline') @@ -18,6 +19,7 @@ export default function ChronosPage() { const [insights, setInsights] = useState([]) const [insightsLoading, setInsightsLoading] = useState(false) const [searchTerm, setSearchTerm] = useState('') + const [importing, setImporting] = useState(false) /* ── Handlers ── */ const handleTimeQuantumChange = async (e) => { @@ -138,6 +140,28 @@ export default function ChronosPage() { timelineRef.current?.exportReport() } + const handleCSVImport = async (e) => { + const file = e.target.files?.[0] + if (!file) return + // Reset input so the same file can be re-selected + e.target.value = '' + + setImporting(true) + try { + const api = (await import('../services/api.js')).default + const result = await api.importCSV(file) + if (result.status === 'success') { + notify(result.message, 'success') + } else { + notify(result.message || 'Import failed', 'error') + } + } catch (err) { + notify('CSV import failed: ' + err.message, 'error') + } finally { + setImporting(false) + } + } + return (
@@ -237,6 +261,25 @@ export default function ChronosPage() { Export Report
+ + {/* Import CSV */} +
+ + + +
diff --git a/TriNetra/frontend-react/src/services/api.js b/TriNetra/frontend-react/src/services/api.js index 7640492..1c31ff9 100644 --- a/TriNetra/frontend-react/src/services/api.js +++ b/TriNetra/frontend-react/src/services/api.js @@ -505,6 +505,22 @@ Generated by TriNetra Auto-SAR System (Demo Mode) }; } + async importCSV(file) { + const formData = new FormData() + formData.append('file', file) + const url = `${this.baseURL}/chronos/import-csv` + try { + const response = await fetch(url, { method: 'POST', body: formData }) + const contentType = response.headers.get('content-type') + if (!contentType || !contentType.includes('application/json')) { + return { status: 'error', message: 'Server returned a non-JSON response' } + } + return await response.json() + } catch (error) { + return { status: 'error', message: error.message } + } + } + // CHRONOS API calls async getTimelineData(scenario = 'all', timeQuantum = '1m') { return this.request(`/chronos/timeline?scenario=${scenario}&time_quantum=${timeQuantum}`);