"""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 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, DATABISNIS_API, 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 '—' # ————— 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_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, weekday, first_run=None): """Mirror `distribute_loop.next_boundary`: mingguan + one-off, Sabtu sebelum launch dilewati; pilih event terdekat > `now`.""" 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 first_run is None: return weekly start = datetime(first_run.year, first_run.month, first_run.day, hour=hour) candidates = [] if start >= now: candidates.append(start) if 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_weekday(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 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 return (f'SETIAP {_SCHED_DAYS[_parse_weekday(DISTRIBUTE_WEEKDAY)]}' f' · {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 _get_liquid_vex(): """V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan. Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum lewat → nilai cache. Bila API 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 try: resp = requests.get( f'{DATABISNIS_API}/v2/state/get_account', params={'account': TARGET_BP}, timeout=8, ) resp.raise_for_status() raw = resp.json().get('account', {}).get('core_liquid_balance') if raw: value = float(raw.split()[0]) except (requests.RequestException, KeyError, TypeError, ValueError): value = _liquid_cache['value'] # 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: empat ambang umur vote (UTC-naive, konsisten dgn stempel `last_vote` hasil scan). Stempel lebih besar = vote lebih baru. new_cut = now − VEX_MATURITY_DAYS: `last_vote > new_cut` → BARU warn_cut = now − (STALE − WARN): `warn_cut < last_vote ≤ new_cut` → VALID + badge `VOTE ULANG SEGERA` expired_cut = now − VEX_STALE_DAYS: `expired_cut < last_vote ≤ new_cut` → VALID (mature+segar) 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/V42: baris di luar band VALID — BARU (`last_vote > new` AND pemilih BARU) dan KADALUARSA (`hidden < last_vote ≤ expired`). Jendela kecil → selalu tampil penuh (⊥ pager). Rank = ordinal dalam band. V42: REVOTE (pemilih lama yang revote, `first_seen ≤ now − MATURITY`) DIANGGAP VALID langsung (⊥ masa matang) → barisnya diambil `_db_rows` (daftar utama), ⊖ di sini. BARU = hanya pemilih baru (`first_seen` None/ DB lama atau `> new_cut`); yang sudah pernah terlihat sebelumnya ⊖ BARU. """ 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] > new_cut: fs = r[4] if fs is None or fs > new_cut: new_rows.append(r[:4]) elif r[2] > exp_cut: pass # REVOTE (first_seen ≤ new_cut) atau VALID → daftar utama else: expired_rows.append(r[:4]) return new_rows, expired_rows def _db_rows(page, q): """V10/V11/V40/V42: satu halaman band VALID (mature+segar + REVOTE yang ⊥ tunggu matang), 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). V42: jendela VALID = `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)` — REVOTE (`first_seen ≤ now−MATURITY`) masuk VALID langsung; baris diberi flag revote (elemen ke-8) utk tag mint di template. 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() rev = ('v.last_vote <= %s OR ' '(fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s)') band = 'v.last_vote > %s AND (' + rev + ')' combined = ('WHERE ' + band + ((' AND ' + where[len('WHERE '):]) if where else '')) rev2 = ('v2.last_vote <= %s OR ' '(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 (' + rev2 + ') ' ' 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, new_cut] + rw_params + [exp_cut, new_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/V42: banyak baris band VALID cocok filter (utk pager). V43: tag REVOTE 1-hari & badge peringatan ⊥ ubah cakupan → tetap V42.""" new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs() where, params = _filter_sql(q, 'v.') rev = ('v.last_vote <= %s OR ' '(fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s)') combined = ('WHERE v.last_vote > %s AND (' + rev + ')' + ((' 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, new_cut] + params)[0] def _stats(): """Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search). V40/V42: cakupan = semua baris TAMPIL (BARU + REVOTE + VALID + KADALUARSA, ⊖ yang tersembunyi > 31 hari). Juga menghitung jumlah per band utk caption bagian. V42: REVOTE (`first_seen ≤ now − MATURITY`) dihitung sbg pemilih valid (⊥ masa matang) selama `last_vote > exp_cut`; VALID hanya pemilih baru yang matang. Invariant: n_new + n_revote_window + n_expired + n_valid == total_voters (partisi semua baris tampil), dgn `n_revote_window` = REVOTE di jendela VALID. V43: `n_revote` (legenda mint) = HANYA REVOTE < `VEX_REVOTE_TAG_DAYS` (subset dari `n_revote_window` — berubah harian, ⊥ partisi); `n_expiring` = VALID dalam `VEX_WARN_DAYS` terakhir sebelum kadaluarsa (`exp_cut < last_vote ≤ warn_cut`, overlays band VALID) utk badge `VOTE ULANG SEGERA`. """ new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs() revote_cut = _revote_cut() row = db.queryone( 'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) ' 'FROM voters WHERE last_vote > %s', [hid_cut]) total_voters, total_staked, 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 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 v.last_vote > %s AND v.last_vote <= %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', [new_cut, new_cut, new_cut, revote_cut, hid_cut, exp_cut, exp_cut, new_cut, new_cut, exp_cut, warn_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, total_staked, top_staked, scanned, n_new, n_revote, n_expired, n_valid, n_expiring) def _search_rows(q, cap): """V11/V40/V42: 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. V42: REVOTE (`first_seen ≤ now − MATURITY`) adalah anggota band VALID (⊥ masa matang). V43: tag `revote` HANYA utk 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_revoter = fs is not None and fs <= new_cut tag_revote = is_revoter and r[3] > revote_cut expiring = exp_cut < r[3] <= warn_cut if r[3] > new_cut: group = ('revote' if tag_revote else ('valid' if is_revoter else 'baru')) elif r[3] > exp_cut: group = 'revote' if tag_revote else 'valid' 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, total_staked, 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 = datetime.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, total_staked=total_staked, 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('/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)