get_voters.py kini menyimpan hanya pemilih segar (last_vote tak diketahui atau > VEX_STALE_DAYS dibuang di normalize, V13). Web menampilkan kolom RANK/AKUN/STAKE/VOTE TERAKHIR, tanpa BOBOT SUARA, stat VOTE BASI, penanda/ filter ?stale=1 (V15). voters.db dipangkas 336 → 42. Oracle diperbarui dan lolos.
172 lines
5.1 KiB
Python
172 lines
5.1 KiB
Python
"""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 datetime import datetime
|
|
|
|
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', '.')
|
|
|
|
|
|
@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 _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."""
|
|
clauses = []
|
|
params = []
|
|
if q:
|
|
clauses.append("owner LIKE ? ESCAPE '\\'")
|
|
params.append(f'%{_escape_like(q)}%')
|
|
if not clauses:
|
|
return '', []
|
|
return 'WHERE ' + ' AND '.join(clauses), params
|
|
|
|
|
|
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, '
|
|
' v.last_vote '
|
|
'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, '
|
|
' v.last_vote '
|
|
'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], 'last_vote': r[4],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host=DASH_HOST, port=DASH_PORT, debug=False)
|