"""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/') 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)