Manutenzione
++ Interrompe l'apertura di nuovi round dopo quello in corso, senza troncare il round attuale — chiusura, + estrazione e pagamento del vincitore avvengono normalmente. Gli utenti vedono un avviso di manutenzione. +
+diff --git a/CLAUDE.md b/CLAUDE.md index 2678efc..aca69b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a c - **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git. - **Operational config**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart. - **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision. +- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner. ## PLM network parameters diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 9e7af95..d301ed1 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -29,6 +29,7 @@ _CONFIG_FIELDS = ( "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", + "paused", ) @@ -41,6 +42,7 @@ class RoundConfigResponse(BaseModel): fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int + paused: bool class RoundConfigUpdate(BaseModel): @@ -52,6 +54,7 @@ class RoundConfigUpdate(BaseModel): fee_rate_sat_vb: int | None = None rbf_timeout_seconds: int | None = None draw_animation_seconds: int | None = None + paused: bool | None = None def _config_response(config) -> RoundConfigResponse: @@ -78,6 +81,27 @@ async def update_config( return _config_response(config) +@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: + """Maintenance switch: the round in progress (if any) still closes, draws, + and pays out its winner normally — only opening the *next* round is + suppressed until /admin/resume is called (rounds/service.py).""" + config = await get_round_config(session) + config.paused = True + await write_audit_log(session, "lottery_paused", {}) + await session.commit() + return _config_response(config) + + +@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) +async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: + config = await get_round_config(session) + config.paused = False + await write_audit_log(session, "lottery_resumed", {}) + await session.commit() + return _config_response(config) + + class AdminUserResponse(BaseModel): id: int username: str diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 2b55a54..b85a9d0 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -25,6 +25,7 @@ class CurrentRoundResponse(BaseModel): winner_user_id: int | None = None winner_amount_sats: int | None = None chain_tip_height: int | None = None + lottery_paused: bool = False @router.get("/current", response_model=CurrentRoundResponse) @@ -39,6 +40,7 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se bet_amount_sats=config.bet_amount_sats, draw_animation_seconds=config.draw_animation_seconds, chain_tip_height=chain_tip_height, + lottery_paused=config.paused, ) participant_count = await session.scalar( @@ -60,4 +62,5 @@ async def current_round(request: Request, session: AsyncSession = Depends(get_se winner_user_id=round_.winner_user_id, winner_amount_sats=round_.winner_amount_sats, chain_tip_height=chain_tip_height, + lottery_paused=config.paused, ) diff --git a/app/db/models.py b/app/db/models.py index 9bf6dec..60927dc 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -95,6 +95,10 @@ class RoundConfig(Base): min_amount_sats: Mapped[int] = mapped_column(BigInteger, default=100_000_000) fee_rate_sat_vb: Mapped[int] = mapped_column(default=1) rbf_timeout_seconds: Mapped[int] = mapped_column(default=900) + # Maintenance switch: when true, the round currently in progress still runs to + # completion (closes, draws, pays out the winner) but no new round is opened + # afterwards — see rounds/service.py:open_new_round_if_needed. + paused: Mapped[bool] = mapped_column(default=False) updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow) diff --git a/app/rounds/service.py b/app/rounds/service.py index dcdb217..44b3eab 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -19,19 +19,26 @@ async def get_active_round(session: AsyncSession) -> Round | None: async def open_new_round_if_needed(session: AsyncSession) -> Round | None: """Returns the active round if one exists (whatever its status). Otherwise opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS) - hasn't elapsed yet — in which case returns None. Callers that need to attach a - bet must additionally check the returned round's status == "open" — a round in - closing/drawing/paying_out isn't accepting new bets, but a new round can't open - until it's done.""" + hasn't elapsed yet, or the lottery is paused for maintenance — in either case + returns None. Callers that need to attach a bet must additionally check the + returned round's status == "open" — a round in closing/drawing/paying_out + isn't accepting new bets, but a new round can't open until it's done. + + Pausing never touches a round already in progress: it only suppresses opening + the *next* one, so the current round still closes, draws, and pays out the + winner normally (see admin.py's /admin/pause and /admin/resume).""" active = await get_active_round(session) if active is not None: return active + config = await get_round_config(session) + if config.paused: + return None + last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) if last_closed is not None and last_closed.closed_at is not None: closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) - cooldown_seconds = (await get_round_config(session)).round_cooldown_seconds - if datetime.now(timezone.utc) < closed_at + timedelta(seconds=cooldown_seconds): + if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds): return None round_ = Round(status="open") diff --git a/app/static/admin.html b/app/static/admin.html index c101cb5..458032c 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -77,6 +77,12 @@ } .chain-block { color: var(--color-muted-foreground); font-size: 0.82rem; white-space: nowrap; } + .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + #maintenance-btn.btn-stop { + background: var(--color-destructive-bg); color: var(--color-destructive); border-color: var(--color-destructive); + } + .status-dot.status-paused { background: var(--color-destructive); } + main { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; } .view { display: none; } @@ -221,6 +227,21 @@
Configurazione operativa, salvata nel database — modificabile in qualsiasi momento senza riavviare il server.
++ Interrompe l'apertura di nuovi round dopo quello in corso, senza troncare il round attuale — chiusura, + estrazione e pagamento del vincitore avvengono normalmente. Gli utenti vedono un avviso di manutenzione. +
+Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.
@@ -355,13 +382,6 @@