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
|
- 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.
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
{% 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 %}
|
||||||
|
|||||||
+29
-60
@@ -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,37 +197,19 @@ 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"
|
self.assertEqual(
|
||||||
write_events(data_path, rows)
|
pending_payout_carry_items(sessions),
|
||||||
original_path = app_module.app.config["DATA_PATH"]
|
[{"player_name": "A", "amount_cents": cents(5)}],
|
||||||
app_module.app.config["DATA_PATH"] = data_path
|
)
|
||||||
|
|
||||||
try:
|
rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8))
|
||||||
sessions = build_session_summaries(load_events(data_path))
|
updated_sessions = build_session_summaries(rows)
|
||||||
self.assertEqual(
|
target = next(s for s in updated_sessions if s.session_id == "s2")
|
||||||
pending_payout_carry_items(sessions),
|
target_entry = target.entries[0]
|
||||||
[{"player_name": "A", "amount_cents": cents(5)}],
|
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))
|
||||||
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))
|
|
||||||
|
|
||||||
def test_same_day_sessions_sort_by_session_sequence(self):
|
def test_same_day_sessions_sort_by_session_sequence(self):
|
||||||
rows = [
|
rows = [
|
||||||
@@ -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,31 +237,21 @@ 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(
|
||||||
|
[row["event_type"] for row in remaining],
|
||||||
self.assertEqual(response.status_code, 302)
|
["session_open", "buyin", "cashout", "session_close"],
|
||||||
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"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+11
-9
@@ -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__":
|
||||||
|
|||||||
Reference in new issue
Block a user