Files
plm-lottery/app/db/models.py
T
davide 162a63d04a Add a maintenance pause/resume switch and a proper user navbar
RoundConfig gets a paused flag toggled via new POST /admin/pause and
/admin/resume endpoints (audit-logged, surfaced as a "Manutenzione" card in
the admin Parametri view). Pausing only stops the *next* round from opening
once the current one closes — rounds/service.py:open_new_round_if_needed
still lets an in-progress round finish, draw, and pay out its winner
normally. GET /rounds/current exposes lottery_paused so the user page shows
a maintenance banner (even while logged out) instead of silently going idle.

Also replaces the user dashboard's stacked account-bar card + bento-grid
menu with a single sticky navbar (identity row + Deposito/Bet/Prelievo
tabs), and moves the page content into a dedicated .app-shell container so
the navbar itself can span full width.
2026-07-22 10:36:36 +02:00

148 lines
7.1 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
derivation_index: Mapped[int] = mapped_column(unique=True)
address: Mapped[str] = mapped_column(String(128), unique=True)
# Read cache only; must always be written in the same transaction as the
# utxo_events rows it summarizes. Source of truth is utxo_events.
cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class UtxoEvent(Base):
__tablename__ = "utxo_events"
__table_args__ = (UniqueConstraint("txid", "vout"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
txid: Mapped[str] = mapped_column(String(64))
vout: Mapped[int]
amount_sats: Mapped[int] = mapped_column(BigInteger)
confirmed_height: Mapped[int]
confirmed_at: Mapped[datetime] = mapped_column(default=utcnow)
# Set once this UTXO is consumed by an outgoing bet/withdrawal build.
spent_txid: Mapped[str | None] = mapped_column(String(64), default=None)
class Round(Base):
__tablename__ = "rounds"
id: Mapped[int] = mapped_column(primary_key=True)
status: Mapped[str] = mapped_column(String(16), default="open")
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(default=None)
draw_block_height: Mapped[int | None] = mapped_column(default=None)
draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None)
seed_int: Mapped[str | None] = mapped_column(String(128), default=None)
winner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
bet_txid: Mapped[str] = mapped_column(String(64))
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(String(16), default="broadcast")
class RoundConfig(Base):
"""Single-row operational config, DB-backed so it's editable without a redeploy.
Everything business/round-related lives here (round timing, bet amount, fee
rate, RBF timeout, minimum amount) so an operator can tune it live. Secrets
and infra wiring (master key, JWT secret, Electrum host, admin token,
database URL) deliberately stay env-var-driven — those require a restart
anyway and aren't safe to hot-swap."""
__tablename__ = "round_config"
id: Mapped[int] = mapped_column(primary_key=True)
fee_address: Mapped[str] = mapped_column(String(128))
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000)
round_duration_seconds: Mapped[int] = mapped_column(default=600)
round_cooldown_seconds: Mapped[int] = mapped_column(default=30)
# Purely a frontend cue: the minimum time the "estrazione in corso" animation
# plays for on every user's dashboard before the winner can be revealed. Does
# NOT gate the actual draw, which still waits for a real confirmed block for
# its entropy (rounds/scheduler.py) — that can take longer than this value.
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
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)
class PendingTransaction(Base):
"""Single source of truth for the RBF timeout->bump->rebroadcast loop."""
__tablename__ = "pending_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(String(16)) # bet | payout | withdrawal
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
withdrawal_id: Mapped[int | None] = mapped_column(ForeignKey("withdrawals.id"), default=None)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
current_txid: Mapped[str] = mapped_column(String(64))
fee_rate_sat_vb: Mapped[int]
raw_tx_hex: Mapped[str] = mapped_column(String)
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
status: Mapped[str] = mapped_column(String(16), default="pending")
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
attempt_count: Mapped[int] = mapped_column(default=1)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class Withdrawal(Base):
__tablename__ = "withdrawals"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
external_address: Mapped[str] = mapped_column(String(128))
amount_requested_sats: Mapped[int] = mapped_column(BigInteger)
amount_sent_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
txid: Mapped[str | None] = mapped_column(String(64), default=None)
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[str] = mapped_column(String(32))
payload_json: Mapped[str] = mapped_column(String)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
created_at: Mapped[datetime] = mapped_column(default=utcnow)