diff --git a/README.md b/README.md index 08b2b71..bdeba2e 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,31 @@ -# Poker Portal +# myboker.org -A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time. +Flask app for running home-poker leagues without keeping a spreadsheet open all night. -The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later. +The current app is account-based and league-based. League owners/managers record sessions and ledger events, viewers can inspect public-facing league data, and site-admin access is reserved for internal tooling. -## What it does +## What It Does -- all-time leaderboard -- per-session pages -- per-player stat pages -- admin login for recording events -- open / closed session tracking -- actual cash payout tracking with `paid_out` events -- debt repayments and write-offs with `debt_repayment` / `writeoff` events -- session and player charts -- CSV import / export from the admin page -- append-only `entries.csv` ledger instead of overwriting old rows +- account signup, login, and email verification +- league creation, invitations, ownership transfer, and member roles +- player, season, session, and ledger management per league +- append-only ledger events with void/correction support +- public/private league visibility +- leaderboards, charts, dashboard stats, and session summaries +- CSV import/export for league ledgers ## Stack -Built with a pretty lightweight setup: - - Python - Flask +- SQLAlchemy / Flask-Migrate - Jinja templates - Chart.js - plain CSS -- CSV event log for storage -## Ledger model +## Ledger Model -The app treats `data/entries.csv` as the source of truth. - -Each row is an event, not a final snapshot. Instead of editing an old row, I append another one. That keeps rebuys, corrections, payouts, and session state changes visible in the log instead of hiding them behind edits. +Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable. Current event types: @@ -49,17 +42,7 @@ Current event types: - `session_open` - `session_close` -A few examples: - -- another `buyin` for a rebuy -- another `cashout` if chip counts are corrected later -- a `paid_out` event when someone is actually settled up -- a `debt_repayment` event when a front is repaid outside poker -- a `writeoff` event when a front will not be collected -- a `note` event for bookkeeping context -- `session_open` / `session_close` to mark whether a game night is still live - -Accounting in the app keeps poker results separate from banker cashflow: +Accounting keeps poker results separate from banker cashflow: - poker investment is `buyin + front + rollover_in` - poker net is `cashout - poker investment` @@ -67,30 +50,12 @@ Accounting in the app keeps poker results separate from banker cashflow: - real cash out is `paid_out` - `rollover_out` settles the source session without counting as cash out - `rollover_in` enters play in the destination session without counting as cash in -- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in +- `payout_carry_in` records prior-session value carried into a later payout without counting as poker investment or cash in - `writeoff` resolves a receivable without counting as cash in -Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`. -The app reads those historical names as aliases for `paid_out`, -`debt_repayment`, and `writeoff`. +Older imports may still contain `paid`, `front_collected`, or `front_writeoff`; those are read as aliases for `paid_out`, `debt_repayment`, and `writeoff`. -It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through. - -## CSV format - -Main file: - -`data/entries.csv` - -Header: - -```csv -id,created_at,session_id,session_date,player_name,event_type,amount_cents,note,actor -``` - -Amounts are stored in cents to avoid floating-point issues. - -## Running it locally +## Running Locally ```bash python -m venv .venv @@ -102,31 +67,37 @@ python app.py Then open: -`http://127.0.0.1:8000` +`http://127.0.0.1:5000` -## Environment values +## Environment Values The app reads these from `.env`: - `SECRET_KEY` -- `ADMIN_USERNAME` -- `ADMIN_PASSWORD` +- `DATABASE_URL` +- `APP_BASE_URL` +- `MAIL_SERVER` +- `MAIL_PORT` +- `MAIL_USE_TLS` +- `MAIL_USE_SSL` +- `MAIL_USERNAME` +- `MAIL_PASSWORD` +- `MAIL_DEFAULT_SENDER` -Example: +## Site Admin Access -```env -SECRET_KEY=change-this -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me +Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials. + +Grant access: + +```bash +flask --app app grant-site-admin user@example.com ``` -## Notes +Revoke access: -A few choices here were deliberate: +```bash +flask --app app revoke-site-admin user@example.com +``` -- no database for now -- no user accounts, just one admin login -- public-facing stats pages, admin-only controls -- CSV backup before importing a replacement ledger - -If I ever decide to take it further, the first real upgrade would probably be moving the storage layer to SQLite while keeping the rest of the app roughly the same. +The old single-password `/admin` system has been removed. diff --git a/app.py b/app.py index 143fade..a660084 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ from __future__ import annotations import click from flask import Flask, render_template -from auth import current_user_id, is_logged_in +from auth import current_user_id, is_logged_in, normalize_email from config import Config from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail @@ -62,6 +62,46 @@ def create_app(config_overrides: dict | None = None) -> Flask: click.echo("Initialized database tables.") + @app.cli.command("grant-site-admin") + @click.argument("email") + def grant_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none() + if user is None: + raise click.ClickException(f"No active user found for {normalized_email}.") + user.is_site_admin = True + db.session.commit() + + click.echo(f"Granted site admin access to {normalized_email}.") + + @app.cli.command("revoke-site-admin") + @click.argument("email") + def revoke_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email).one_or_none() + if user is None: + raise click.ClickException(f"No user found for {normalized_email}.") + user.is_site_admin = False + db.session.commit() + + click.echo(f"Revoked site admin access from {normalized_email}.") + return app diff --git a/auth.py b/auth.py index c33f1f1..86be33b 100644 --- a/auth.py +++ b/auth.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import wraps -from flask import current_app, flash, redirect, request, session as flask_session, url_for +from flask import abort, current_app, flash, redirect, request, session as flask_session, url_for from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from werkzeug.security import check_password_hash, generate_password_hash @@ -51,7 +51,6 @@ def normalize_email(email: str) -> str: def log_user_in(user_id: str) -> None: - flask_session.pop("is_admin", None) flask_session["user_id"] = user_id @@ -68,6 +67,22 @@ def is_logged_in() -> bool: return current_user_id() is not None +def is_site_admin() -> bool: + user_id = current_user_id() + if not user_id: + return False + + from db import database_extensions_available + + if not database_extensions_available(): + return False + + from db_models import User + + user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none() + return bool(user and user.is_site_admin) + + def login_required(view): @wraps(view) def wrapped_view(*args, **kwargs): @@ -77,3 +92,16 @@ def login_required(view): return view(*args, **kwargs) return wrapped_view + + +def site_admin_required(view): + @wraps(view) + def wrapped_view(*args, **kwargs): + if not is_logged_in(): + flash("Login required.", "error") + return redirect(url_for("account.login", next=request.full_path)) + if not is_site_admin(): + abort(403) + return view(*args, **kwargs) + + return wrapped_view diff --git a/config.py b/config.py index f9233f3..5a4dfc4 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.23" +APP_VERSION = "2.5.24" def load_local_env(env_path: Path) -> None: diff --git a/db_models.py b/db_models.py index c360e8c..cf2c78a 100644 --- a/db_models.py +++ b/db_models.py @@ -77,6 +77,7 @@ class User(TimestampMixin, db.Model): email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True) last_login_at = db.Column(db.DateTime(timezone=True), nullable=True) disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) + is_site_admin = db.Column(db.Boolean, nullable=False, default=False) class League(TimestampMixin, db.Model): diff --git a/migrations/versions/0005_site_admin_accounts.py b/migrations/versions/0005_site_admin_accounts.py new file mode 100644 index 0000000..45c745c --- /dev/null +++ b/migrations/versions/0005_site_admin_accounts.py @@ -0,0 +1,36 @@ +"""add account-backed site admin flag + +Revision ID: 0005_site_admin_accounts +Revises: 0004_email_verification_codes +Create Date: 2026-06-28 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0005_site_admin_accounts" +down_revision = "0004_email_verification_codes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} + if "is_site_admin" not in columns: + op.add_column( + "users", + sa.Column( + "is_site_admin", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + + +def downgrade() -> None: + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} + if "is_site_admin" in columns: + op.drop_column("users", "is_site_admin") diff --git a/routes/admin.py b/routes/admin.py deleted file mode 100644 index f959d89..0000000 --- a/routes/admin.py +++ /dev/null @@ -1,584 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import csv -from datetime import datetime, timezone - -from flask import ( - Blueprint, - current_app, - flash, - redirect, - render_template, - request, - send_file, - session as flask_session, - url_for, -) - -from auth import is_admin -from services import ( - build_session_summaries, - next_session_id, - pending_payout_carry_items, - prunable_empty_session_ids, - unique_player_names, -) -from storage import CSV_HEADERS, append_event, load_events, write_events -from utils import cents_to_dollars, session_label - -admin_bp = Blueprint("admin", __name__) - -ADMIN_EVENT_TYPES = [ - ("buyin", "Buy-in", "var(--accent)"), - ("front", "Front", "var(--warn)"), - ("rollover_in", "Rollover-in", "var(--rolled)"), - ("payout_carry_in", "Payout carry-in", "var(--rolled)"), - ("cashout", "Cashout result", "var(--rank-2)"), - ("paid_out", "Paid out cash", "var(--pos)"), - ("rollover_out", "Rollover-out", "var(--rolled)"), - ("note", "Note", "var(--muted)"), -] - - -@admin_bp.route("/admin/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - - if ( - username == current_app.config["ADMIN_USERNAME"] - and password == current_app.config["ADMIN_PASSWORD"] - ): - flask_session.pop("user_id", None) - flask_session["is_admin"] = True - flash("Admin login successful.", "success") - return redirect(url_for("admin.dashboard")) - - flash("Invalid admin credentials.", "error") - - return render_template("admin_login.html") - - -@admin_bp.post("/admin/logout") -def logout(): - flask_session.clear() - flash("Logged out.", "success") - return redirect(url_for("public.home")) - - -@admin_bp.post("/admin/session-state") -def session_state(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - session_id = request.form.get("session_id", "").strip() - state = request.form.get("state", "").strip() - - events = load_events(data_path) - sessions = build_session_summaries(events) - by_id = {s.session_id: s for s in sessions} - target = by_id.get(session_id) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin.dashboard")) - - if state not in {"open", "closed"}: - flash("Invalid session state.", "error") - return redirect(url_for("admin.dashboard")) - - append_event( - data_path, - session_id=target.session_id, - session_date=target.session_date, - amount_cents=0, - player_name="", - event_type="session_open" if state == "open" else "session_close", - note=f"Session marked {state}.", - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash(f"Session marked {state}.", "success") - return redirect(url_for("admin.dashboard")) - - -@admin_bp.get("/admin/export") -@admin_bp.get("/admin/export.csv") -def export_csv(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - export_name = ( - f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" - ) - - return send_file( - data_path, - as_attachment=True, - download_name=export_name, - mimetype="text/csv", - ) - - -@admin_bp.post("/admin/import") -def import_csv(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - uploaded_file = request.files.get("csv_file") - if uploaded_file is None or uploaded_file.filename == "": - flash("Choose a CSV file to import.", "error") - return redirect(url_for("admin.dashboard")) - - if not uploaded_file.filename.lower().endswith(".csv"): - flash("Only CSV files are allowed.", "error") - return redirect(url_for("admin.dashboard")) - - try: - uploaded_text = uploaded_file.stream.read().decode("utf-8-sig") - except UnicodeDecodeError: - flash("CSV file must be valid UTF-8 text.", "error") - return redirect(url_for("admin.dashboard")) - - uploaded_lines = uploaded_text.splitlines() - if not uploaded_lines: - flash("CSV file is empty.", "error") - return redirect(url_for("admin.dashboard")) - - try: - uploaded_header = next(csv.reader(uploaded_lines)) - except Exception: - flash("Could not read the uploaded CSV header.", "error") - return redirect(url_for("admin.dashboard")) - - if uploaded_header != CSV_HEADERS: - flash("CSV header does not match the current audit format.", "error") - return redirect(url_for("admin.dashboard")) - - try: - existing_ids = {event["id"] for event in load_events(data_path)} - reader = csv.DictReader(uploaded_lines) - new_rows = [] - skipped_count = 0 - for row in reader: - event_id = row.get("id", "") - if not event_id or event_id in existing_ids: - skipped_count += 1 - continue - - new_rows.append({header: row.get(header, "") for header in CSV_HEADERS}) - existing_ids.add(event_id) - - if new_rows: - with open(data_path, "a", encoding="utf-8", newline="") as handle: - writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS) - writer.writerows(new_rows) - - flash( - f"Imported {len(new_rows)} new events, skipped {skipped_count} duplicates.", - "success", - ) - except Exception: - flash("Import failed. Existing CSV was not updated.", "error") - - return redirect(url_for("admin.dashboard")) - - -@admin_bp.post("/admin/write-off-front") -def write_off_front(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - debt_key = request.form.get("debt_key", "").strip() - note = request.form.get("note", "").strip() - amount_raw = request.form.get("amount", "0").strip() - - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin.dashboard")) - - if amount_cents <= 0: - flash("Write-off amount must be greater than zero.", "error") - return redirect(url_for("admin.dashboard")) - - try: - session_id, player_name = debt_key.split("||", 1) - except ValueError: - flash("Select a valid player debt.", "error") - return redirect(url_for("admin.dashboard")) - - events = load_events(data_path) - sessions = build_session_summaries(events) - target = next((s for s in sessions if s.session_id == session_id), None) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin.dashboard")) - - entry = next( - (e for e in target.entries if e.player_name == player_name), - None, - ) - - if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding debt.", "error") - return redirect(url_for("admin.dashboard")) - - if amount_cents > entry.player_owes_cents: - flash( - f"Write-off cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", - "error", - ) - return redirect(url_for("admin.dashboard")) - - append_event( - data_path, - session_id=target.session_id, - session_date=target.session_date, - player_name=entry.player_name, - event_type="writeoff", - amount_cents=amount_cents, - note=note or "Debt written off.", - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash("Debt written off.", "success") - return redirect(url_for("admin.dashboard")) - - -@admin_bp.post("/admin/collect-front") -def collect_front(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - debt_key = request.form.get("debt_key", "").strip() - note = request.form.get("note", "").strip() - amount_raw = request.form.get("amount", "0").strip() - - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin.dashboard")) - - if amount_cents <= 0: - flash("Collected amount must be greater than zero.", "error") - return redirect(url_for("admin.dashboard")) - - try: - session_id, player_name = debt_key.split("||", 1) - except ValueError: - flash("Select a valid player debt.", "error") - return redirect(url_for("admin.dashboard")) - - events = load_events(data_path) - sessions = build_session_summaries(events) - target = next((s for s in sessions if s.session_id == session_id), None) - - if target is None: - flash("Session not found.", "error") - return redirect(url_for("admin.dashboard")) - - entry = next( - (e for e in target.entries if e.player_name == player_name), - None, - ) - - if entry is None or entry.player_owes_cents <= 0: - flash("That player does not have an outstanding debt.", "error") - return redirect(url_for("admin.dashboard")) - - if amount_cents > entry.player_owes_cents: - flash( - f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.", - "error", - ) - return redirect(url_for("admin.dashboard")) - - append_event( - data_path, - session_id=target.session_id, - session_date=target.session_date, - player_name=entry.player_name, - event_type="debt_repayment", - amount_cents=amount_cents, - note=note or "Debt repayment collected.", - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash("Debt repayment collected.", "success") - return redirect(url_for("admin.dashboard")) - - -@admin_bp.post("/admin/open-session") -def open_session(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - session_date = request.form.get("session_date", "").strip() - if not session_date: - flash("Session date is required.", "error") - return redirect(url_for("admin.dashboard")) - - events = load_events(data_path) - sessions = build_session_summaries(events) - new_session_id = next_session_id(sessions, session_date) - - append_event( - data_path, - session_id=new_session_id, - session_date=session_date, - amount_cents=0, - player_name="", - event_type="session_open", - note="Session opened.", - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash(f"Opened session {new_session_id}.", "success") - return redirect(url_for("admin.dashboard")) - - -@admin_bp.post("/admin/prune-empty-sessions") -def prune_empty_sessions(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - events = load_events(data_path) - sessions = build_session_summaries(events) - prunable_ids = prunable_empty_session_ids(events, sessions) - - if not prunable_ids: - flash("No empty marker-only sessions to prune.", "success") - return redirect(url_for("admin.dashboard")) - - pruned_events = [ - event - for event in events - if (event["session_id"].strip() or event["session_date"].strip()) - not in prunable_ids - ] - write_events(data_path, pruned_events) - - flash( - f"Pruned {len(prunable_ids)} empty session" - f"{'s' if len(prunable_ids) != 1 else ''}.", - "success", - ) - return redirect(url_for("admin.dashboard")) - - -@admin_bp.post("/admin/apply-payout-carry-in") -def apply_payout_carry_in(): - if not is_admin(): - flash("Admin login required.", "error") - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - session_id = request.form.get("session_id", "").strip() - player_name = request.form.get("player_name", "").strip() - - events = load_events(data_path) - sessions = build_session_summaries(events) - by_id = {s.session_id: s for s in sessions} - target = by_id.get(session_id) - - if target is None or target.status != "open": - flash("Select a valid open session.", "error") - return redirect(url_for("admin.dashboard")) - - if not any(e.player_name == player_name for e in target.entries): - flash("Add that player to the open session before applying carry-in.", "error") - return redirect(url_for("admin.dashboard")) - - pending_by_player = { - str(item["player_name"]): int(item["amount_cents"]) - for item in pending_payout_carry_items(sessions) - } - amount_cents = pending_by_player.get(player_name, 0) - - if amount_cents <= 0: - flash("No pending payout carry-in found for that player.", "error") - return redirect(url_for("admin.dashboard")) - - append_event( - data_path, - session_id=target.session_id, - session_date=target.session_date, - player_name=player_name, - event_type="payout_carry_in", - amount_cents=amount_cents, - note="Payout carry-in from prior carry-out.", - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash( - f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.", - "success", - ) - return redirect(url_for("admin.dashboard")) - - -@admin_bp.route("/admin", methods=["GET", "POST"]) -def dashboard(): - if not is_admin(): - return redirect(url_for("admin.login")) - - data_path = current_app.config["DATA_PATH"] - - if request.method == "POST": - session_id = request.form.get("session_id", "").strip() - player_name = request.form.get("player_name", "").strip() - event_type = request.form.get("event_type", "").strip() - note = request.form.get("note", "").strip() - - amount_raw = request.form.get("amount", "0").strip() - try: - amount_cents = int(round(float(amount_raw) * 100)) - except ValueError: - flash("Amount must be a number.", "error") - return redirect(url_for("admin.dashboard")) - - events = load_events(data_path) - sessions = build_session_summaries(events) - by_id = {s.session_id: s for s in sessions} - target = by_id.get(session_id) - - if target is None: - flash("Select a valid open session.", "error") - return redirect(url_for("admin.dashboard")) - - if target.status != "open": - flash( - "That session is closed. Reopen it first if you need to add events.", - "error", - ) - return redirect(url_for("admin.dashboard")) - - append_event( - data_path, - session_id=target.session_id, - session_date=target.session_date, - player_name=player_name, - event_type=event_type, - amount_cents=amount_cents, - note=note, - actor=current_app.config["ADMIN_USERNAME"], - ) - - flash("Event added.", "success") - return redirect( - url_for( - "admin.dashboard", - session_id=session_id, - player_name=player_name, - event_type=event_type, - ) - ) - - events = load_events(data_path) - sessions = build_session_summaries(events) - empty_session_count = len(prunable_empty_session_ids(events, sessions)) - pending_carry_items = pending_payout_carry_items(sessions) - pending_carry_total_cents = sum( - int(item["amount_cents"]) for item in pending_carry_items - ) - open_sessions = [s for s in sessions if s.status == "open"] - live_session = open_sessions[0] if open_sessions else None - live_session_player_names = ( - {e.player_name for e in live_session.entries} - if live_session is not None - else set() - ) - live_session_no = "" - live_unresolved_count = 0 - if live_session is not None: - live_session_no = f"#{len(sessions):03d}" - live_unresolved_count = sum( - 1 - for e in live_session.entries - if e.current_due_cents > 0 or e.player_owes_cents > 0 - ) - - recent_events = list(reversed(events[-20:])) - event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES} - event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES} - selected_event_type = request.args.get("event_type", "buyin").strip() - if selected_event_type not in event_type_colors: - selected_event_type = "buyin" - append_form = { - "session_id": request.args.get("session_id", "").strip(), - "player_name": request.args.get("player_name", "").strip(), - "event_type": selected_event_type, - } - debt_entries = [ - {"session": s, "entry": e} - for s in sessions - for e in s.entries - if e.player_owes_cents > 0 - ] - - cash_in_cents = sum(s.total_banker_cash_in_cents for s in sessions) - cash_out_cents = sum(s.total_banker_cash_out_cents for s in sessions) - house_owes_players_cents = ( - sum(s.total_current_due_to_player_cents for s in sessions) - + pending_carry_total_cents - ) - players_owe_house_cents = sum( - s.total_current_due_to_house_cents for s in sessions - ) - admin_totals = { - "cash_in_cents": cash_in_cents, - "cash_out_cents": cash_out_cents, - "house_owes_players_cents": house_owes_players_cents, - "players_owe_house_cents": players_owe_house_cents, - "pending_carry_cents": pending_carry_total_cents, - "collected_cents": sum(s.total_debt_repayment_cents for s in sessions), - "written_off_cents": sum(s.total_writeoff_cents for s in sessions), - "net_book_position_cents": ( - cash_in_cents - - cash_out_cents - + players_owe_house_cents - - house_owes_players_cents - ), - } - - return render_template( - "admin_dashboard.html", - open_sessions=open_sessions, - recent_sessions=sessions, - recent_events=recent_events, - debt_entries=debt_entries, - live_session=live_session, - live_session_player_names=live_session_player_names, - live_session_no=live_session_no, - live_unresolved_count=live_unresolved_count, - pending_carry_items=pending_carry_items, - player_names=unique_player_names(events), - append_form=append_form, - event_types=ADMIN_EVENT_TYPES, - event_type_colors=event_type_colors, - event_type_labels=event_type_labels, - admin_today=datetime.now().date().isoformat(), - session_label=session_label, - admin_totals=admin_totals, - empty_session_count=empty_session_count, - ) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html deleted file mode 100644 index 975cd46..0000000 --- a/templates/admin_dashboard.html +++ /dev/null @@ -1,436 +0,0 @@ -{% extends "base.html" %} -{% block title %}Admin · myboker.org{% endblock %} -{% block page_class %}page--admin{% endblock %} -{% block content %} - -
Admin
- {% if open_sessions %} - {{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live - {% endif %} -Append events to the audit trail. Player ledger history stays permanent.
-No open player debts right now.
-| Session | -Players | -Cash in | -Cash paid out | -House owes players | -Players owe house | -Book position | -Status | -Action | -
|---|---|---|---|---|---|---|---|---|
| {{ session_label(s) }} | -{{ s.entries|length }} | -{{ s.total_banker_cash_in_cents | money }} | -{{ s.total_banker_cash_out_cents | money }} | -{{ s.total_current_due_to_player_cents | money }} | -{{ s.total_current_due_to_house_cents | money }} | -{{ s.total_net_book_position_cents | money }} | -{{ s.status }} | -
- |
-
| No sessions yet. | ||||||||
{{ event.amount_cents | money }}
-{{ event.session_date | pretty_date }}
-No recent events.
- {% endfor %} -This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.
-Restricted access
-Only myboker.org manager needs credentials. All stats pages stay public.
-