647 lines
25 KiB
Python
647 lines
25 KiB
Python
"""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)
|