From 2f1f19c435e53b2d9d212c13a532b123cb0d322b Mon Sep 17 00:00:00 2001 From: dsutanto Date: Thu, 6 Aug 2026 19:05:03 +0700 Subject: [PATCH] jadwal distribusi mingguan + banner BERITA jadwal berikutnya di dashboard (V37/V38, T43/T44) --- .env.example | 13 ++++-- AGENTS.md | 18 +++---- SPEC.md | 10 +++- config.py | 5 ++ dashboard.py | 76 +++++++++++++++++++++++++++++- distribute_loop.py | 79 +++++++++++++++++++++++-------- docker-compose.yml | 6 +++ static/style.css | 37 +++++++++++++++ templates/history.html | 2 +- templates/index.html | 9 +++- test_dashboard.py | 25 ++++++++++ test_distribute_loop.py | 101 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 test_distribute_loop.py diff --git a/.env.example b/.env.example index fd8e380..20590cc 100644 --- a/.env.example +++ b/.env.example @@ -51,12 +51,19 @@ SCAN_HOURS=0,8,16 # Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500) SCAN_RUN_ON_START=1 -# — Distribusi profit-share harian (service distribute) — -# Jam (lokal, TZ) run harian. `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi -# .env: di stack ia dibaca dari file `.env` yang di-bind dari NFS +# — Distribusi profit-share (service distribute) — +# Jam (lokal, TZ) run. Jadwal MINGGUAN (V37): tiap DISTRIBUTE_WEEKDAY (nama +# `sat`/`mon`.. atau 0-6; Senin=0, default `sat`) jam DISTRIBUTE_HOUR, plus satu +# one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD; kosong = ⊥). Contoh produksi: +# DISTRIBUTE_FIRST_RUN=2026-08-17 # putaran pertama Senin 17 Agt +# DISTRIBUTE_WEEKDAY=sat # lalu tiap Sabtu +# `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi .env: di stack ia dibaca +# dari file `.env` yang di-bind dari NFS # (`/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env`, # dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal. DISTRIBUTE_HOUR=10 +DISTRIBUTE_WEEKDAY=sat +DISTRIBUTE_FIRST_RUN= DISTRIBUTE_MAX_ATTEMPTS=3 # Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan # pembayaran. --dry-run tetap jalan walau off. diff --git a/AGENTS.md b/AGENTS.md index a5353af..e03b4a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34). +Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters on a weekly schedule: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34). ## Run @@ -11,11 +11,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list. - Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container: `VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py` -- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. +- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` weekly at `DISTRIBUTE_WEEKDAY` default `sat` + hour `DISTRIBUTE_HOUR` default `10`, optional one-off `DISTRIBUTE_FIRST_RUN`; key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. - Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans. -- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. -- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_images.py test_claim.py`. -- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. +- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN`, `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. +- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`. +- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37) is pure calendar logic — weekly schedule boundary + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. ## Key facts @@ -29,8 +29,8 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. - The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column. - Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key. -- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only. -- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next day. +- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only. +- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run. - Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting. - Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB. @@ -44,11 +44,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`) → `next_window` = +24 jam; tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; reward = selisih saldo (setelah−sebelum, settle); fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, temp DB). - `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar. - `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`. -- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards. +- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard). `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards. - `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd. - `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard. - `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. -- `distribute_loop.py` — scheduler harian dalam container stack (service `distribute`): tunggu `DISTRIBUTE_HOUR` (lokal via `TZ`), panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba besok; ⊥ distribusi-awal saat start (snapshot bisa basi). +- `distribute_loop.py` — scheduler mingguan dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `sat`) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, kosong = ⊥); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi). - `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context. - `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`). - `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono). diff --git a/SPEC.md b/SPEC.md index 6e0d344..28e43ec 100644 --- a/SPEC.md +++ b/SPEC.md @@ -54,7 +54,7 @@ cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID) web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only) web: GET `/` + `?q=` → filter owner (server-side, no-JS fallback); pager bawa `q` web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}`, rank global, cap 500 -env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ` — via `config.py` (`.env`) +env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (dashboard banner V38; web service di stack menerima TZ + jadwal) — via `config.py` (`.env`) api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_balance` → body {code:`vex.token`, account, symbol:`VEX`} → `["X.XXXX VEX"]` api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_stats` → {supply, max_supply, issuer} (cek precision) api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via pyntelope) → txid @@ -64,7 +64,7 @@ db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, t db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain) cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile -env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`) +env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_WEEKDAY` (default `sat`), `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, kosong = ⊥), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`) cmd: `./venv/bin/python telegram.py` — modul notifikasi (⊥ CLI); `TELEGRAM_BOT_TOKEN` + daftar chat kosong → kanal no-op senyap; `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal db: table `claim_runs` (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via `db.py`; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola) api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows` → body {json:true, code:`vexcore`, scope:`vexcore`, table:`producers`, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim @@ -72,6 +72,7 @@ api: aksi `vexcore::claimrewards` (owner name) sign pyntelope → `push_transact cmd: `./venv/bin/python claim_loop.py` → scheduler klaim harian (image `databisnisid-claim`, service `claim`); `claim.py` modul logika klaim (⊥ CLI) env: `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT` — via `config.py` (`.env`); key/Telegram dari bind NFS `config.env` di stack (sama dgn distribute) cmd: `./venv/bin/python test_claim.py` — oracle V32..V34 (mock chain+pyntelope, temp DB) +cmd: `./venv/bin/python test_distribute_loop.py` — oracle V37 (logika kalender mingguan + one-off, ⊥ jaringan/DB) ## §V — Invariants V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) @@ -109,6 +110,8 @@ V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal V36: notifikasi klaim reward (sukses `notify_claim` & gagal `notify_claim_failure`) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke `failed`) +V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` (nama `mon`..`sun` atau 0-6; default `sat`) jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, kosong=⊥) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op +V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (`DISTRIBUSI BERIKUTNYA` + tanggal id-ID `SENIN · 17 AGUSTUS 2026 · 10:00 WIB` + baris berulang `SETIAP SABTU · 10:00`); logika boundary MIRROR `distribute_loop.next_boundary` (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`/`TZ` (interpolasi .env) ## §T — Tasks id|status|task|cites @@ -154,6 +157,8 @@ T39|x|telegram.py: `notify_claim` (reward+fee) + `notify_claim_failure` + hook d T40|x|test_claim.py oracle (mock chain+pyntelope, temp DB: jendela, math fee, siklus penuh, retry fee, resume, reward 0) + test_images.py += Dockerfile.claim & modul claim/claim_loop|V31,V32,V33,V34,I.db T41|x|kill-switch distribusi: `DISTRIBUTE_ENABLED` (config default false) + gate `run_distribution` (non-dry-run no-op: exit 0, ⊥ sign/tulis/notif; dry-run tetap) + env compose `${DISTRIBUTE_ENABLED:-false}` + oracle (default off, disabled no-op, disabled+dry-run)|V35,I.env T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env +T43|x|jadwal mingguan: `distribute_loop.py` `next_boundary` = {one-off `DISTRIBUTE_FIRST_RUN` @ jam (jika depan)} ∪ {mingguan `DISTRIBUTE_WEEKDAY` @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`) + env compose + .env.example + oracle `test_distribute_loop.py`|V37,I.env +T44|x|dashboard banner BERITA jadwal distribusi berikutnya: `dashboard.py` mirror `_next_boundary` (⊥ impor lintas-image, V31) + `_next_schedule`/`_fmt_schedule` (id-ID) + `_recurrence_label`; section `.news` di `index.html` + CSS; env compose web += `TZ`/`DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env ## §B — Bug log id|date|cause|fix @@ -162,3 +167,4 @@ B2|2026-08-05|`ESCAPE '\'` valid di SQLite tapi memutus literal string MySQL (`' B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []` gagal di mysql|`db.query` bungkus `list()` → tipe seragam (V17) B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31 B5|2026-08-06|`DISTRIBUTE_ENABLED` pakai `not in ('1','true','yes')` → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke `in (...)` — default off (V35) +B6|2026-08-06|mirror `_parse_weekday` di dashboard memakai kunci nama hari id-ID (`sabtu`) tapi config default `sat` (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN `mon..sun` (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard diff --git a/config.py b/config.py index 14d5496..11823a9 100644 --- a/config.py +++ b/config.py @@ -62,6 +62,11 @@ DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3')) # `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis). DISTRIBUTE_ENABLED = os.getenv( 'DISTRIBUTE_ENABLED', 'false').lower() in ('1', 'true', 'yes') +# Jadwal mingguan (V37): hari `DISTRIBUTE_WEEKDAY` (nama `sat`/`mon`.. atau +# 0-6, default `sat`) jam `DISTRIBUTE_HOUR`; plus satu one-off +# `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, kosong = ⊥). Dipakai distribute_loop. +DISTRIBUTE_WEEKDAY = os.getenv('DISTRIBUTE_WEEKDAY', 'sat') +DISTRIBUTE_FIRST_RUN = os.getenv('DISTRIBUTE_FIRST_RUN', '') # ————————————————— Notifikasi Telegram (V30/V36) ————————————————— # Bot token tunggal; dua daftar chat id (CSV): KOMUNITAS (`..._COMMUNITY_...`) diff --git a/dashboard.py b/dashboard.py index 39c2210..5144571 100644 --- a/dashboard.py +++ b/dashboard.py @@ -7,13 +7,14 @@ Konfigurasi dari env/`.env`. """ import time -from datetime import datetime +from datetime import datetime, timedelta 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) SEARCH_CAP = 500 @@ -66,6 +67,77 @@ def shortdate(value): 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(): + """Baris berulang: `SETIAP SABTU · 10:00`.""" + return (f'SETIAP {_SCHED_DAYS[_parse_weekday(DISTRIBUTE_WEEKDAY)]}' + f' · {DISTRIBUTE_HOUR:02d}:00') + + def _get_liquid_vex(): """V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan. @@ -206,6 +278,8 @@ def index(): scanned=scanned, q=q, filtered_count=filtered_count, + next_schedule=_fmt_schedule(_next_schedule()), + schedule_recur=_recurrence_label(), ) diff --git a/distribute_loop.py b/distribute_loop.py index bd7a436..500cfc6 100644 --- a/distribute_loop.py +++ b/distribute_loop.py @@ -1,14 +1,18 @@ -"""Jadwal distribusi harian: jalankan distribute.py pada jam DISTRIBUTE_HOUR. +"""Jadwal distribusi mingguan: jalankan distribute.py pada hari `DISTRIBUTE_WEEKDAY` +jam `DISTRIBUTE_HOUR`, plus satu one-off `DISTRIBUTE_FIRST_RUN` (YYYY-MM-DD). -Menunggu hingga batas `DISTRIBUTE_HOUR` berikutnya (waktu lokal via `TZ`) -lalu memanggil `distribute.main()`. Loop tak pernah keluar — service dijaga -hidup oleh restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke hari -berikutnya (⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise -RuntimeError yang jelas — ditangkap, dicetak, dicoba lagi besok. +Contoh produksi: `DISTRIBUTE_FIRST_RUN=2026-08-17` (Senin) lalu `DISTRIBUTE_WEEKDAY=sat` +→ putaran pertama Senin 17 Agt 10:00, berikutnya tiap Sabtu 10:00. + +Menunggu hingga batas terjadwal berikutnya (waktu lokal via `TZ`) lalu memanggil +`distribute.main()`. Loop tak pernah keluar — service dijaga hidup oleh +restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke jadwal berikutnya +(⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise RuntimeError yang +jelas — ditangkap, dicetak, dicoba lagi di jadwal berikutnya. Berbeda dengan scan: ⊥ ada "distribusi-awal" saat start (SCAN_RUN_ON_START) — membayar di container start berisiko memakai snapshot basi; distribusi hanya -berjalan pada jam terjadwal. +berjalan pada jadwal. Scheduler helper (`next_boundary`, `_wait_db`) sengaja diduplikasi dari `scan_loop.py` (bukan di-import) karena kedua image Docker terpisah: @@ -22,19 +26,50 @@ from datetime import datetime, timedelta import db import distribute +WEEKDAYS = { + 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, + 'fri': 4, 'sat': 5, 'sun': 6, +} -def next_boundary(now, hours): - """datetime berikutnya pada jam-jam `hours` yang lebih besar dari `now`. - Sama dengan `scan_loop.next_boundary` (mirror agar image dist mandiri). - """ - for h in hours: - cand = now.replace(hour=h, minute=0, second=0, microsecond=0) - if cand > now: - return cand - # semua jam hari ini sudah lewat → besok jam pertama - nxt = now + timedelta(days=1) - return nxt.replace(hour=hours[0], minute=0, second=0, microsecond=0) +def parse_weekday(value): + """Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0).""" + v = str(value).strip().lower() + if v in WEEKDAYS: + return 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): + """datetime berikutnya utk distribusi. + + Mingguan: `weekday` jam `hour`. Bila `first_run` diset, distribusi ⊥ jalan + sebelum tanggal itu — Sabtu (atau hari mingguan) yang jatuh sebelum launch + dilewati; event = {one-off `first_run` (jika masih depan)} ∪ {mingguan ≥ + tanggal `first_run`}; pilih yang 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 _wait_db(timeout=120): @@ -61,11 +96,15 @@ def main(): hour = int(os.getenv('DISTRIBUTE_HOUR', '10')) if not 0 <= hour <= 23: raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}') - print(f'Jadwal distribusi: jam {hour} (waktu lokal)') + weekday = parse_weekday(os.getenv('DISTRIBUTE_WEEKDAY', 'sat')) + first_run = parse_first_run(os.getenv('DISTRIBUTE_FIRST_RUN', '')) + jadwal = (f'jam {hour} tiap hari {list(WEEKDAYS)[weekday]} (waktu lokal)' + + (f', one-off {first_run.isoformat()}' if first_run else '')) + print(f'Jadwal distribusi: {jadwal}') _wait_db() while True: now = datetime.now() - target = next_boundary(now, [hour]) + target = next_boundary(now, hour, weekday, first_run) wait = (target - now).total_seconds() print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam ' f'sampai {target.isoformat(timespec="seconds")}', flush=True) diff --git a/docker-compose.yml b/docker-compose.yml index 6f8145c..a80fccb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: DASH_WORKERS: "2" DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id} DASH_LIQUID_TTL: "60" + TZ: ${TZ:-Asia/Jakarta} + DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10} + DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat} + DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-} ports: - published: 5000 target: 5000 @@ -104,6 +108,8 @@ services: VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid} TZ: ${TZ:-Asia/Jakarta} DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10} + DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat} + DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-} DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3} DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false} DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id} diff --git a/static/style.css b/static/style.css index 148baaa..8e0b2f5 100644 --- a/static/style.css +++ b/static/style.css @@ -106,6 +106,41 @@ body { white-space: nowrap; } +/* ————— news (jadwal distribusi berikutnya, V38) ————— */ +.news { + margin: 0 var(--sp-lg) var(--sp-xl); + border: 1px solid var(--hairline-strong); + padding: var(--sp-lg); + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} +.news-tag { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--link); +} +.news-title { + font-family: var(--font-display); + font-size: 24px; + font-weight: 400; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--on-dark); +} +.news-date { + font-family: var(--font-display); + font-size: 32px; + font-weight: 400; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--on-dark); + font-variant-numeric: tabular-nums; +} +.news .caption { margin-top: 0; } + /* ————— stats band ————— */ .stats { display: grid; @@ -321,6 +356,8 @@ button.pill { /* ————— mobile (<768px): list → kartu berlabel ————— */ @media (max-width: 768px) { + .news { margin-left: var(--sp-md); margin-right: var(--sp-md); } + .news-date { font-size: 24px; } .stats { grid-template-columns: 1fr; } .spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); } diff --git a/templates/history.html b/templates/history.html index 9419ae3..c80b210 100644 --- a/templates/history.html +++ b/templates/history.html @@ -7,7 +7,7 @@ - +
diff --git a/templates/index.html b/templates/index.html index 7e9a6bc..89211e7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,7 +7,7 @@ - +
@@ -23,6 +23,13 @@

