"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list. Read-only (V10/V11): tidak pernah menulis ke DB. Data disegarkan oleh `get_voters.py` (run harian / cron). Styling mengikuti `DESIGN.md`. Konfigurasi dari env/`.env`. """ import sqlite3 from flask import Flask, abort, jsonify, render_template, request from config import DASH_HOST, DASH_PORT, DB_PATH, PAGE_SIZE, TARGET_BP SEARCH_CAP = 500 MAX_Q_LEN = 64 app = Flask(__name__) @app.template_filter('vex') def vex(value): """Format jumlah VEX ala id-ID (koma ribuan, titik desimal).""" return f'{value:,.4f}'.replace(',', '\u00a0').replace('.', ',').replace('\u00a0', '.') def _escape_like(q): """Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola.""" return (q.replace('\\', '\\\\') .replace('%', '\\%') .replace('_', '\\_')) def _filter_sql(q): """V11: klausa WHERE + params untuk filter owner substring (case-insensitive).""" if not q: return '', [] return "WHERE owner LIKE ? ESCAPE '\\'", [f'%{_escape_like(q)}%'] def _db_rows(page, q): """V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global. Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter. Correlated count selalu menghitung atas SELURUH tabel → rank tetap global. """ offset = (page - 1) * PAGE_SIZE where, params = _filter_sql(q) sql = ( 'SELECT v.owner, v.staked, v.weight, v.scanned_at, ' ' (SELECT COUNT(*) FROM voters v2 ' ' WHERE v2.staked > v.staked ' ' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn ' 'FROM voters v ' + where + ' ' 'ORDER BY v.staked DESC, v.owner ASC LIMIT ? OFFSET ?' ) conn = sqlite3.connect(DB_PATH) try: return conn.execute(sql, params + [PAGE_SIZE, offset]).fetchall() finally: conn.close() def _count(q): """V11: banyak baris cocok filter (atau total bila tanpa filter).""" where, params = _filter_sql(q) conn = sqlite3.connect(DB_PATH) try: return conn.execute( 'SELECT COUNT(*) FROM voters ' + where, params ).fetchone()[0] finally: conn.close() def _stats(): """Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).""" conn = sqlite3.connect(DB_PATH) try: total_voters, total_staked, top_staked = conn.execute( 'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) ' 'FROM voters' ).fetchone() scanned = conn.execute( 'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters' ).fetchone()[0] finally: conn.close() return total_voters, total_staked, top_staked, scanned @app.get('/') def index(): try: page = max(1, int(request.args.get('page', 1))) except ValueError: abort(400) q = request.args.get('q', '').strip()[:MAX_Q_LEN] total_voters, total_staked, top_staked, scanned = _stats() filtered_count = _count(q) total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE) page = min(page, total_pages) rows = _db_rows(page, q) return render_template( 'index.html', bp=TARGET_BP, rows=rows, page=page, total_pages=total_pages, total_voters=total_voters, total_staked=total_staked, top_staked=top_staked, scanned=scanned, q=q, filtered_count=filtered_count, ) @app.get('/api/search') def api_search(): """V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global.""" q = request.args.get('q', '').strip()[:MAX_Q_LEN] where, params = _filter_sql(q) sql = ( 'SELECT v.owner, v.staked, v.weight, ' ' (SELECT COUNT(*) FROM voters v2 ' ' WHERE v2.staked > v.staked ' ' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn ' 'FROM voters v ' + where + ' ' 'ORDER BY v.staked DESC, v.owner ASC LIMIT ?' ) conn = sqlite3.connect(DB_PATH) try: rows = conn.execute(sql, params + [SEARCH_CAP]).fetchall() finally: conn.close() return jsonify({ 'query': q, 'count': len(rows), 'cap': SEARCH_CAP, 'results': [ {'owner': r[0], 'staked': r[1], 'weight': r[2], 'rank': r[3]} for r in rows ], }) if __name__ == '__main__': app.run(host=DASH_HOST, port=DASH_PORT, debug=False)