Files
databisnisid/dashboard.py
T
proitlab 205ad35c70 T9-T12: env config + live owner search + responsive (§V11, I.env, I.web)
- config.py: env/.env (python-dotenv), defaults = konstanta lama; get_voters & dashboard pakai config
- dashboard: ?q= server filter + /api/search JSON (substring, LIKE-escape, rank global via correlated count)
- static/app.js: debounced live search; degrades to GET ?q=
- static/style.css: input search + responsive (1024/768/480), list→kartu berlabel di mobile
- test_dashboard: search/API/rank-global/config tests; .env.example; .env gitignored
2026-08-04 16:23:26 +07:00

150 lines
4.5 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 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)