remove legacy systems

This commit is contained in:
SowinskiBraeden committed 2026-06-28 10:51:59 -07:00
1 parent 9e6f5bd3ef
commit 79c638b8fa
12 files changed
+178 -1175

No files matched your search

+41 -70
View File
@@ -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 - account signup, login, and email verification
- per-session pages - league creation, invitations, ownership transfer, and member roles
- per-player stat pages - player, season, session, and ledger management per league
- admin login for recording events - append-only ledger events with void/correction support
- open / closed session tracking - public/private league visibility
- actual cash payout tracking with `paid_out` events - leaderboards, charts, dashboard stats, and session summaries
- debt repayments and write-offs with `debt_repayment` / `writeoff` events - CSV import/export for league ledgers
- session and player charts
- CSV import / export from the admin page
- append-only `entries.csv` ledger instead of overwriting old rows
## Stack ## Stack
Built with a pretty lightweight setup:
- Python - Python
- Flask - Flask
- SQLAlchemy / Flask-Migrate
- Jinja templates - Jinja templates
- Chart.js - Chart.js
- plain CSS - plain CSS
- CSV event log for storage
## Ledger model ## Ledger Model
The app treats `data/entries.csv` as the source of truth. Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable.
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.
Current event types: Current event types:
@@ -49,17 +42,7 @@ Current event types:
- `session_open` - `session_open`
- `session_close` - `session_close`
A few examples: Accounting keeps poker results separate from banker cashflow:
- 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:
- poker investment is `buyin + front + rollover_in` - poker investment is `buyin + front + rollover_in`
- poker net is `cashout - poker investment` - 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` - real cash out is `paid_out`
- `rollover_out` settles the source session without counting as cash 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 - `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 - `writeoff` resolves a receivable without counting as cash in
Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`. Older imports may still contain `paid`, `front_collected`, or `front_writeoff`; those are read as aliases for `paid_out`, `debt_repayment`, and `writeoff`.
The app reads those historical names 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. ## Running Locally
## 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
```bash ```bash
python -m venv .venv python -m venv .venv
@@ -102,31 +67,37 @@ python app.py
Then open: Then open:
`http://127.0.0.1:8000` `http://127.0.0.1:5000`
## Environment values ## Environment Values
The app reads these from `.env`: The app reads these from `.env`:
- `SECRET_KEY` - `SECRET_KEY`
- `ADMIN_USERNAME` - `DATABASE_URL`
- `ADMIN_PASSWORD` - `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 Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials.
SECRET_KEY=change-this
ADMIN_USERNAME=admin Grant access:
ADMIN_PASSWORD=change-me
```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 The old single-password `/admin` system has been removed.
- 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.
+41 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import click import click
from flask import Flask, render_template 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 config import Config
from db import database_extensions_available, db, init_database from db import database_extensions_available, db, init_database
from extensions import csrf, limiter, mail from extensions import csrf, limiter, mail
@@ -62,6 +62,46 @@ def create_app(config_overrides: dict | None = None) -> Flask:
click.echo("Initialized database tables.") 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 return app
+30 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from functools import wraps 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 itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from werkzeug.security import check_password_hash, generate_password_hash 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: def log_user_in(user_id: str) -> None:
flask_session.pop("is_admin", None)
flask_session["user_id"] = user_id flask_session["user_id"] = user_id
@@ -68,6 +67,22 @@ def is_logged_in() -> bool:
return current_user_id() is not None 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): def login_required(view):
@wraps(view) @wraps(view)
def wrapped_view(*args, **kwargs): def wrapped_view(*args, **kwargs):
@@ -77,3 +92,16 @@ def login_required(view):
return view(*args, **kwargs) return view(*args, **kwargs)
return wrapped_view 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
+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3 ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.5.23" APP_VERSION = "2.5.24"
def load_local_env(env_path: Path) -> None: def load_local_env(env_path: Path) -> None:
+1
View File
@@ -77,6 +77,7 @@ class User(TimestampMixin, db.Model):
email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True) email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True)
last_login_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) 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): 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
View File
@@ -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,
)
-436
View File
@@ -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') }}">&darr; Export CSV</a>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; 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 %}
-24
View File
@@ -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 %}
+1 -1
View File
@@ -131,7 +131,7 @@
{% else %} {% else %}
<tr> <tr>
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);"> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
+16 -47
View File
@@ -1,15 +1,12 @@
from pathlib import Path
import tempfile
import unittest import unittest
import app as app_module
from charts import player_session_series from charts import player_session_series
from services import ( from services import (
build_leaderboard, build_leaderboard,
build_session_summaries, build_session_summaries,
pending_payout_carry_items, pending_payout_carry_items,
prunable_empty_session_ids,
) )
from storage import load_events, write_events
from utils import session_sort_key from utils import session_sort_key
@@ -189,7 +186,7 @@ class AccountingTests(unittest.TestCase):
self.assertEqual(entry.paid_out_cents, 0) self.assertEqual(entry.paid_out_cents, 0)
self.assertEqual(entry.payout_status, "settled") 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 = [ rows = [
event("s1", "2026-01-01", "", "session_open", 0, 1), event("s1", "2026-01-01", "", "session_open", 0, 1),
event("s1", "2026-01-01", "A", "buyin", 10, 2), event("s1", "2026-01-01", "A", "buyin", 10, 2),
@@ -200,32 +197,14 @@ class AccountingTests(unittest.TestCase):
event("s2", "2026-01-02", "A", "buyin", 10, 7), event("s2", "2026-01-02", "A", "buyin", 10, 7),
] ]
with tempfile.TemporaryDirectory() as tmpdir: sessions = build_session_summaries(rows)
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
try:
sessions = build_session_summaries(load_events(data_path))
self.assertEqual( self.assertEqual(
pending_payout_carry_items(sessions), pending_payout_carry_items(sessions),
[{"player_name": "A", "amount_cents": cents(5)}], [{"player_name": "A", "amount_cents": cents(5)}],
) )
with app_module.app.test_client() as client: rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8))
with client.session_transaction() as flask_session: updated_sessions = build_session_summaries(rows)
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 = next(s for s in updated_sessions if s.session_id == "s2")
target_entry = target.entries[0] target_entry = target.entries[0]
self.assertEqual(target_entry.payout_carry_in_cents, cents(5)) self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
@@ -248,7 +227,7 @@ class AccountingTests(unittest.TestCase):
["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"], ["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 = [ rows = [
event("empty-1", "2026-01-01", "", "session_open", 0, 1), event("empty-1", "2026-01-01", "", "session_open", 0, 1),
event("empty-1", "2026-01-01", "", "session_close", 0, 2), event("empty-1", "2026-01-01", "", "session_close", 0, 2),
@@ -258,27 +237,17 @@ class AccountingTests(unittest.TestCase):
event("played-1", "2026-01-02", "", "session_close", 0, 6), event("played-1", "2026-01-02", "", "session_close", 0, 6),
] ]
with tempfile.TemporaryDirectory() as tmpdir: sessions = build_session_summaries(rows)
data_path = Path(tmpdir) / "entries.csv" prunable_ids = prunable_empty_session_ids(rows, sessions)
write_events(data_path, rows) self.assertEqual(prunable_ids, {"empty-1"})
original_path = app_module.app.config["DATA_PATH"]
app_module.app.config["DATA_PATH"] = data_path
try: remaining = [
with app_module.app.test_client() as client: row
with client.session_transaction() as flask_session: for row in rows
flask_session["is_admin"] = True if (row["session_id"].strip() or row["session_date"].strip())
not in prunable_ids
response = client.post("/admin/prune-empty-sessions") ]
finally: self.assertEqual({row["session_id"] for row in remaining}, {"played-1"})
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( self.assertEqual(
[row["event_type"] for row in remaining], [row["event_type"] for row in remaining],
["session_open", "buyin", "cashout", "session_close"], ["session_open", "buyin", "cashout", "session_close"],
+11 -9
View File
@@ -1,10 +1,11 @@
import unittest import unittest
from stats import SessionEntry, SessionSummary, build_leaderboard from models import SessionEntry, SessionSummary
from services import build_leaderboard
class CashInAccountingTest(unittest.TestCase): 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( entry = SessionEntry(
session_id="2026-06-21-01", session_id="2026-06-21-01",
session_date="2026-06-21", 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_buy_in_cents, 2500)
self.assertEqual(session.total_front_collected_cents, 0) self.assertEqual(session.total_front_cents, 500)
self.assertEqual(session.total_cash_in_cents, 3000) 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( entry = SessionEntry(
session_id="2026-06-21-01", session_id="2026-06-21-01",
session_date="2026-06-21", 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_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): def test_written_off_front_does_not_count_as_session_cash_in(self):
entry = SessionEntry( entry = SessionEntry(
@@ -58,7 +59,7 @@ class CashInAccountingTest(unittest.TestCase):
self.assertEqual(session.total_front_writeoff_cents, 1000) self.assertEqual(session.total_front_writeoff_cents, 1000)
self.assertEqual(session.total_cash_in_cents, 0) 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 = SessionSummary(
session_id="2026-06-21-01", session_id="2026-06-21-01",
session_date="2026-06-21", session_date="2026-06-21",
@@ -69,6 +70,7 @@ class CashInAccountingTest(unittest.TestCase):
player_name="Alex", player_name="Alex",
buy_in_cents=2500, buy_in_cents=2500,
front_cents=500, front_cents=500,
front_collected_cents=200,
cash_out_cents=4000, cash_out_cents=4000,
) )
], ],
@@ -77,8 +79,8 @@ class CashInAccountingTest(unittest.TestCase):
player = build_leaderboard([session])[0] player = build_leaderboard([session])[0]
self.assertEqual(player.total_buy_in_cents, 2500) self.assertEqual(player.total_buy_in_cents, 2500)
self.assertEqual(player.total_front_collected_cents, 0) self.assertEqual(player.total_front_collected_cents, 200)
self.assertEqual(player.total_cash_in_cents, 3000) self.assertEqual(player.total_real_cash_in_cents, 2700)
if __name__ == "__main__": if __name__ == "__main__":