remove legacy systems
This commit is contained in:
12 files changed
+191
-1188
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
-584
@@ -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,
|
||||
)
|
||||
@@ -1,436 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin · myboker.org{% endblock %}
|
||||
{% block page_class %}page--admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="hero" style="padding-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
||||
<p class="hero__kicker" style="margin:0;">Admin</p>
|
||||
{% if open_sessions %}
|
||||
<span class="live-badge">{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="hero__title">Ledger controls</h1>
|
||||
<p class="hero__sub">Append events to the audit trail. Player ledger history stays permanent.</p>
|
||||
</div>
|
||||
|
||||
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
|
||||
affect receivables/payables through the session settlement formulas. -->
|
||||
<div class="cash-strip">
|
||||
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||
<span class="kicker">Cash in</span>
|
||||
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
|
||||
</div>
|
||||
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||
<span class="kicker">Cash paid out</span>
|
||||
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
|
||||
</div>
|
||||
<div class="stat stat--rail" style="--rail:var(--neg);">
|
||||
<span class="kicker">House owes players</span>
|
||||
<div class="stat__val {{ 'num-neg' if admin_totals.house_owes_players_cents > 0 else '' }}">{{ admin_totals.house_owes_players_cents | money }}</div>
|
||||
</div>
|
||||
<div class="stat stat--rail" style="--rail:var(--owes);">
|
||||
<span class="kicker">Players owe house</span>
|
||||
<div class="stat__val {{ 'num-neg' if admin_totals.players_owe_house_cents > 0 else '' }}">{{ admin_totals.players_owe_house_cents | money }}</div>
|
||||
</div>
|
||||
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if admin_totals.net_book_position_cents == 0 else 'var(--warn)' }};">
|
||||
<span class="kicker">Net book position</span>
|
||||
<div class="stat__val {% if admin_totals.net_book_position_cents == 0 %}num-muted{% elif admin_totals.net_book_position_cents > 0 %}num-pos{% else %}num-neg{% endif %}">
|
||||
{{ admin_totals.net_book_position_cents | money }}
|
||||
</div>
|
||||
<div class="stat__sub">cash in - cash paid out + receivables - payables</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin workspace -->
|
||||
<div class="admin-workspace">
|
||||
|
||||
<form class="panel append" method="post" action="{{ url_for('admin.dashboard') }}">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Append ledger event</h2>
|
||||
<span class="panel__tag">fast entry</span>
|
||||
</div>
|
||||
<div class="append__body">
|
||||
<div class="append__grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Session</span>
|
||||
<select class="control" name="session_id" required>
|
||||
<option value="">Select session</option>
|
||||
{% for s in open_sessions %}
|
||||
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
|
||||
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Player</span>
|
||||
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
|
||||
<datalist id="player_names_list">
|
||||
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Event type</span>
|
||||
<select class="control" name="event_type" required id="eventTypeSelect">
|
||||
{% for value, label, color in event_types %}
|
||||
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Amount</span>
|
||||
<span class="control control--money">
|
||||
<span class="control__prefix">$</span>
|
||||
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field append__note">
|
||||
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
|
||||
<textarea class="control append__textarea" name="note" placeholder="e.g. fronted buy-in, settle next week"></textarea>
|
||||
</label>
|
||||
<div class="append__footer">
|
||||
<button type="submit" class="btn btn--primary">Append event</button>
|
||||
<span class="append__preview">Will record as
|
||||
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% if pending_carry_items %}
|
||||
<div class="carry-recs">
|
||||
<div class="micro">Recommended carry-ins</div>
|
||||
{% for item in pending_carry_items %}
|
||||
<div class="carry-rec">
|
||||
<span>{{ item.player_name }}</span>
|
||||
<strong>{{ item.amount_cents | money }}</strong>
|
||||
{% if live_session and item.player_name in live_session_player_names %}
|
||||
<form method="post" action="{{ url_for('admin.apply_payout_carry_in') }}">
|
||||
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
|
||||
<input type="hidden" name="player_name" value="{{ item.player_name }}">
|
||||
<button class="table-action-button" type="submit">Apply</button>
|
||||
</form>
|
||||
{% elif live_session %}
|
||||
<em>Add buy-in first</em>
|
||||
{% else %}
|
||||
<em>Open session</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel panel--pad session-control-panel">
|
||||
<h2 class="panel__title panel__title--mb">Session controls</h2>
|
||||
<div class="session-summary-strip">
|
||||
<div>
|
||||
<span class="micro">Today</span>
|
||||
<strong>{{ admin_today }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="micro">Empty</span>
|
||||
<strong>{{ empty_session_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if live_session %}
|
||||
<div class="livecard">
|
||||
<div class="livecard__head">
|
||||
<span class="dot dot--live"></span>
|
||||
<span class="livecard__name">{{ session_label(live_session) }}</span>
|
||||
<span class="livecard__no">{{ live_session_no }}</span>
|
||||
</div>
|
||||
<div class="livecard__metrics">
|
||||
<div><div class="micro">Invested</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
|
||||
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
|
||||
<div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('admin.session_state') }}"
|
||||
{% if live_unresolved_count > 0 %}data-close-warning="true"{% endif %}>
|
||||
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
|
||||
<button class="btn-close" type="submit" name="state" value="closed">Close session</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="livecard livecard--empty">
|
||||
<div class="livecard__head">
|
||||
<span class="dot dot--idle"></span>
|
||||
<span class="livecard__name">No open session</span>
|
||||
<span class="livecard__no">idle</span>
|
||||
</div>
|
||||
<div class="livecard__metrics">
|
||||
<div><div class="micro">Invested</div><div class="mono16">{{ 0 | money }}</div></div>
|
||||
<div><div class="micro">Seated</div><div class="mono16">0</div></div>
|
||||
<div><div class="micro">Open</div><div class="mono16">0</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('admin.open_session') }}">
|
||||
<input type="hidden" name="session_date" value="{{ admin_today }}">
|
||||
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ url_for('admin.prune_empty_sessions') }}" class="prune-session-form">
|
||||
<button class="secondary-button" type="submit" {% if empty_session_count == 0 %}disabled{% endif %}>
|
||||
Prune empty sessions{% if empty_session_count > 0 %} ({{ empty_session_count }}){% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel panel--pad import-export-panel">
|
||||
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
|
||||
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
|
||||
<form method="post" action="{{ url_for('admin.import_csv') }}" enctype="multipart/form-data" class="import-form">
|
||||
<div class="csv__btns">
|
||||
<a class="csv-btn-ghost" href="{{ url_for('admin.export_csv') }}">↓ Export CSV</a>
|
||||
<label class="csv-btn-ghost" for="csvTopImport">↑ Import CSV</label>
|
||||
</div>
|
||||
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
|
||||
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div><!-- /.admin-workspace -->
|
||||
|
||||
<!-- Debt resolution -->
|
||||
<div class="panel debt-panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<span class="kicker" style="margin:0;">Front receivables</span>
|
||||
<h2 class="panel__title">Debt resolution</h2>
|
||||
</div>
|
||||
<div class="debt-summary">
|
||||
<span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
|
||||
<span class="badge badge--paid">Cash repaid {{ admin_totals.collected_cents | money }}</span>
|
||||
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if debt_entries %}
|
||||
<div class="debt-layout">
|
||||
<div class="debt-list">
|
||||
{% for item in debt_entries %}
|
||||
{% set s = item.session %}{% set e = item.entry %}
|
||||
<div class="debt-list-item">
|
||||
<div>
|
||||
<strong>{{ e.player_name }}</strong>
|
||||
<span>{{ session_label(s) }}</span>
|
||||
</div>
|
||||
<b>{{ e.player_owes_cents | money }}</b>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="debt-action-grid">
|
||||
<form class="form-card debt-form" method="post" action="{{ url_for('admin.collect_front') }}">
|
||||
<h3>Collect debt</h3>
|
||||
<label>
|
||||
<span>Outstanding debt</span>
|
||||
<select name="debt_key" required>
|
||||
<option value="">Select debt</option>
|
||||
{% for item in debt_entries %}
|
||||
{% set s = item.session %}{% set e = item.entry %}
|
||||
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
||||
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount collected</span>
|
||||
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Note</span>
|
||||
<textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Mark collected</button>
|
||||
</form>
|
||||
|
||||
<form class="form-card debt-form" method="post" action="{{ url_for('admin.write_off_front') }}">
|
||||
<h3>Write off debt</h3>
|
||||
<label>
|
||||
<span>Outstanding debt</span>
|
||||
<select name="debt_key" required>
|
||||
<option value="">Select debt</option>
|
||||
{% for item in debt_entries %}
|
||||
{% set s = item.session %}{% set e = item.entry %}
|
||||
<option value="{{ s.session_id }}||{{ e.player_name }}">
|
||||
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount to write off</span>
|
||||
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Note</span>
|
||||
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
|
||||
</label>
|
||||
<button class="secondary-button" type="submit">Write off debt</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="debt-empty">
|
||||
<div class="debt-empty__mark">$0</div>
|
||||
<p>No open player debts right now.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="admin-bottom-grid">
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<span class="kicker" style="margin:0;">All sessions</span>
|
||||
<h2 class="panel__title">Current totals</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Players</th>
|
||||
<th>Cash in</th>
|
||||
<th>Cash paid out</th>
|
||||
<th>House owes players</th>
|
||||
<th>Players owe house</th>
|
||||
<th>Book position</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_sessions %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('public.session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
|
||||
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
|
||||
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_banker_cash_in_cents | money }}</td>
|
||||
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_banker_cash_out_cents | money }}</td>
|
||||
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
|
||||
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
|
||||
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
|
||||
<td><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.session_state') }}" class="inline-action-form">
|
||||
<input type="hidden" name="session_id" value="{{ s.session_id }}">
|
||||
{% if s.status == "open" %}
|
||||
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
|
||||
{% else %}
|
||||
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">No sessions yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<span class="kicker" style="margin:0;">Recent events</span>
|
||||
<h2 class="panel__title">Ledger feed</h2>
|
||||
</div>
|
||||
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
|
||||
</div>
|
||||
<div class="event-feed feed feed--inline">
|
||||
{% for event in recent_events %}
|
||||
<div class="event-card event-card--inline">
|
||||
<div class="event-card-top">
|
||||
<strong>{{ event.player_name or "Session" }}</strong>
|
||||
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
|
||||
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
||||
</div>
|
||||
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||
<p class="tiny-text">{{ event.session_date | pretty_date }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted-text" style="padding:20px;">No recent events.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="closeSessionModal" hidden>
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="closeSessionModalTitle">
|
||||
<div class="modal-card__head">
|
||||
<span class="kicker" style="margin:0;">Open settlement</span>
|
||||
<h2 class="panel__title" id="closeSessionModalTitle">Close session?</h2>
|
||||
</div>
|
||||
<p class="modal-card__text">This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.</p>
|
||||
<div class="modal-card__actions">
|
||||
<button class="secondary-button" type="button" id="cancelCloseSession">Keep open</button>
|
||||
<button class="btn btn--primary" type="button" id="confirmCloseSession">Close session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||
const eventTypePreview = document.getElementById('eventTypePreview');
|
||||
eventTypeSelect?.addEventListener('change', () => {
|
||||
const option = eventTypeSelect.selectedOptions[0];
|
||||
if (!option || !eventTypePreview) return;
|
||||
eventTypePreview.style.color = option.dataset.color || 'var(--muted)';
|
||||
eventTypePreview.textContent = (option.dataset.label || option.value).toUpperCase();
|
||||
});
|
||||
|
||||
const csvDrop = document.getElementById('csvDrop');
|
||||
const csvInput = document.getElementById('csvTopImport');
|
||||
csvDrop?.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
csvDrop.classList.add('is-drag');
|
||||
});
|
||||
csvDrop?.addEventListener('dragleave', () => csvDrop.classList.remove('is-drag'));
|
||||
csvDrop?.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
csvDrop.classList.remove('is-drag');
|
||||
if (!csvInput || !event.dataTransfer?.files?.length) return;
|
||||
csvInput.files = event.dataTransfer.files;
|
||||
csvInput.form?.submit();
|
||||
});
|
||||
|
||||
const closeSessionModal = document.getElementById('closeSessionModal');
|
||||
const cancelCloseSession = document.getElementById('cancelCloseSession');
|
||||
const confirmCloseSession = document.getElementById('confirmCloseSession');
|
||||
let pendingCloseForm = null;
|
||||
|
||||
document.querySelectorAll('form[data-close-warning="true"]').forEach((form) => {
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (form.dataset.confirmed === 'true') return;
|
||||
event.preventDefault();
|
||||
pendingCloseForm = form;
|
||||
closeSessionModal.hidden = false;
|
||||
confirmCloseSession?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
cancelCloseSession?.addEventListener('click', () => {
|
||||
closeSessionModal.hidden = true;
|
||||
pendingCloseForm = null;
|
||||
});
|
||||
|
||||
closeSessionModal?.addEventListener('click', (event) => {
|
||||
if (event.target !== closeSessionModal) return;
|
||||
closeSessionModal.hidden = true;
|
||||
pendingCloseForm = null;
|
||||
});
|
||||
|
||||
confirmCloseSession?.addEventListener('click', () => {
|
||||
if (!pendingCloseForm) return;
|
||||
pendingCloseForm.dataset.confirmed = 'true';
|
||||
pendingCloseForm.submit();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin Login · myboker.org{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="form-shell narrow">
|
||||
<form class="panel form-card" method="post">
|
||||
<div>
|
||||
<p class="eyebrow">Restricted access</p>
|
||||
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1>
|
||||
<p class="muted-text" style="margin-bottom:8px;">Only myboker.org manager needs credentials. All stats pages stay public.</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -131,7 +131,7 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);">
|
||||
No players yet — add the first event from the admin page.
|
||||
No players yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
+29
-60
@@ -1,15 +1,12 @@
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import app as app_module
|
||||
from charts import player_session_series
|
||||
from services import (
|
||||
build_leaderboard,
|
||||
build_session_summaries,
|
||||
pending_payout_carry_items,
|
||||
prunable_empty_session_ids,
|
||||
)
|
||||
from storage import load_events, write_events
|
||||
from utils import session_sort_key
|
||||
|
||||
|
||||
@@ -189,7 +186,7 @@ class AccountingTests(unittest.TestCase):
|
||||
self.assertEqual(entry.paid_out_cents, 0)
|
||||
self.assertEqual(entry.payout_status, "settled")
|
||||
|
||||
def test_admin_can_apply_pending_payout_carry_in_to_open_session(self):
|
||||
def test_pending_payout_carry_in_preserves_poker_investment(self):
|
||||
rows = [
|
||||
event("s1", "2026-01-01", "", "session_open", 0, 1),
|
||||
event("s1", "2026-01-01", "A", "buyin", 10, 2),
|
||||
@@ -200,37 +197,19 @@ class AccountingTests(unittest.TestCase):
|
||||
event("s2", "2026-01-02", "A", "buyin", 10, 7),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
data_path = Path(tmpdir) / "entries.csv"
|
||||
write_events(data_path, rows)
|
||||
original_path = app_module.app.config["DATA_PATH"]
|
||||
app_module.app.config["DATA_PATH"] = data_path
|
||||
sessions = build_session_summaries(rows)
|
||||
self.assertEqual(
|
||||
pending_payout_carry_items(sessions),
|
||||
[{"player_name": "A", "amount_cents": cents(5)}],
|
||||
)
|
||||
|
||||
try:
|
||||
sessions = build_session_summaries(load_events(data_path))
|
||||
self.assertEqual(
|
||||
pending_payout_carry_items(sessions),
|
||||
[{"player_name": "A", "amount_cents": cents(5)}],
|
||||
)
|
||||
|
||||
with app_module.app.test_client() as client:
|
||||
with client.session_transaction() as flask_session:
|
||||
flask_session["is_admin"] = True
|
||||
|
||||
response = client.post(
|
||||
"/admin/apply-payout-carry-in",
|
||||
data={"session_id": "s2", "player_name": "A"},
|
||||
)
|
||||
finally:
|
||||
app_module.app.config["DATA_PATH"] = original_path
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
updated_sessions = build_session_summaries(load_events(data_path))
|
||||
target = next(s for s in updated_sessions if s.session_id == "s2")
|
||||
target_entry = target.entries[0]
|
||||
self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
|
||||
self.assertEqual(target_entry.invested_cents, cents(10))
|
||||
self.assertEqual(target_entry.current_due_to_player_cents, cents(5))
|
||||
rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8))
|
||||
updated_sessions = build_session_summaries(rows)
|
||||
target = next(s for s in updated_sessions if s.session_id == "s2")
|
||||
target_entry = target.entries[0]
|
||||
self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
|
||||
self.assertEqual(target_entry.invested_cents, cents(10))
|
||||
self.assertEqual(target_entry.current_due_to_player_cents, cents(5))
|
||||
|
||||
def test_same_day_sessions_sort_by_session_sequence(self):
|
||||
rows = [
|
||||
@@ -248,7 +227,7 @@ class AccountingTests(unittest.TestCase):
|
||||
["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"],
|
||||
)
|
||||
|
||||
def test_admin_prunes_only_empty_marker_sessions(self):
|
||||
def test_prunable_empty_sessions_excludes_played_sessions(self):
|
||||
rows = [
|
||||
event("empty-1", "2026-01-01", "", "session_open", 0, 1),
|
||||
event("empty-1", "2026-01-01", "", "session_close", 0, 2),
|
||||
@@ -258,31 +237,21 @@ class AccountingTests(unittest.TestCase):
|
||||
event("played-1", "2026-01-02", "", "session_close", 0, 6),
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
data_path = Path(tmpdir) / "entries.csv"
|
||||
write_events(data_path, rows)
|
||||
original_path = app_module.app.config["DATA_PATH"]
|
||||
app_module.app.config["DATA_PATH"] = data_path
|
||||
sessions = build_session_summaries(rows)
|
||||
prunable_ids = prunable_empty_session_ids(rows, sessions)
|
||||
self.assertEqual(prunable_ids, {"empty-1"})
|
||||
|
||||
try:
|
||||
with app_module.app.test_client() as client:
|
||||
with client.session_transaction() as flask_session:
|
||||
flask_session["is_admin"] = True
|
||||
|
||||
response = client.post("/admin/prune-empty-sessions")
|
||||
finally:
|
||||
app_module.app.config["DATA_PATH"] = original_path
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
remaining = load_events(data_path)
|
||||
self.assertEqual(
|
||||
{row["session_id"] for row in remaining},
|
||||
{"played-1"},
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["event_type"] for row in remaining],
|
||||
["session_open", "buyin", "cashout", "session_close"],
|
||||
)
|
||||
remaining = [
|
||||
row
|
||||
for row in rows
|
||||
if (row["session_id"].strip() or row["session_date"].strip())
|
||||
not in prunable_ids
|
||||
]
|
||||
self.assertEqual({row["session_id"] for row in remaining}, {"played-1"})
|
||||
self.assertEqual(
|
||||
[row["event_type"] for row in remaining],
|
||||
["session_open", "buyin", "cashout", "session_close"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+11
-9
@@ -1,10 +1,11 @@
|
||||
import unittest
|
||||
|
||||
from stats import SessionEntry, SessionSummary, build_leaderboard
|
||||
from models import SessionEntry, SessionSummary
|
||||
from services import build_leaderboard
|
||||
|
||||
|
||||
class CashInAccountingTest(unittest.TestCase):
|
||||
def test_front_recovered_from_cashout_counts_as_session_cash_in(self):
|
||||
def test_front_is_poker_investment_not_cash_in(self):
|
||||
entry = SessionEntry(
|
||||
session_id="2026-06-21-01",
|
||||
session_date="2026-06-21",
|
||||
@@ -20,10 +21,10 @@ class CashInAccountingTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(session.total_buy_in_cents, 2500)
|
||||
self.assertEqual(session.total_front_collected_cents, 0)
|
||||
self.assertEqual(session.total_cash_in_cents, 3000)
|
||||
self.assertEqual(session.total_front_cents, 500)
|
||||
self.assertEqual(session.total_cash_in_cents, 2500)
|
||||
|
||||
def test_collected_front_shortfall_counts_as_session_cash_in(self):
|
||||
def test_debt_repayment_counts_as_session_cash_in(self):
|
||||
entry = SessionEntry(
|
||||
session_id="2026-06-21-01",
|
||||
session_date="2026-06-21",
|
||||
@@ -39,7 +40,7 @@ class CashInAccountingTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(session.total_front_collected_cents, 600)
|
||||
self.assertEqual(session.total_cash_in_cents, 1000)
|
||||
self.assertEqual(session.total_cash_in_cents, 600)
|
||||
|
||||
def test_written_off_front_does_not_count_as_session_cash_in(self):
|
||||
entry = SessionEntry(
|
||||
@@ -58,7 +59,7 @@ class CashInAccountingTest(unittest.TestCase):
|
||||
self.assertEqual(session.total_front_writeoff_cents, 1000)
|
||||
self.assertEqual(session.total_cash_in_cents, 0)
|
||||
|
||||
def test_leaderboard_cash_in_includes_collected_fronts(self):
|
||||
def test_leaderboard_cash_in_uses_real_cash_only(self):
|
||||
session = SessionSummary(
|
||||
session_id="2026-06-21-01",
|
||||
session_date="2026-06-21",
|
||||
@@ -69,6 +70,7 @@ class CashInAccountingTest(unittest.TestCase):
|
||||
player_name="Alex",
|
||||
buy_in_cents=2500,
|
||||
front_cents=500,
|
||||
front_collected_cents=200,
|
||||
cash_out_cents=4000,
|
||||
)
|
||||
],
|
||||
@@ -77,8 +79,8 @@ class CashInAccountingTest(unittest.TestCase):
|
||||
player = build_leaderboard([session])[0]
|
||||
|
||||
self.assertEqual(player.total_buy_in_cents, 2500)
|
||||
self.assertEqual(player.total_front_collected_cents, 0)
|
||||
self.assertEqual(player.total_cash_in_cents, 3000)
|
||||
self.assertEqual(player.total_front_collected_cents, 200)
|
||||
self.assertEqual(player.total_real_cash_in_cents, 2700)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user