PEMINDAIAN TERAKHIR · {{ scanned }}

+
+ BERITA +

DISTRIBUSI BERIKUTNYA

+

{{ next_schedule }}

+

{{ schedule_recur }}

+
+
{{ total_voters }} diff --git a/test_dashboard.py b/test_dashboard.py index 7288284..ee2e7e8 100644 --- a/test_dashboard.py +++ b/test_dashboard.py @@ -106,6 +106,31 @@ def main(): '50,0000' in html) + # ————— V38: banner BERITA jadwal distribusi berikutnya ————— + dashboard._next_schedule = lambda: datetime(2026, 8, 17, 10, 0) + resp = client.get('/') + html = resp.get_data(as_text=True) + check('V38 tag BERITA', 'BERITA' in html) + check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html) + check('V38 tanggal id-ID (Senin 17 Agt)', + 'SENIN · 17 AGUSTUS 2026 · 10:00' in html) + check('V38 baris berulang', 'SETIAP SABTU · 10:00' in html) + + # drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor) + import distribute_loop + drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0), + datetime(2026, 8, 22, 9, 0), datetime(2026, 8, 22, 11, 0), + datetime(2026, 8, 15, 9, 0)] + fr = dashboard._parse_first_run('2026-08-17') + check('V38 drift-guard one-off = distribute_loop', + all(dashboard._next_boundary(n, 10, 5, fr) + == distribute_loop.next_boundary(n, 10, 5, fr) + for n in drift_dts)) + check('V38 drift-guard mingguan = distribute_loop', + all(dashboard._next_boundary(n, 10, 5) + == distribute_loop.next_boundary(n, 10, 5) + for n in drift_dts)) + # halaman melebihi total → dibatasi (tidak error) resp = client.get('/?page=99') check('V10 halaman ekstra → 200', resp.status_code == 200) diff --git a/test_distribute_loop.py b/test_distribute_loop.py new file mode 100644 index 0000000..ed24084 --- /dev/null +++ b/test_distribute_loop.py @@ -0,0 +1,101 @@ +"""Oracle jadwal distribusi mingguan (V37): next_boundary + parser. + +Murni logika kalender — ⊥ jaringan/DB. Verifikasi: first_run one-off diambil +bila lebih dekat dari mingguan; batas dihitung ulang tiap iterasi loop +(distribute_loop.main loop tak pernah keluar); first_run yang sudah lewat +dibuang → hanya jadwal mingguan. +""" + +from datetime import datetime + +import distribute_loop as dl + +CHECKS = 0 + + +def check(tag, cond): + global CHECKS + CHECKS += 1 + if not cond: + raise AssertionError(f'GAGAL: {tag}') + print(f'ok: {tag}') + + +def main(): + # parse_weekday: nama + angka, kasus-tak-acak + check('parse_weekday sat', dl.parse_weekday('sat') == 5) + check('parse_weekday SUN upper', dl.parse_weekday('SUN') == 6) + check('parse_weekday mon', dl.parse_weekday('mon') == 0) + check('parse_weekday angka 0-6', dl.parse_weekday('3') == 3) + check('parse_weekday 6', dl.parse_weekday('6') == 6) + for bad in ('', 'xyz', '7', '-1'): + try: + dl.parse_weekday(bad) + raise AssertionError(f'GAGAL: parse_weekday {bad!r} harus raise') + except ValueError: + pass + check('parse_weekday invalid raise', True) + + # parse_first_run: kosong → None, format → date + check('parse_first_run kosong', dl.parse_first_run('') is None) + check('parse_first_run None', dl.parse_first_run(None) is None) + fr = dl.parse_first_run('2026-08-17') + check('parse_first_run format', fr.year == 2026 and fr.month == 8 + and fr.day == 17) + try: + dl.parse_first_run('17/08/2026') + raise AssertionError('GAGAL: format salah harus raise') + except ValueError: + pass + check('parse_first_run format salah raise', True) + + # — next_boundary: mingguan Sabtu jam 10 + one-off Senin 17 Agt — + # Awal (Kamis 6 Agt pagi): Sabtu 8 Agt jatuh SEBELUM launch 17 Agt → dilewati, + # event terdekat = one-off Senin 17 Agt + now = datetime(2026, 8, 6, 9, 0) # Kamis + t = dl.next_boundary(now, 10, 5, fr) + check('Sabtu sebelum launch dilewati → Senin 17 Agt', + t == datetime(2026, 8, 17, 10, 0)) + + # Sama jam sudah lewat di hari one-off → mingguan Sabtu berikutnya + now = datetime(2026, 8, 17, 11, 0) # Senin, jam lewat + t = dl.next_boundary(now, 10, 5, fr) + check('first_run lewat → Sabtu 22 Agt', + t == datetime(2026, 8, 22, 10, 0)) + + # Mingguan saja (⊥ first_run) → Sabtu terdekat dari sekarang + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5) + check('tanpa first_run → Sabtu 8 Agt', + t == datetime(2026, 8, 8, 10, 0)) + + # Hari yang sama, jam belum lewat → hari ini + t = dl.next_boundary(datetime(2026, 8, 22, 9, 0), 10, 5) + check('Sabtu jam belum lewat → hari ini', + t == datetime(2026, 8, 22, 10, 0)) + + # Hari yang sama, jam sudah lewat → minggu depan + t = dl.next_boundary(datetime(2026, 8, 22, 11, 0), 10, 5) + check('Sabtu jam lewat → Sabtu pekan depan', + t == datetime(2026, 8, 29, 10, 0)) + + # first_run = hari Sabtu (22 Agt) → Sabtu2 sebelum launch dilewati + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5, + dl.parse_first_run('2026-08-22')) + check('first_run Sabtu → Sabtu 8/15 Agt dilewati, event = 22 Agt', + t == datetime(2026, 8, 22, 10, 0)) + + # Satu hari sebelum launch (Sabtu 15 Agt pagi) → tetap 22 Agt + t = dl.next_boundary(datetime(2026, 8, 15, 9, 0), 10, 5, + dl.parse_first_run('2026-08-22')) + check('sehari sblm launch Sabtu → tetap 22 Agt', + t == datetime(2026, 8, 22, 10, 0)) + + # Hari lain dalam seminggu: Rabu (weekday 2) → next Rabu 10:00 + t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 2) + check('Rabu → Rabu 12 Agt', t == datetime(2026, 8, 12, 10, 0)) + + print(f'SEMUA UJI PASS ({CHECKS} checks)') + + +if __name__ == '__main__': + main()