"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list. Read-only (V10/V11): tidak pernah menulis ke DB. Data pemilih disegarkan oleh `get_voters.py` (run harian / cron). Saldo liquid akun diambil langsung dari API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`. Konfigurasi dari env/`.env`. """ import time from datetime import datetime import requests from flask import Flask, abort, jsonify, render_template, request import db from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API, PAGE_SIZE, TARGET_BP) SEARCH_CAP = 500 MAX_Q_LEN = 64 # Tabel distribute_payments diperlukan utk kolom TOTAL REWARD (V29). Dipastikan # ada sekali per worker (idempoten) sebelum query list/search dijalankan. _dist_schema_ready = False def _ensure_dist_schema(): global _dist_schema_ready if not _dist_schema_ready: db.ensure_distribute_schema() _dist_schema_ready = True app = Flask(__name__) @app.after_request def _no_cache_html(resp): """HTML ⊥ di-cache browser (V28): tautan style.css memakai ?v= utk memaksa fetch versi baru; halaman itu sendiri tak boleh heuristik cache sehingga tiap muat selalu menarik tautan ber-version tsb.""" if resp.mimetype == 'text/html': resp.headers['Cache-Control'] = 'no-store' return resp # Cache saldo liquid per worker (in-memory, ⊥ tulis DB). `at` mencatat # kapan sukses ATAU gagal terakhir → API dihubungi paling banyak sekali # per TTL (cooldown saat gagal pun ikut mencegah pukulan berulang). _liquid_cache = {'at': 0.0, 'value': None} @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', '.') @app.template_filter('shortdate') def shortdate(value): """Potong stempel waktu ISO → YYYY-MM-DD (untuk kolom vote terakhir).""" if not value: return '—' try: return datetime.fromisoformat(value).strftime('%Y-%m-%d') except ValueError: return '—' def _get_liquid_vex(): """V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan. Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum lewat → nilai cache. Bila API gagal → nilai lama tetap dipakai (atau None bila belum pernah sukses), dan `at` ikut diset → API tak dipukul berulang. """ now = time.monotonic() if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL: return _liquid_cache['value'] value = None try: resp = requests.get( f'{DATABISNIS_API}/v2/state/get_account', params={'account': TARGET_BP}, timeout=8, ) resp.raise_for_status() raw = resp.json().get('account', {}).get('core_liquid_balance') if raw: value = float(raw.split()[0]) except (requests.RequestException, KeyError, TypeError, ValueError): value = _liquid_cache['value'] # gagal → nilai lama bila ada _liquid_cache.update(at=now, value=value) return value def _escape_like(q): """Escape wildcard LIKE agar `%`/`_`/`!` dari pengguna tidak jadi pola. `!` dipakai sebagai karakter escape, bukan backslash: backslash bermasalah di literal string MySQL (`'\'` memutus string), sedangkan `!` netral di kedua backend (sqlite & mysql sama-sama paham `ESCAPE '!'`). """ return (q.replace('!', '!!') .replace('%', '!%') .replace('_', '!_')) def _filter_sql(q, tbl=''): """V11: klausa WHERE + params untuk filter owner substring. `tbl` = prefix kolom owner (mis. `v.`) bila query memakai JOIN; kosong utk query tunggal agar kolom tak ambigu dengan kolom `owner` dari subquery. """ clauses = [] params = [] if q: clauses.append(f"{tbl}owner LIKE %s ESCAPE '!'") params.append(f'%{_escape_like(q)}%') if not clauses: return '', [] return 'WHERE ' + ' AND '.join(clauses), params def _reward_join(): """V29: total reward per voter = Σ amount distribute_payments status=sent. Gabung (LEFT) subquery agregat agar baris tanpa transfer tetap tampil dgn reward 0. Hanya status `sent` (⊥ failed/pending) → tidak ada dobel hitung saat baris gagal ikut run berikutnya. """ return ('LEFT JOIN (SELECT owner, SUM(amount) AS total ' ' FROM distribute_payments ' ' WHERE status = %s GROUP BY owner) rw ' ' ON rw.owner = v.owner'), ['sent'] 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, 'v.') rw_join, rw_params = _reward_join() 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, ' ' v.last_vote, ' ' COALESCE(rw.total, 0) ' 'FROM voters v ' + rw_join + ' ' + where + ' ' 'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s' ) return db.query(sql, rw_params + params + [PAGE_SIZE, offset]) def _count(q): """V11: banyak baris cocok filter (atau total bila tanpa filter).""" where, params = _filter_sql(q) return db.queryone( 'SELECT COUNT(*) FROM voters ' + where, params )[0] def _stats(): """Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).""" row = db.queryone( 'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) ' 'FROM voters' ) total_voters, total_staked, top_staked = row scanned = db.queryone( 'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters' )[0] 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] _ensure_dist_schema() total_voters, total_staked, top_staked, scanned = _stats() liquid_vex = _get_liquid_vex() 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, liquid_vex=liquid_vex, 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. Menyertakan `total_reward` (V29) agar hasil search sama kolomnya dgn list. """ q = request.args.get('q', '').strip()[:MAX_Q_LEN] _ensure_dist_schema() where, params = _filter_sql(q, 'v.') rw_join, rw_params = _reward_join() 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, ' ' v.last_vote, ' ' COALESCE(rw.total, 0) ' 'FROM voters v ' + rw_join + ' ' + where + ' ' 'ORDER BY v.staked DESC, v.owner ASC LIMIT %s' ) rows = db.query(sql, rw_params + params + [SEARCH_CAP]) return jsonify({ 'query': q, 'count': len(rows), 'cap': SEARCH_CAP, 'results': [ { 'owner': r[0], 'staked': r[1], 'weight': r[2], 'rank': r[3], 'last_vote': r[4], 'total_reward': r[5], } for r in rows ], }) @app.get('/history') def history(): """V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih. Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul. """ runs = db.list_runs() latest = runs[0][0] if runs else None payments = db.list_payments(run_id=latest) return render_template('history.html', runs=runs, payments=payments, latest_date=runs[0][1] if runs else None, bp=TARGET_BP) if __name__ == '__main__': app.run(host=DASH_HOST, port=DASH_PORT, debug=False)