Files
databisnisid/dashboard.py
T

647 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 math
import time
from datetime import datetime, timedelta, timezone
import requests
from flask import Flask, abort, jsonify, render_template, request
import db
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, HYPERION_NODES,
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS,
VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
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
# Tabel voter_first_seen (V41) dipakai utk band BARU vs REVOTE. Dipastikan ada
# (idempoten) — scan juga yang menciptakannya; dashboard siap bila belum.
_first_seen_schema_ready = False
def _ensure_dist_schema():
global _dist_schema_ready
if not _dist_schema_ready:
db.ensure_distribute_schema()
_dist_schema_ready = True
def _ensure_first_seen_schema():
global _first_seen_schema_ready
if not _first_seen_schema_ready:
db.ensure_first_seen_schema()
_first_seen_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 '—'
@app.template_filter('countdown')
def countdown(value, now=None):
"""'VALID DALAM N HARI' utk pemilih BARU (V52).
Maturity = `first_seen_at` + `VEX_MATURITY_DAYS`. Nilai NULL/legacy →
None (⊥ cue). Dihitung utk hari penuh, minimal 1 hari."""
if not value:
return None
try:
valid_at = datetime.fromisoformat(value) + timedelta(days=VEX_MATURITY_DAYS)
except ValueError:
return None
now = now or datetime.now(timezone.utc).replace(tzinfo=None)
days = max(1, math.ceil((valid_at - now).total_seconds() / 86400))
return f'VALID DALAM {days} HARI'
# ————— Jadwal distribusi berikutnya (V38) —————
# Banner BERITA di dashboard menampilkan run distribusi berikutnya. Logika
# boundary sengaja mirror `distribute_loop.next_boundary` (⊥ impor lintas-image,
# V31) — oracle test_dashboard menjaga keduanya tak melenceng.
_SCHED_DAYS = ('SENIN', 'SELASA', 'RABU', 'KAMIS', 'JUMAT', 'SABTU', 'MINGGU')
# Parser pakai singkatan Inggris (sama dgn distribute_loop.WEEKDAYS); display
# id-ID via `_SCHED_DAYS` (index = weekday Python, Senin=0).
_SCHED_WEEKDAYS = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6}
_SCHED_MONTHS = ('JANUARI', 'FEBRUARI', 'MARET', 'APRIL', 'MEI', 'JUNI',
'JULI', 'AGUSTUS', 'SEPTEMBER', 'OKTOBER', 'NOVEMBER',
'DESEMBER')
def _parse_weekday(value):
"""Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0)."""
v = str(value).strip().lower()
if v in _SCHED_WEEKDAYS:
return _SCHED_WEEKDAYS[v]
if v.isdigit() and 0 <= int(v) <= 6:
return int(v)
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
def _parse_weekdays(value):
"""Satu hari (`sat`) atau CSV (`wed,sat`) → daftar weekday unik terurut
(int, Senin=0). Mirror `distribute_loop.parse_weekdays` (V44)."""
if not value:
raise ValueError('DISTRIBUTE_WEEKDAY kosong')
days = sorted({_parse_weekday(d)
for d in str(value).split(',') if d.strip()})
if not days:
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
return days
def _parse_first_run(value):
"""`YYYY-MM-DD` → date; kosong → None (⊥ one-off)."""
if not value:
return None
return datetime.strptime(str(value).strip(), '%Y-%m-%d').date()
def _next_boundary(now, hour, weekdays, first_run=None):
"""Mirror `distribute_loop.next_boundary`: tiap hari di `weekdays` jam
`hour` + one-off `first_run` (yang sebelum launch dilewati); pilih event
terdekat > `now`."""
if isinstance(weekdays, int):
weekdays = [weekdays]
start = None
if first_run is not None:
start = datetime(first_run.year, first_run.month, first_run.day,
hour=hour)
candidates = []
if start is not None and start >= now:
candidates.append(start)
for weekday in weekdays:
days_ahead = (weekday - now.weekday()) % 7
weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0)
+ timedelta(days=days_ahead))
if weekly <= now:
weekly += timedelta(days=7)
if start is None or weekly >= start:
candidates.append(weekly)
return min(candidates)
def _next_schedule(now=None):
"""Run distribusi berikutnya (local TZ). `now` bisa di-inject utk oracle."""
return _next_boundary(now or datetime.now(), DISTRIBUTE_HOUR,
_parse_weekdays(DISTRIBUTE_WEEKDAY),
_parse_first_run(DISTRIBUTE_FIRST_RUN))
def _fmt_schedule(dt):
"""`SENIN · 17 AGUSTUS 2026 · 10:00 WIB` (id-ID, uppercase)."""
tz = dt.astimezone().tzname() or ''
return (f'{_SCHED_DAYS[dt.weekday()]} · {dt.day} '
f'{_SCHED_MONTHS[dt.month - 1]} {dt.year} · '
f'{dt.hour:02d}:{dt.minute:02d} {tz}').strip()
def _recurrence_label(now=None):
"""Baris berulang: `SETIAP RABU & SABTU · 10:00`. ⊥ tampil saat event
berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak
konsisten); baris hanya utk putaran mingguan (muncul lagi setelah launch
lewat)."""
if _is_first_run_next(now):
return None
hari = ' & '.join(_SCHED_DAYS[d] for d in _parse_weekdays(DISTRIBUTE_WEEKDAY))
return f'SETIAP {hari} · {DISTRIBUTE_HOUR:02d}:00'
def _is_first_run_next(now=None):
"""True bila event distribusi berikutnya = one-off `DISTRIBUTE_FIRST_RUN`
(launch), bukan putaran mingguan."""
now = now or datetime.now()
first = _parse_first_run(DISTRIBUTE_FIRST_RUN)
if first is None:
return False
start = datetime(first.year, first.month, first.day, hour=DISTRIBUTE_HOUR)
return start >= now and _next_schedule(now) == start
def _now():
"""Jam lokal utk banner jadwal — injectable utk oracle (V64/B25).
Route memanggil helper ini (⊥ `datetime.now()` inline) agar test bisa
mem-fix tanggal; jam dinding nyata berubah seiring kalender, membuat
asersi "first-run masih depan" ⊖ stabil (B25).
"""
return datetime.now()
def _get_liquid_vex():
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
Ambil `account.core_liquid_balance` dari Hyperion. Bila TTL belum lewat →
nilai cache. V60: coba SEMUA `HYPERION_NODES` (failover pool) — host mati
→ lanjut cadangan, SALDO LIQUID tetap live. Bila SEMUA 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
for node in HYPERION_NODES:
try:
resp = requests.get(
f'{node}/v2/state/get_account',
params={'account': TARGET_BP},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
# V61: bentuk tak dikenal (list/string/proxy salah) → lewati host
raw = None
if isinstance(data, dict):
raw = data.get('account', {}).get('core_liquid_balance')
if raw:
value = float(raw.split()[0])
break
except (requests.RequestException, KeyError, TypeError, ValueError,
AttributeError):
continue
if value is None:
value = _liquid_cache['value'] # semua 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 _freshness_cutoffs(now=None):
"""V40/V43/V52: empat ambang umur (UTC-naive, konsisten dgn stempel
`last_vote` hasil scan & `first_seen_at`). Stempel lebih besar = lebih baru.
new_cut = now − VEX_MATURITY_DAYS: maturity akun — `first_seen_at
≤ new_cut` → matang/VALID;
`> new_cut`/NULL → BARU (V52)
warn_cut = now − (STALE − WARN): `expired_cut < last_vote ≤ warn_cut`
→ VALID + badge `VOTE ULANG SEGERA`
expired_cut = now − VEX_STALE_DAYS: `last_vote > expired_cut` → belum
basi (VALID bila akun matang)
hidden_cut = now − (STALE+EXPIRED): `hidden_cut < last_vote ≤ expired_cut`
→ KADALUARSA; `≤ hidden_cut` → sembunyi
V43: tag REVOTE hanya selama `last_vote > now − VEX_REVOTE_TAG_DAYS`
(ambang `revote_cut`, dihitung pemanggil); badge peringatan selama
`expired_cut < last_vote ≤ warn_cut`.
"""
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
def cut(days):
return (now - timedelta(days=days)).isoformat(timespec='seconds')
return (cut(VEX_MATURITY_DAYS),
cut(VEX_STALE_DAYS - VEX_WARN_DAYS),
cut(VEX_STALE_DAYS),
cut(VEX_STALE_DAYS + VEX_EXPIRED_DAYS))
def _revote_cut(now=None):
"""V43: ambang tag REVOTE (mint) — hanya pemilih lama yang revote dalam
`now − VEX_REVOTE_TAG_DAYS` terakhir (`last_vote > revote_cut`) diberi
tag; setelah itu tampil VALID biasa (tetap dibayar langsung, V42)."""
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
return (now - timedelta(days=VEX_REVOTE_TAG_DAYS)).isoformat(timespec='seconds')
def _edge_rows():
"""V40/V52: baris di luar band VALID — BARU (pemilih BARU) dan KADALUARSA
(`hidden < last_vote ≤ expired`). Jendela kecil → selalu tampil penuh
(⊥ pager). Rank = ordinal dalam band.
V52: BARU = akun yang baru pertama kali terlihat (`first_seen` None/DB lama
atau `> new_cut`) dengan `last_vote > exp_cut` — maturity 3 hari diukur
dari `first_seen_at` (presisi detik), BUKAN `last_vote` yang terkuantisasi
pekan (⊥ B15: voter baru Senin–Kamis tampak matang). Akun yang sudah
pernah terlihat (`first_seen ≤ new_cut`) → band VALID (`_db_rows`).
"""
new_cut, _warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
rw_join, rw_params = _reward_join()
sql = (
'SELECT v.owner, v.staked, v.last_vote, COALESCE(rw.total, 0), '
' fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' '
'WHERE v.last_vote > %s '
'ORDER BY v.staked DESC, v.owner ASC'
)
new_rows, expired_rows = [], []
for r in db.query(sql, rw_params + [hid_cut]):
if r[2] > exp_cut:
fs = r[4]
if fs is None or fs > new_cut:
# V52: BARU membawa first_seen_at (elemen ke-4) utk countdown
# `VALID DALAM N HARI` di template.
new_rows.append(r[:4] + (fs,))
else:
expired_rows.append(r[:4])
return new_rows, expired_rows
def _db_rows(page, q):
"""V10/V11/V40/V52: satu halaman band VALID (akun matang, `first_seen ≤
now−MATURITY`), rank dalam band.
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 menghitung atas SELURUH tabel → rank tetap global.
V40: subquery korelasi dibatasi jendela VALID agar rank 1..N konsisten dgn
daftar yang tampil (tiap band punya penomoran sendiri).
V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
(maturity diukur dari `first_seen_at`, presisi detik — ⊥ `last_vote` yang
terkuantisasi pekan, B15); pemilih BARU (first_seen None/`> new_cut`)
→ band BARU, ⊖ di sini.
V43: flag revote (elemen ke-8) hanya utk revote < `VEX_REVOTE_TAG_DAYS`
(`last_vote > revote_cut`); elemen ke-9 = flag `VOTE ULANG SEGERA`
(`expired_cut < last_vote ≤ warn_cut`).
"""
offset = (page - 1) * PAGE_SIZE
new_cut, warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
band = 'v.last_vote > %s AND ' + mature
combined = ('WHERE ' + band
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
mature2 = ('fs2.first_seen_at IS NOT NULL AND fs2.first_seen_at <= %s')
sql = (
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
' (SELECT COUNT(*) FROM voters v2 '
' LEFT JOIN voter_first_seen fs2 ON fs2.owner = v2.owner '
' WHERE v2.last_vote > %s AND ' + mature2 + ' '
' AND (v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner))) AS rn, '
' v.last_vote, '
' COALESCE(rw.total, 0), '
' fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' ' + combined + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
)
rows = db.query(sql, [exp_cut, new_cut] + rw_params
+ [exp_cut, new_cut]
+ params + [PAGE_SIZE, offset])
return [
r + ((1,) if (r[7] is not None and r[7] <= new_cut
and r[5] > revote_cut) else (0,))
+ ((1,) if exp_cut < r[5] <= warn_cut else (0,))
for r in rows
]
def _count(q):
"""V11/V40/V52: banyak baris band VALID cocok filter (utk pager).
V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
(maturity dari `first_seen_at` — B15); tag REVOTE 1-hari & badge peringatan
⊥ ubah cakupan."""
new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
where, params = _filter_sql(q, 'v.')
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
combined = ('WHERE v.last_vote > %s AND ' + mature
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
return db.queryone(
'SELECT COUNT(*) FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner ' + combined,
[exp_cut, new_cut] + params)[0]
def _stats():
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).
V40/V52: cakupan = semua baris TAMPIL (BARU + VALID + KADALUARSA,
⊖ yang tersembunyi > 31 hari). V52: maturity dari `first_seen_at`
(presisi detik, ⊥ `last_vote` terkuantisasi pekan — B15): BARU = akun
baru (`first_seen` None/`> new_cut`) dgn `last_vote > exp_cut`; VALID =
akun matang (`first_seen ≤ new_cut`) dgn `last_vote > exp_cut`.
Invariant: n_new + n_valid + n_expired == total_voters (partisi semua
baris tampil). V43: `n_revote` (legenda mint) = subset VALID yg revote
< `VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); `n_expiring` =
subset VALID dgn `exp_cut < last_vote ≤ warn_cut` utk badge peringatan.
"""
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
row = db.queryone(
'SELECT COUNT(*), COALESCE(MAX(staked), 0) '
'FROM voters WHERE last_vote > %s', [hid_cut])
total_voters, top_staked = row
counts = db.queryone(
'SELECT '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
' AND (fs.first_seen_at IS NULL OR fs.first_seen_at > %s) '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s AND v.last_vote > %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s THEN 1 ELSE 0 END), 0) '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
'WHERE v.last_vote > %s',
[exp_cut, new_cut,
new_cut, revote_cut,
hid_cut, exp_cut,
exp_cut, new_cut,
exp_cut, warn_cut, new_cut,
hid_cut],
)
n_new, n_revote, n_expired, n_valid, n_expiring = counts
scanned = db.queryone(
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters')[0]
return (total_voters, top_staked, scanned,
n_new, n_revote, n_expired, n_valid, n_expiring)
def _search_rows(q, cap):
"""V11/V40/V52: pencarian owner (substring) di SEMUA band tampil.
Kembalikan daftar dict bertag `group` ('baru'|'revote'|'valid'|
'kadaluarsa') + `expired` + rank ordinal dalam kelompok. Dipakai
`api_search` (JSON) dan mode `?q=` tanpa-JS agar hasilnya konsisten.
V52: BARU = akun baru (`first_seen` None/`> new_cut`) dgn `last_vote >
exp_cut`; VALID = akun matang (`first_seen ≤ new_cut`) dgn `last_vote >
exp_cut`. V43: tag `revote` HANYA utk akun matang yg revote <
`VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); revote lebih lama →
`valid` polos. Baris juga diberi `expiring` (`VOTE ULANG SEGERA`) saat
`exp_cut < last_vote ≤ warn_cut`.
"""
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
# V40: pencarian menjangkau SEMUA baris tampil (BARU + VALID + KADALUARSA)
# = seluruh band di atas ambang tersembunyi `last_vote > hid_cut`.
window = 'v.last_vote > %s'
if where:
combined = where + ' AND ' + window
params = params + [hid_cut]
else:
combined = 'WHERE ' + window
params = [hid_cut]
sql = (
'SELECT v.owner, v.staked, v.weight, v.last_vote, '
' COALESCE(rw.total, 0), fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' ' + combined + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
)
rows = db.query(sql, rw_params + params + [cap])
buckets = {'baru': 0, 'revote': 0, 'valid': 0, 'kadaluarsa': 0}
results = []
for r in rows:
fs = r[5]
is_mature = fs is not None and fs <= new_cut
tag_revote = is_mature and r[3] > revote_cut
expiring = exp_cut < r[3] <= warn_cut
if r[3] > exp_cut:
group = ('revote' if tag_revote
else ('valid' if is_mature else 'baru'))
else:
group = 'kadaluarsa'
buckets[group] += 1
results.append({
'owner': r[0], 'staked': r[1], 'weight': r[2],
'rank': buckets[group], 'last_vote': r[3], 'total_reward': r[4],
'group': group, 'expired': group == 'kadaluarsa',
'expiring': expiring,
})
return results
@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()
_ensure_first_seen_schema()
(total_voters, top_staked, scanned,
n_new, n_revote, n_expired, n_valid, n_expiring) = _stats()
liquid_vex = _get_liquid_vex()
if q:
# Mode pencarian (⊥ JS): satu daftar datar ber-tag, ⊥ bagian/pager.
search_rows = _search_rows(q, SEARCH_CAP)
filtered_count = len(search_rows)
rows = new_rows = expired_rows = ()
page = total_pages = 1
else:
search_rows = None
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)
new_rows, expired_rows = _edge_rows()
now = _now()
return render_template(
'index.html',
bp=TARGET_BP,
rows=rows,
new_rows=new_rows,
expired_rows=expired_rows,
search_rows=search_rows,
page=page,
total_pages=total_pages,
total_voters=total_voters,
top_staked=top_staked,
liquid_vex=liquid_vex,
scanned=scanned,
q=q,
filtered_count=filtered_count,
new_count=n_new,
revote_count=n_revote,
expired_count=n_expired,
valid_count=n_valid,
expiring_count=n_expiring,
stale_days=VEX_STALE_DAYS,
warn_days=VEX_WARN_DAYS,
next_schedule=_fmt_schedule(_next_schedule(now)),
schedule_recur=_recurrence_label(now),
)
@app.get('/api/search')
def api_search():
"""V11/V40: JSON hasil pencarian owner (substring) di SEMUA band tampil."""
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
_ensure_dist_schema()
_ensure_first_seen_schema()
results = _search_rows(q, SEARCH_CAP)
return jsonify({
'query': q,
'count': len(results),
'cap': SEARCH_CAP,
'results': results,
})
@app.get('/api/voter/<owner>')
def api_voter(owner):
"""V57: cek apakah akun = pemilih databisnisid yang memenuhi syarat reward.
Jendela sama persis dgn distribusi (`db.eligible_voters` — satu sumber,
dipakai pula `distribute.py`): `last_vote > now−STALE` (⊥ basi) DAN akun
matang (`first_seen_at ≤ now−MATURITY`). Akun BARU/KADALUARSA/tersembunyi/
tak ada → `false`. Baca snapshot DB saja (kesegaran = skan harian), ⊥ chain
live; ⊥ format-validation nama akun. Selalu 200, ⊖ 404.
"""
_ensure_first_seen_schema()
rows = db.eligible_voters(owner)
return jsonify({'owner': owner, 'is_valid_voter': len(rows) > 0})
@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)