87 files changed
+16791
-1388
No files matched your search
+9
-3
@@ -1,3 +1,9 @@
|
|||||||
SECRET_KEY=replace-this-with-a-long-random-string
|
SECRET_KEY=local-dev-secret-change-me
|
||||||
ADMIN_USERNAME=admin
|
APP_ENV=development
|
||||||
ADMIN_PASSWORD=replace-this-password
|
APP_BASE_URL=http://localhost:5000
|
||||||
|
MAIL_SERVER=
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=true
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
MAIL_DEFAULT_SENDER=noreply@myboker.org
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
SECRET_KEY=replace-this-with-a-long-random-string
|
||||||
|
APP_ENV=production
|
||||||
|
DATABASE_URL=postgresql+psycopg://user:password@host/dbname
|
||||||
|
APP_BASE_URL=https://myboker.org
|
||||||
|
RATELIMIT_STORAGE_URI=redis://localhost:6379/0
|
||||||
|
MAIL_SERVER=smtp.sendgrid.net
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=true
|
||||||
|
MAIL_USERNAME=apikey
|
||||||
|
MAIL_PASSWORD=your-sendgrid-api-key
|
||||||
|
MAIL_DEFAULT_SENDER=noreply@myboker.org
|
||||||
@@ -1,38 +1,39 @@
|
|||||||
# 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
|
## Project Layout
|
||||||
|
|
||||||
The app treats `data/entries.csv` as the source of truth.
|
- `app.py` is the deployment entrypoint for `flask --app app` and Gunicorn.
|
||||||
|
- `boker/` contains the Flask application package, routes, models, services, repositories, and config.
|
||||||
|
- `templates/` and `static/` contain Jinja views and public assets.
|
||||||
|
- `migrations/` contains Alembic migrations for database deploys.
|
||||||
|
- `tests/` contains the committed regression suite.
|
||||||
|
|
||||||
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.
|
## Ledger Model
|
||||||
|
|
||||||
|
Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable.
|
||||||
|
|
||||||
Current event types:
|
Current event types:
|
||||||
|
|
||||||
@@ -49,17 +50,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 +58,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 +75,49 @@ 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`. Use `.env.example` for local development and `.env.production.example` as the production checklist.
|
||||||
|
|
||||||
- `SECRET_KEY`
|
- `SECRET_KEY`
|
||||||
- `ADMIN_USERNAME`
|
- `APP_ENV`
|
||||||
- `ADMIN_PASSWORD`
|
- `DATABASE_URL`
|
||||||
|
- `APP_BASE_URL`
|
||||||
|
- `RATELIMIT_STORAGE_URI`
|
||||||
|
- `MAIL_SERVER`
|
||||||
|
- `MAIL_PORT`
|
||||||
|
- `MAIL_USE_TLS`
|
||||||
|
- `MAIL_USE_SSL`
|
||||||
|
- `MAIL_USERNAME`
|
||||||
|
- `MAIL_PASSWORD`
|
||||||
|
- `MAIL_DEFAULT_SENDER`
|
||||||
|
|
||||||
Example:
|
For local development, omit `DATABASE_URL` to use the default SQLite database at `data/boker-dev.sqlite3`.
|
||||||
|
|
||||||
```env
|
For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or any SQLite database. Use PostgreSQL for `DATABASE_URL`, install dependencies from `requirements.txt`, then run:
|
||||||
SECRET_KEY=change-this
|
|
||||||
ADMIN_USERNAME=admin
|
```bash
|
||||||
ADMIN_PASSWORD=change-me
|
flask --app app db upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes.
|
||||||
|
|
||||||
A few choices here were deliberate:
|
## Site Admin Access
|
||||||
|
|
||||||
- no database for now
|
Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials.
|
||||||
- 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.
|
Grant access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app grant-site-admin user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app revoke-site-admin user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The old single-password `/admin` system has been removed.
|
||||||
@@ -1,39 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Flask
|
from boker.app import app, create_app
|
||||||
|
|
||||||
from auth import is_admin
|
__all__ = ["app", "create_app"]
|
||||||
from config import Config
|
|
||||||
from routes.admin import admin_bp
|
|
||||||
from routes.public import public_bp
|
|
||||||
from storage import ensure_data_file
|
|
||||||
from utils import cents_to_dollars, safe_date_label
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config.from_object(Config)
|
|
||||||
|
|
||||||
ensure_data_file(app.config["DATA_PATH"])
|
|
||||||
|
|
||||||
app.jinja_env.filters["money"] = cents_to_dollars
|
|
||||||
app.jinja_env.filters["pretty_date"] = safe_date_label
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def inject_globals() -> dict:
|
|
||||||
return {
|
|
||||||
"app_version": app.config["APP_VERSION"],
|
|
||||||
"is_admin": is_admin(),
|
|
||||||
}
|
|
||||||
|
|
||||||
app.register_blueprint(public_bp)
|
|
||||||
app.register_blueprint(admin_bp)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Authentication helpers.
|
|
||||||
|
|
||||||
Kept in its own module so both app.py (context processor) and routes/admin.py
|
|
||||||
(route guards) can import is_admin() without circular imports.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flask import session as flask_session
|
|
||||||
|
|
||||||
|
|
||||||
def is_admin() -> bool:
|
|
||||||
return bool(flask_session.get("is_admin"))
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application package for myboker.org."""
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email
|
||||||
|
from boker.config import DEFAULT_DATABASE_URL, DEFAULT_SECRET_KEY, Config, ProductionConfig
|
||||||
|
from boker.db import database_extensions_available, db, init_database
|
||||||
|
from boker.extensions import csrf, limiter, mail
|
||||||
|
from boker.routes.account import account_bp
|
||||||
|
from boker.routes.internal import internal_bp
|
||||||
|
from boker.routes.leagues import leagues_bp
|
||||||
|
from boker.routes.public import public_bp
|
||||||
|
from boker.storage import ensure_data_file
|
||||||
|
from boker.utils import cents_to_dollars, safe_date_label
|
||||||
|
|
||||||
|
|
||||||
|
def _config_for_environment():
|
||||||
|
app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower()
|
||||||
|
if app_env in {"prod", "production"}:
|
||||||
|
return ProductionConfig
|
||||||
|
return Config
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_overrides: dict | None = None) -> Flask:
|
||||||
|
app = Flask(__name__, template_folder="../templates", static_folder="../static")
|
||||||
|
app.config.from_object(_config_for_environment())
|
||||||
|
if config_overrides:
|
||||||
|
app.config.update(config_overrides)
|
||||||
|
|
||||||
|
if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY:
|
||||||
|
raise RuntimeError("Set SECRET_KEY before running in production.")
|
||||||
|
if app.config["SESSION_COOKIE_SECURE"]:
|
||||||
|
database_url = app.config["SQLALCHEMY_DATABASE_URI"]
|
||||||
|
if database_url == DEFAULT_DATABASE_URL or database_url.startswith("sqlite:"):
|
||||||
|
raise RuntimeError("Set DATABASE_URL to a production PostgreSQL database before running in production.")
|
||||||
|
|
||||||
|
ensure_data_file(app.config["DATA_PATH"])
|
||||||
|
init_database(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
limiter.init_app(app)
|
||||||
|
mail.init_app(app)
|
||||||
|
|
||||||
|
app.jinja_env.filters["money"] = cents_to_dollars
|
||||||
|
app.jinja_env.filters["pretty_date"] = safe_date_label
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals() -> dict:
|
||||||
|
return {
|
||||||
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"current_user_id": current_user_id(),
|
||||||
|
"is_logged_in": is_logged_in(),
|
||||||
|
"current_user_is_site_admin": is_site_admin(),
|
||||||
|
}
|
||||||
|
|
||||||
|
app.register_blueprint(public_bp)
|
||||||
|
app.register_blueprint(account_bp)
|
||||||
|
app.register_blueprint(internal_bp)
|
||||||
|
app.register_blueprint(leagues_bp)
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return render_template("404.html"), 404
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden(e):
|
||||||
|
return render_template("403.html"), 403
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def unexpected_error(error):
|
||||||
|
if isinstance(error, HTTPException):
|
||||||
|
return error
|
||||||
|
|
||||||
|
crash_id = uuid4().hex[:12]
|
||||||
|
app.logger.exception(
|
||||||
|
"Unhandled exception [%s] during %s %s",
|
||||||
|
crash_id,
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
)
|
||||||
|
return render_template("500.html", crash_id=crash_id), 500
|
||||||
|
|
||||||
|
@app.cli.command("init-db")
|
||||||
|
def init_db_command() -> None:
|
||||||
|
if not database_extensions_available() or db is None:
|
||||||
|
raise click.ClickException(
|
||||||
|
"Database dependencies are not installed. Run pip install -r requirements.txt."
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
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 boker.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 boker.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
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import abort, current_app, flash, redirect, request, session as flask_session, url_for
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reset_token(user_id: str) -> str:
|
||||||
|
return _serializer().dumps(user_id, salt="password-reset")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_reset_token(token: str, max_age: int = 3600) -> str | None:
|
||||||
|
try:
|
||||||
|
return _serializer().loads(token, salt="password-reset", max_age=max_age)
|
||||||
|
except (SignatureExpired, BadSignature):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str:
|
||||||
|
return _serializer().dumps(
|
||||||
|
{"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id},
|
||||||
|
salt="league-invite",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_invite_token(token: str, max_age: int = 604800) -> dict | None:
|
||||||
|
try:
|
||||||
|
data = _serializer().loads(token, salt="league-invite", max_age=max_age)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except (SignatureExpired, BadSignature):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return generate_password_hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
return check_password_hash(password_hash, password)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_email(email: str) -> str:
|
||||||
|
return email.strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_in(user_id: str) -> None:
|
||||||
|
flask_session["user_id"] = user_id
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_out() -> None:
|
||||||
|
flask_session.pop("user_id", None)
|
||||||
|
|
||||||
|
|
||||||
|
def current_user_id() -> str | None:
|
||||||
|
user_id = flask_session.get("user_id")
|
||||||
|
return str(user_id) if user_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in() -> bool:
|
||||||
|
return current_user_id() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_site_admin() -> bool:
|
||||||
|
user_id = current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from boker.db import database_extensions_available
|
||||||
|
|
||||||
|
if not database_extensions_available():
|
||||||
|
return False
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none()
|
||||||
|
return bool(user and user.is_site_admin)
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
flash("Login required.", "error")
|
||||||
|
return redirect(url_for("account.login", next=request.full_path))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def site_admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
flash("Login required.", "error")
|
||||||
|
return redirect(url_for("account.login", next=request.full_path))
|
||||||
|
if not is_site_admin():
|
||||||
|
abort(403)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from models import SessionEntry, SessionSummary
|
from boker.models import SessionEntry, SessionSummary
|
||||||
from utils import net_result_bucket, session_chart_label, session_sort_key
|
from boker.utils import net_result_bucket, session_chart_label, session_sort_key
|
||||||
|
|
||||||
PLAYER_PALETTE = [
|
PLAYER_PALETTE = [
|
||||||
"#9b8cf0", # --line-1 violet
|
"#9b8cf0", # --line-1 violet
|
||||||
@@ -31,8 +31,8 @@ def color_for_name(name: str, names: list[str]) -> str:
|
|||||||
return PLAYER_PALETTE[index % len(PLAYER_PALETTE)]
|
return PLAYER_PALETTE[index % len(PLAYER_PALETTE)]
|
||||||
|
|
||||||
|
|
||||||
def net_tone(value_cents: int) -> str:
|
def net_tone(value_cents: int, break_even_cents: int = 100) -> str:
|
||||||
bucket = net_result_bucket(value_cents)
|
bucket = net_result_bucket(value_cents, break_even_cents)
|
||||||
|
|
||||||
if bucket == "win":
|
if bucket == "win":
|
||||||
return "#6fc093" # --pos
|
return "#6fc093" # --pos
|
||||||
@@ -96,7 +96,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
|
|||||||
|
|
||||||
|
|
||||||
def player_session_series(
|
def player_session_series(
|
||||||
sessions: list[SessionSummary], player_name: str
|
sessions: list[SessionSummary], player_name: str, break_even_cents: int = 100
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ordered_sessions = sorted(sessions, key=session_sort_key)
|
ordered_sessions = sorted(sessions, key=session_sort_key)
|
||||||
labels: list[str] = []
|
labels: list[str] = []
|
||||||
@@ -130,12 +130,12 @@ def player_session_series(
|
|||||||
"labels": labels,
|
"labels": labels,
|
||||||
"color": color_for_name(player_name, all_player_names),
|
"color": color_for_name(player_name, all_player_names),
|
||||||
"net_values": net_values,
|
"net_values": net_values,
|
||||||
"net_colors": [net_tone(round(value * 100)) for value in net_values],
|
"net_colors": [net_tone(round(value * 100), break_even_cents) for value in net_values],
|
||||||
"cumulative_values": cumulative_values,
|
"cumulative_values": cumulative_values,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def session_breakdown_series(session: SessionSummary) -> dict[str, Any]:
|
def session_breakdown_series(session: SessionSummary, break_even_cents: int = 100) -> dict[str, Any]:
|
||||||
ordered_entries = sorted(
|
ordered_entries = sorted(
|
||||||
session.entries,
|
session.entries,
|
||||||
key=lambda entry: (entry.net_cents, entry.player_name.casefold()),
|
key=lambda entry: (entry.net_cents, entry.player_name.casefold()),
|
||||||
@@ -145,5 +145,5 @@ def session_breakdown_series(session: SessionSummary) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"labels": [entry.player_name for entry in ordered_entries],
|
"labels": [entry.player_name for entry in ordered_entries],
|
||||||
"net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries],
|
"net_values": [round(entry.net_cents / 100, 2) for entry in ordered_entries],
|
||||||
"net_colors": [net_tone(entry.net_cents) for entry in ordered_entries],
|
"net_colors": [net_tone(entry.net_cents, break_even_cents) for entry in ordered_entries],
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
||||||
|
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
||||||
|
DEFAULT_SECRET_KEY = "change-this-before-deploying"
|
||||||
|
|
||||||
|
ELIGIBLE_MIN_SESSIONS = 3
|
||||||
|
APP_VERSION = "2.5.29"
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_env(env_path: Path) -> None:
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
load_local_env(BASE_DIR / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
APP_VERSION: str = APP_VERSION
|
||||||
|
SECRET_KEY: str = os.getenv("SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||||
|
SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
|
||||||
|
SESSION_COOKIE_HTTPONLY: bool = True
|
||||||
|
SESSION_COOKIE_SAMESITE: str = "Lax"
|
||||||
|
SESSION_COOKIE_NAME: str = "myboker_org_session"
|
||||||
|
DATA_PATH: Path = DATA_PATH
|
||||||
|
WTF_CSRF_TIME_LIMIT: int = 3600
|
||||||
|
APP_BASE_URL: str = os.getenv("APP_BASE_URL", "http://localhost:5000")
|
||||||
|
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "")
|
||||||
|
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||||
|
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes")
|
||||||
|
MAIL_USE_SSL: bool = os.getenv(
|
||||||
|
"MAIL_USE_SSL",
|
||||||
|
"true" if MAIL_PORT == 465 else "false",
|
||||||
|
).lower() in ("true", "1", "yes")
|
||||||
|
MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None
|
||||||
|
MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None
|
||||||
|
MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org")
|
||||||
|
MAIL_TIMEOUT: float = float(os.getenv("MAIL_TIMEOUT", "5"))
|
||||||
|
RATELIMIT_STORAGE_URI: str | None = os.getenv("RATELIMIT_STORAGE_URI") or None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
SESSION_COOKIE_SECURE: bool = True
|
||||||
|
SESSION_COOKIE_SAMESITE: str = "Strict"
|
||||||
|
PREFERRED_URL_SCHEME: str = "https"
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - exercised only before deps install.
|
||||||
|
Migrate = None
|
||||||
|
SQLAlchemy = None
|
||||||
|
|
||||||
|
|
||||||
|
db = SQLAlchemy() if SQLAlchemy is not None else None
|
||||||
|
migrate = Migrate() if Migrate is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def database_extensions_available() -> bool:
|
||||||
|
return db is not None and migrate is not None
|
||||||
|
|
||||||
|
|
||||||
|
def init_database(app: Any) -> bool:
|
||||||
|
if not database_extensions_available():
|
||||||
|
app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
import boker.db_models # noqa: F401 - registers SQLAlchemy models with metadata.
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
app.config["DATABASE_EXTENSIONS_AVAILABLE"] = True
|
||||||
|
return True
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
if db is None: # pragma: no cover - imported only when DB dependencies exist.
|
||||||
|
raise RuntimeError("Database dependencies are not installed.")
|
||||||
|
|
||||||
|
|
||||||
|
LEAGUE_ROLES = ("owner", "manager", "viewer")
|
||||||
|
PLAYER_STATUSES = ("active", "archived")
|
||||||
|
LEAGUE_VISIBILITIES = ("private", "public")
|
||||||
|
SESSION_STATUSES = ("open", "closed")
|
||||||
|
|
||||||
|
CANONICAL_EVENT_TYPES = {
|
||||||
|
"buyin",
|
||||||
|
"front",
|
||||||
|
"rollover_in",
|
||||||
|
"payout_carry_in",
|
||||||
|
"cashout",
|
||||||
|
"paid_out",
|
||||||
|
"rollover_out",
|
||||||
|
"debt_repayment",
|
||||||
|
"writeoff",
|
||||||
|
"note",
|
||||||
|
"session_open",
|
||||||
|
"session_close",
|
||||||
|
}
|
||||||
|
|
||||||
|
EVENT_TYPE_ALIASES = {
|
||||||
|
"paid": "paid_out",
|
||||||
|
"front_collected": "debt_repayment",
|
||||||
|
"front_writeoff": "writeoff",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_EVENT_TYPES = CANONICAL_EVENT_TYPES | set(EVENT_TYPE_ALIASES)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def uuid_str() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lookup(value: str) -> str:
|
||||||
|
return " ".join(value.strip().casefold().split())
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_event_type(event_type: str) -> str:
|
||||||
|
normalized = event_type.strip().lower()
|
||||||
|
return EVENT_TYPE_ALIASES.get(normalized, normalized)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=utc_now,
|
||||||
|
onupdate=utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(TimestampMixin, db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
email = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
email_verification_code_hash = db.Column(db.String(255), 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)
|
||||||
|
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):
|
||||||
|
__tablename__ = "leagues"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
name = db.Column(db.String(160), nullable=False)
|
||||||
|
slug = db.Column(db.String(120), nullable=False, index=True)
|
||||||
|
public_key = db.Column(db.String(12), nullable=False, unique=True, index=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
visibility = db.Column(db.String(20), nullable=False, default="private")
|
||||||
|
created_by_user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3)
|
||||||
|
break_even_cents = db.Column(db.Integer, nullable=False, default=100)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.CheckConstraint(
|
||||||
|
f"visibility IN {LEAGUE_VISIBILITIES}",
|
||||||
|
name="ck_leagues_visibility",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_ref(self) -> str:
|
||||||
|
return f"{self.slug}-{self.public_key}"
|
||||||
|
|
||||||
|
|
||||||
|
class LeagueMembership(db.Model):
|
||||||
|
__tablename__ = "league_memberships"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
league_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("leagues.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
role = db.Column(db.String(20), nullable=False)
|
||||||
|
invited_by_user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
|
||||||
|
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"),
|
||||||
|
db.CheckConstraint(f"role IN {LEAGUE_ROLES}", name="ck_memberships_role"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Player(TimestampMixin, db.Model):
|
||||||
|
__tablename__ = "players"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
league_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("leagues.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
display_name = db.Column(db.String(160), nullable=False)
|
||||||
|
normalized_name = db.Column(db.String(160), nullable=False)
|
||||||
|
slug = db.Column(db.String(120), nullable=False)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="active")
|
||||||
|
linked_user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"),
|
||||||
|
db.UniqueConstraint("league_id", "slug", name="uq_players_slug"),
|
||||||
|
db.UniqueConstraint("league_id", "id", name="uq_players_league_id"),
|
||||||
|
db.CheckConstraint(f"status IN {PLAYER_STATUSES}", name="ck_players_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Season(TimestampMixin, db.Model):
|
||||||
|
__tablename__ = "seasons"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
league_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("leagues.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name = db.Column(db.String(160), nullable=False)
|
||||||
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
archived_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("league_id", "name", name="uq_seasons_name"),
|
||||||
|
db.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"),
|
||||||
|
db.CheckConstraint(
|
||||||
|
"start_date IS NULL OR end_date IS NULL OR start_date <= end_date",
|
||||||
|
name="ck_seasons_date_order",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PokerSession(TimestampMixin, db.Model):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
league_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("leagues.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
season_id = db.Column(db.String(36), nullable=True)
|
||||||
|
session_date = db.Column(db.Date, nullable=False, index=True)
|
||||||
|
sequence_on_date = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
label = db.Column(db.String(80), nullable=True)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="closed")
|
||||||
|
opened_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
closed_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"league_id",
|
||||||
|
"session_date",
|
||||||
|
"sequence_on_date",
|
||||||
|
name="uq_sessions_date_sequence",
|
||||||
|
),
|
||||||
|
db.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"),
|
||||||
|
db.ForeignKeyConstraint(
|
||||||
|
["league_id", "season_id"],
|
||||||
|
["seasons.league_id", "seasons.id"],
|
||||||
|
name="fk_sessions_season_same_league",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
db.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"),
|
||||||
|
db.CheckConstraint(f"status IN {SESSION_STATUSES}", name="ck_sessions_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_label(self) -> str:
|
||||||
|
if self.label:
|
||||||
|
return self.label
|
||||||
|
return f"{self.session_date.isoformat()} - S{self.sequence_on_date}"
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerEvent(db.Model):
|
||||||
|
__tablename__ = "ledger_events"
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=uuid_str)
|
||||||
|
league_id = db.Column(db.String(36), nullable=False, index=True)
|
||||||
|
session_id = db.Column(db.String(36), nullable=False, index=True)
|
||||||
|
player_id = db.Column(db.String(36), nullable=True, index=True)
|
||||||
|
event_type = db.Column(db.String(40), nullable=False)
|
||||||
|
amount_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
note = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now)
|
||||||
|
created_by_user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
legacy_event_id = db.Column(db.String(80), nullable=True)
|
||||||
|
legacy_player_name = db.Column(db.String(160), nullable=True)
|
||||||
|
voided_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
voided_by_user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
void_reason = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.ForeignKeyConstraint(
|
||||||
|
["league_id", "session_id"],
|
||||||
|
["sessions.league_id", "sessions.id"],
|
||||||
|
name="fk_events_session_same_league",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
db.ForeignKeyConstraint(
|
||||||
|
["league_id", "player_id"],
|
||||||
|
["players.league_id", "players.id"],
|
||||||
|
name="fk_events_player_same_league",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
db.CheckConstraint(
|
||||||
|
f"event_type IN {tuple(sorted(SUPPORTED_EVENT_TYPES))}",
|
||||||
|
name="ck_events_type",
|
||||||
|
),
|
||||||
|
db.CheckConstraint(
|
||||||
|
"player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')",
|
||||||
|
name="ck_events_player_required",
|
||||||
|
),
|
||||||
|
db.Index("ix_events_league_created", "league_id", "created_at"),
|
||||||
|
db.Index("ix_events_league_session_created", "league_id", "session_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(email: str, password_hash: str) -> User:
|
||||||
|
return User(email=email.strip().casefold(), password_hash=password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def make_player(league_id: str, display_name: str, slug: str) -> Player:
|
||||||
|
return Player(
|
||||||
|
league_id=league_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
normalized_name=normalize_lookup(display_name),
|
||||||
|
slug=slug.strip().lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(
|
||||||
|
league_id: str,
|
||||||
|
session_date: date,
|
||||||
|
sequence_on_date: int,
|
||||||
|
season_id: str | None = None,
|
||||||
|
status: str = "closed",
|
||||||
|
) -> PokerSession:
|
||||||
|
return PokerSession(
|
||||||
|
league_id=league_id,
|
||||||
|
season_id=season_id,
|
||||||
|
session_date=session_date,
|
||||||
|
sequence_on_date=sequence_on_date,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from boker.extensions import mail
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_send_suppressed() -> bool:
|
||||||
|
state = current_app.extensions.get("mail")
|
||||||
|
return bool(getattr(state, "suppress", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _send_message(msg: Message) -> None:
|
||||||
|
if not _mail_send_suppressed() and not current_app.config.get("MAIL_SERVER"):
|
||||||
|
raise RuntimeError("Mail server is not configured.")
|
||||||
|
|
||||||
|
previous_timeout = socket.getdefaulttimeout()
|
||||||
|
socket.setdefaulttimeout(float(current_app.config.get("MAIL_TIMEOUT", 5)))
|
||||||
|
try:
|
||||||
|
mail.send(msg)
|
||||||
|
finally:
|
||||||
|
socket.setdefaulttimeout(previous_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_email(title: str, intro: str, cta_label: str | None = None, cta_url: str | None = None, code: str | None = None, note: str | None = None) -> str:
|
||||||
|
app_url = current_app.config.get("APP_BASE_URL", "https://myboker.org").rstrip("/")
|
||||||
|
cta_html = ""
|
||||||
|
if cta_label and cta_url:
|
||||||
|
cta_html = f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 32px 24px;">
|
||||||
|
<a href="{escape(cta_url)}" style="display:inline-block;background:#9b8cf0;color:#12101e;text-decoration:none;font:700 14px Arial,sans-serif;padding:12px 18px;border-radius:6px;">{escape(cta_label)}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 24px;color:#7c8294;font:400 12px/1.6 Arial,sans-serif;">
|
||||||
|
If the button does not work, copy and paste this link:<br>
|
||||||
|
<a href="{escape(cta_url)}" style="color:#b8aff8;word-break:break-all;">{escape(cta_url)}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
code_html = ""
|
||||||
|
if code:
|
||||||
|
code_html = f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 32px 24px;">
|
||||||
|
<div style="display:inline-block;letter-spacing:6px;font:800 34px/1.1 Arial,sans-serif;color:#f5f7fb;background:#151925;border:1px solid #32394a;border-radius:8px;padding:14px 18px;">{escape(code)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
note_html = ""
|
||||||
|
if note:
|
||||||
|
note_html = f"""
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 28px;color:#a3a8b8;font:400 14px/1.6 Arial,sans-serif;">{escape(note)}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#0f172a;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;margin:0;padding:32px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#202129;border:1px solid #313442;border-radius:10px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 32px 10px;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td width="32" height="32" style="background:#1e1533;border:1px solid #3d3260;border-radius:7px;text-align:center;vertical-align:middle;font-size:15px;color:#9b8cf0;font-family:Arial,sans-serif;">♦</td>
|
||||||
|
<td style="padding-left:10px;vertical-align:middle;font:700 17px/1 Arial,sans-serif;color:#f5f7fb;letter-spacing:-0.3px;">myboker<span style="color:#9b8cf0;">.org</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 32px 8px;">
|
||||||
|
<h1 style="margin:0;color:#f5f7fb;font:800 24px/1.25 Arial,sans-serif;">{escape(title)}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 22px;color:#c6cad6;font:400 15px/1.6 Arial,sans-serif;">{escape(intro)}</td>
|
||||||
|
</tr>
|
||||||
|
{code_html}
|
||||||
|
{cta_html}
|
||||||
|
{note_html}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 32px 26px;border-top:1px solid #32394a;color:#7c8294;font:400 12px/1.6 Arial,sans-serif;">
|
||||||
|
This email was sent by <a href="{escape(app_url)}" style="color:#b8aff8;text-decoration:none;">myboker.org</a>. If you did not request this, you can safely ignore it.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_reset(to_email: str, reset_url: str) -> None:
|
||||||
|
body = (
|
||||||
|
f"You requested a password reset for your myboker.org account.\n\n"
|
||||||
|
f"Reset your password:\n\n"
|
||||||
|
f"{reset_url}\n\n"
|
||||||
|
f"This link expires in 1 hour. If you did not request this, you can ignore this email."
|
||||||
|
)
|
||||||
|
msg = Message(
|
||||||
|
subject="Reset your myboker.org password",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=body,
|
||||||
|
html=_html_email(
|
||||||
|
"Reset your password",
|
||||||
|
"We received a request to reset the password for your myboker.org account.",
|
||||||
|
cta_label="Reset password",
|
||||||
|
cta_url=reset_url,
|
||||||
|
note="This link expires in 1 hour.",
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
_send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_temporary_password(to_email: str, temporary_password: str) -> None:
|
||||||
|
body = (
|
||||||
|
"A temporary password was created for your myboker.org account.\n\n"
|
||||||
|
f"Temporary password:\n\n{temporary_password}\n\n"
|
||||||
|
"Sign in with this password, then change it from account settings."
|
||||||
|
)
|
||||||
|
msg = Message(
|
||||||
|
subject="Temporary myboker.org password",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=body,
|
||||||
|
html=_html_email(
|
||||||
|
"Temporary password",
|
||||||
|
"A site administrator created a temporary password for your myboker.org account.",
|
||||||
|
code=temporary_password,
|
||||||
|
note="Sign in with this password, then change it from account settings.",
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
_send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_verification_code(to_email: str, code: str) -> None:
|
||||||
|
body = (
|
||||||
|
"Verify your myboker.org account with this code:\n\n"
|
||||||
|
f"{code}\n\n"
|
||||||
|
"This code expires in 15 minutes. If you did not create an account, you can ignore this email."
|
||||||
|
)
|
||||||
|
msg = Message(
|
||||||
|
subject="Your myboker.org verification code",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=body,
|
||||||
|
html=_html_email(
|
||||||
|
"Verify your email",
|
||||||
|
"Enter this verification code to finish creating your myboker.org account.",
|
||||||
|
code=code,
|
||||||
|
note="This code expires in 15 minutes. Do not share it with anyone.",
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
_send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str, role: str) -> None:
|
||||||
|
role_label = role.strip().lower() if role else "member"
|
||||||
|
body = (
|
||||||
|
f"{invited_by_email} has invited you to join {league_name} as a {role_label} on myboker.org.\n\n"
|
||||||
|
f"Accept your invitation:\n\n"
|
||||||
|
f"{invite_url}\n\n"
|
||||||
|
f"This link expires in 7 days. You'll need a myboker.org account with this email address to accept."
|
||||||
|
)
|
||||||
|
msg = Message(
|
||||||
|
subject=f"You've been invited to join {league_name} on myboker.org",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=body,
|
||||||
|
html=_html_email(
|
||||||
|
"You have a league invite",
|
||||||
|
f"{invited_by_email} invited you to join {league_name} as a {role_label}.",
|
||||||
|
cta_label="Accept invite",
|
||||||
|
cta_url=invite_url,
|
||||||
|
note="This invitation link expires in 7 days.",
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
_send_message(msg)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_mail import Mail
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
mail = Mail()
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from boker.auth import hash_password, normalize_email
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
if db is None: # pragma: no cover - imported only when DB dependencies exist.
|
||||||
|
raise RuntimeError("Database dependencies are not installed.")
|
||||||
|
|
||||||
|
from boker.db_models import ( # noqa: E402
|
||||||
|
LedgerEvent,
|
||||||
|
League,
|
||||||
|
LeagueMembership,
|
||||||
|
Player,
|
||||||
|
PokerSession,
|
||||||
|
Season,
|
||||||
|
User,
|
||||||
|
make_player,
|
||||||
|
make_session,
|
||||||
|
make_user,
|
||||||
|
normalize_lookup,
|
||||||
|
)
|
||||||
|
from boker.utils import slugify
|
||||||
|
|
||||||
|
PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
def generate_public_key(length: int = 6) -> str:
|
||||||
|
return "".join(secrets.choice(PUBLIC_KEY_ALPHABET) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def unique_league_public_key() -> str:
|
||||||
|
while True:
|
||||||
|
public_key = generate_public_key()
|
||||||
|
if League.query.filter_by(public_key=public_key).first() is None:
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email: str, password: str) -> User:
|
||||||
|
user = make_user(normalize_email(email), hash_password(password))
|
||||||
|
db.session.add(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_league(owner: User, name: str, slug: str, description: str | None = None) -> League:
|
||||||
|
league = League(
|
||||||
|
name=name.strip(),
|
||||||
|
slug=slug.strip().lower(),
|
||||||
|
public_key=unique_league_public_key(),
|
||||||
|
description=description.strip() if description else None,
|
||||||
|
created_by_user_id=owner.id,
|
||||||
|
)
|
||||||
|
db.session.add(league)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
db.session.add(
|
||||||
|
LeagueMembership(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=owner.id,
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return league
|
||||||
|
|
||||||
|
|
||||||
|
def unique_league_slug(name: str, requested_slug: str | None = None) -> str:
|
||||||
|
return slugify(requested_slug or name)
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_email(email: str) -> User | None:
|
||||||
|
return User.query.filter_by(email=normalize_email(email)).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def find_league_by_slug(slug: str) -> League | None:
|
||||||
|
return League.query.filter_by(slug=slug.strip().lower(), archived_at=None).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def find_league_by_id(league_id: str) -> League | None:
|
||||||
|
return League.query.filter_by(id=league_id, archived_at=None).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def find_league_by_public_key(public_key: str) -> League | None:
|
||||||
|
return League.query.filter_by(
|
||||||
|
public_key=public_key.strip().lower(),
|
||||||
|
archived_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_public_leagues(name_query: str = "") -> list[League]:
|
||||||
|
query = League.query.filter_by(visibility="public", archived_at=None)
|
||||||
|
if name_query:
|
||||||
|
query = query.filter(League.name.ilike(f"%{name_query}%"))
|
||||||
|
return query.order_by(League.name.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_leagues_for_user(user_id: str) -> list[tuple[League, LeagueMembership]]:
|
||||||
|
return (
|
||||||
|
db.session.query(League, LeagueMembership)
|
||||||
|
.join(LeagueMembership, LeagueMembership.league_id == League.id)
|
||||||
|
.filter(
|
||||||
|
LeagueMembership.user_id == user_id,
|
||||||
|
LeagueMembership.disabled_at.is_(None),
|
||||||
|
League.archived_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(League.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def league_counts(league_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"players": Player.query.filter_by(league_id=league_id, status="active").count(),
|
||||||
|
"seasons": Season.query.filter_by(league_id=league_id, archived_at=None).count(),
|
||||||
|
"sessions": PokerSession.query.filter_by(league_id=league_id).count(),
|
||||||
|
"open_sessions": PokerSession.query.filter_by(league_id=league_id, status="open").count(),
|
||||||
|
"events": LedgerEvent.query.filter_by(league_id=league_id, voided_at=None).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_player(league_id: str, display_name: str, slug: str) -> Player:
|
||||||
|
player = make_player(league_id=league_id, display_name=display_name, slug=slug)
|
||||||
|
db.session.add(player)
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def unique_player_slug(league_id: str, display_name: str, requested_slug: str | None = None) -> str:
|
||||||
|
base_slug = slugify(requested_slug or display_name, fallback="player")
|
||||||
|
candidate = base_slug
|
||||||
|
suffix = 2
|
||||||
|
|
||||||
|
while Player.query.filter_by(league_id=league_id, slug=candidate).first() is not None:
|
||||||
|
candidate = f"{base_slug}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def list_players_for_league(league_id: str) -> list[Player]:
|
||||||
|
return (
|
||||||
|
Player.query.filter_by(league_id=league_id)
|
||||||
|
.order_by(Player.status.asc(), Player.display_name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_player_for_league(league_id: str, player_id: str) -> Player | None:
|
||||||
|
return Player.query.filter_by(league_id=league_id, id=player_id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def set_player_status(player: Player, status: str) -> Player:
|
||||||
|
if status not in {"active", "archived"}:
|
||||||
|
raise ValueError(f"Unsupported player status: {status}")
|
||||||
|
|
||||||
|
player.status = status
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def create_season(
|
||||||
|
league_id: str,
|
||||||
|
name: str,
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> Season:
|
||||||
|
season = Season(
|
||||||
|
league_id=league_id,
|
||||||
|
name=name.strip(),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
db.session.add(season)
|
||||||
|
return season
|
||||||
|
|
||||||
|
|
||||||
|
def next_sequence_on_date(league_id: str, session_date: date) -> int:
|
||||||
|
current = (
|
||||||
|
db.session.query(db.func.max(PokerSession.sequence_on_date))
|
||||||
|
.filter_by(league_id=league_id, session_date=session_date)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
return int(current or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def create_poker_session(
|
||||||
|
league_id: str,
|
||||||
|
session_date: date,
|
||||||
|
season_id: str | None = None,
|
||||||
|
status: str = "closed",
|
||||||
|
) -> PokerSession:
|
||||||
|
session = make_session(
|
||||||
|
league_id=league_id,
|
||||||
|
season_id=season_id,
|
||||||
|
session_date=session_date,
|
||||||
|
sequence_on_date=next_sequence_on_date(league_id, session_date),
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.session.add(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions_for_league(league_id: str) -> list[PokerSession]:
|
||||||
|
return (
|
||||||
|
PokerSession.query.filter_by(league_id=league_id)
|
||||||
|
.order_by(
|
||||||
|
PokerSession.session_date.desc(),
|
||||||
|
PokerSession.sequence_on_date.desc(),
|
||||||
|
PokerSession.created_at.desc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_session_for_league(league_id: str, session_id: str) -> PokerSession | None:
|
||||||
|
return PokerSession.query.filter_by(league_id=league_id, id=session_id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_status(session: PokerSession, status: str) -> PokerSession:
|
||||||
|
if status not in {"open", "closed"}:
|
||||||
|
raise ValueError(f"Unsupported session status: {status}")
|
||||||
|
|
||||||
|
from boker.db_models import utc_now
|
||||||
|
|
||||||
|
session.status = status
|
||||||
|
if status == "open":
|
||||||
|
session.opened_at = utc_now()
|
||||||
|
session.closed_at = None
|
||||||
|
else:
|
||||||
|
session.closed_at = utc_now()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def player_name_exists(league_id: str, display_name: str) -> bool:
|
||||||
|
return (
|
||||||
|
Player.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
normalized_name=normalize_lookup(display_name),
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def player_name_taken(league_id: str, display_name: str, exclude_player_id: str) -> bool:
|
||||||
|
return (
|
||||||
|
Player.query.filter(
|
||||||
|
Player.league_id == league_id,
|
||||||
|
Player.normalized_name == normalize_lookup(display_name),
|
||||||
|
Player.id != exclude_player_id,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_player(player: Player, display_name: str, notes: str | None) -> Player:
|
||||||
|
player.display_name = display_name.strip()
|
||||||
|
player.normalized_name = normalize_lookup(display_name)
|
||||||
|
player.notes = notes.strip() if notes else None
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_league_role(
|
||||||
|
user_id: str,
|
||||||
|
league_id: str,
|
||||||
|
allowed_roles: set[str] | tuple[str, ...] | list[str],
|
||||||
|
) -> bool:
|
||||||
|
membership = LeagueMembership.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
league_id=league_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
return bool(membership and membership.role in set(allowed_roles))
|
||||||
|
|
||||||
|
|
||||||
|
def find_membership(league_id: str, user_id: str) -> LeagueMembership | None:
|
||||||
|
return LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_members_for_league(league_id: str) -> list[tuple[LeagueMembership, User]]:
|
||||||
|
return (
|
||||||
|
db.session.query(LeagueMembership, User)
|
||||||
|
.join(User, User.id == LeagueMembership.user_id)
|
||||||
|
.filter(
|
||||||
|
LeagueMembership.league_id == league_id,
|
||||||
|
LeagueMembership.disabled_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(LeagueMembership.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_league_member(
|
||||||
|
league_id: str,
|
||||||
|
user_id: str,
|
||||||
|
role: str,
|
||||||
|
invited_by_user_id: str,
|
||||||
|
) -> LeagueMembership:
|
||||||
|
existing = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
).one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
existing.role = role
|
||||||
|
existing.invited_by_user_id = invited_by_user_id or None
|
||||||
|
existing.disabled_at = None
|
||||||
|
return existing
|
||||||
|
|
||||||
|
membership = LeagueMembership(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=role,
|
||||||
|
invited_by_user_id=invited_by_user_id or None,
|
||||||
|
)
|
||||||
|
db.session.add(membership)
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def remove_league_member(league_id: str, user_id: str) -> None:
|
||||||
|
from boker.db_models import utc_now
|
||||||
|
|
||||||
|
membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
if membership and membership.role != "owner":
|
||||||
|
membership.disabled_at = utc_now()
|
||||||
|
|
||||||
|
|
||||||
|
def set_league_member_role(league_id: str, user_id: str, role: str) -> LeagueMembership | None:
|
||||||
|
if role not in {"manager", "viewer"}:
|
||||||
|
raise ValueError(f"Unsupported member role: {role}")
|
||||||
|
|
||||||
|
membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
if membership is None or membership.role == "owner":
|
||||||
|
return None
|
||||||
|
|
||||||
|
membership.role = role
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_league_ownership(
|
||||||
|
league: League,
|
||||||
|
current_owner_user_id: str,
|
||||||
|
new_owner_user_id: str,
|
||||||
|
) -> tuple[LeagueMembership, LeagueMembership] | None:
|
||||||
|
if current_owner_user_id == new_owner_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_owner = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=current_owner_user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
new_owner = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=new_owner_user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
if current_owner is None or current_owner.role != "owner" or new_owner is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_owner.role = "manager"
|
||||||
|
new_owner.role = "owner"
|
||||||
|
league.created_by_user_id = new_owner_user_id
|
||||||
|
return current_owner, new_owner
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seasons
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_seasons_for_league(league_id: str, include_archived: bool = False) -> list[Season]:
|
||||||
|
q = Season.query.filter_by(league_id=league_id)
|
||||||
|
if not include_archived:
|
||||||
|
q = q.filter(Season.archived_at.is_(None))
|
||||||
|
return q.order_by(Season.sort_order.asc(), Season.created_at.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def find_season(league_id: str, season_id: str) -> Season | None:
|
||||||
|
return Season.query.filter_by(league_id=league_id, id=season_id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def update_season(
|
||||||
|
season: Season,
|
||||||
|
name: str,
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
) -> Season:
|
||||||
|
season.name = name.strip()
|
||||||
|
season.start_date = start_date
|
||||||
|
season.end_date = end_date
|
||||||
|
return season
|
||||||
|
|
||||||
|
|
||||||
|
def archive_season(season: Season) -> Season:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
season.archived_at = datetime.now(timezone.utc)
|
||||||
|
return season
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_season(season: Season) -> Season:
|
||||||
|
season.archived_at = None
|
||||||
|
return season
|
||||||
|
|
||||||
|
|
||||||
|
def delete_season(season: Season) -> None:
|
||||||
|
PokerSession.query.filter_by(season_id=season.id).update({"season_id": None})
|
||||||
|
db.session.delete(season)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_league(league: League) -> None:
|
||||||
|
from boker.db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season
|
||||||
|
LedgerEvent.query.filter_by(league_id=league.id).delete(synchronize_session=False)
|
||||||
|
PokerSession.query.filter_by(league_id=league.id).delete(synchronize_session=False)
|
||||||
|
Player.query.filter_by(league_id=league.id).delete(synchronize_session=False)
|
||||||
|
Season.query.filter_by(league_id=league.id).delete(synchronize_session=False)
|
||||||
|
LeagueMembership.query.filter_by(league_id=league.id).delete(synchronize_session=False)
|
||||||
|
db.session.delete(league)
|
||||||
|
|
||||||
|
|
||||||
|
def auto_assign_sessions_to_seasons(league_id: str) -> int:
|
||||||
|
"""Assign unassigned sessions to seasons based on date ranges.
|
||||||
|
|
||||||
|
Only seasons with both start_date and end_date set are considered.
|
||||||
|
Sessions with exactly one matching season are assigned; sessions that
|
||||||
|
match zero or multiple seasons are left untouched (caller decides).
|
||||||
|
Returns the number of sessions assigned.
|
||||||
|
"""
|
||||||
|
eligible_seasons = [
|
||||||
|
s for s in list_seasons_for_league(league_id, include_archived=False)
|
||||||
|
if s.start_date is not None and s.end_date is not None
|
||||||
|
]
|
||||||
|
if not eligible_seasons:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
unassigned = PokerSession.query.filter_by(
|
||||||
|
league_id=league_id, season_id=None
|
||||||
|
).all()
|
||||||
|
assigned = 0
|
||||||
|
for session in unassigned:
|
||||||
|
matches = [
|
||||||
|
s for s in eligible_seasons
|
||||||
|
if s.start_date <= session.session_date <= s.end_date
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
session.season_id = matches[0].id
|
||||||
|
assigned += 1
|
||||||
|
return assigned
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
if db is None: # pragma: no cover - imported only when DB dependencies exist.
|
||||||
|
raise RuntimeError("Database dependencies are not installed.")
|
||||||
|
|
||||||
|
from boker.db_models import ( # noqa: E402
|
||||||
|
CANONICAL_EVENT_TYPES,
|
||||||
|
LedgerEvent,
|
||||||
|
Player,
|
||||||
|
PokerSession,
|
||||||
|
User,
|
||||||
|
canonical_event_type,
|
||||||
|
)
|
||||||
|
from boker.storage import EventRow # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def session_event_ref(session: PokerSession) -> str:
|
||||||
|
return f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def append_ledger_event(
|
||||||
|
league_id: str,
|
||||||
|
session_id: str,
|
||||||
|
event_type: str,
|
||||||
|
amount_cents: int,
|
||||||
|
created_by_user_id: str,
|
||||||
|
player_id: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
legacy_event_id: str | None = None,
|
||||||
|
legacy_player_name: str | None = None,
|
||||||
|
) -> LedgerEvent:
|
||||||
|
canonical_type = canonical_event_type(event_type)
|
||||||
|
if canonical_type not in CANONICAL_EVENT_TYPES:
|
||||||
|
raise ValueError(f"Unsupported event type: {event_type}")
|
||||||
|
|
||||||
|
if canonical_type in {"note", "session_open", "session_close"}:
|
||||||
|
amount_cents = 0
|
||||||
|
|
||||||
|
if player_id is None and canonical_type not in {"note", "session_open", "session_close"}:
|
||||||
|
raise ValueError(f"Event type {canonical_type} requires a player.")
|
||||||
|
|
||||||
|
event = LedgerEvent(
|
||||||
|
league_id=league_id,
|
||||||
|
session_id=session_id,
|
||||||
|
player_id=player_id,
|
||||||
|
event_type=canonical_type,
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
note=note.strip() if note else None,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
legacy_event_id=legacy_event_id,
|
||||||
|
legacy_player_name=legacy_player_name,
|
||||||
|
)
|
||||||
|
db.session.add(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def list_ledger_events_for_league(league_id: str) -> list[LedgerEvent]:
|
||||||
|
return (
|
||||||
|
LedgerEvent.query.filter_by(league_id=league_id, voided_at=None)
|
||||||
|
.order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_ledger_events_for_session(league_id: str, session_id: str) -> list[LedgerEvent]:
|
||||||
|
return (
|
||||||
|
LedgerEvent.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
session_id=session_id,
|
||||||
|
voided_at=None,
|
||||||
|
)
|
||||||
|
.order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_row(event: LedgerEvent, session: PokerSession, player: Player | None, user: User | None) -> EventRow:
|
||||||
|
return EventRow(
|
||||||
|
id=event.id,
|
||||||
|
created_at=event.created_at.isoformat() if event.created_at else "",
|
||||||
|
session_id=session_event_ref(session),
|
||||||
|
session_date=session.session_date.isoformat(),
|
||||||
|
player_name=(player.display_name if player is not None else event.legacy_player_name or ""),
|
||||||
|
event_type=event.event_type,
|
||||||
|
amount_cents=event.amount_cents,
|
||||||
|
note=event.note or "",
|
||||||
|
actor=user.email if user is not None else "",
|
||||||
|
voided_at=event.voided_at.isoformat() if event.voided_at else "",
|
||||||
|
void_reason=event.void_reason or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_event_rows_for_league(league_id: str) -> list[EventRow]:
|
||||||
|
"""All events including voided — for display feeds only, not accounting."""
|
||||||
|
rows = (
|
||||||
|
db.session.query(LedgerEvent, PokerSession, Player, User)
|
||||||
|
.join(
|
||||||
|
PokerSession,
|
||||||
|
(PokerSession.league_id == LedgerEvent.league_id)
|
||||||
|
& (PokerSession.id == LedgerEvent.session_id),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Player,
|
||||||
|
(Player.league_id == LedgerEvent.league_id)
|
||||||
|
& (Player.id == LedgerEvent.player_id),
|
||||||
|
)
|
||||||
|
.outerjoin(User, User.id == LedgerEvent.created_by_user_id)
|
||||||
|
.filter(LedgerEvent.league_id == league_id)
|
||||||
|
.order_by(
|
||||||
|
PokerSession.session_date.asc(),
|
||||||
|
PokerSession.sequence_on_date.asc(),
|
||||||
|
LedgerEvent.created_at.asc(),
|
||||||
|
LedgerEvent.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_event_row(event, session, player, user) for event, session, player, user in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def void_ledger_event(event_id: str, league_id: str, user_id: str, reason: str) -> LedgerEvent:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
event = LedgerEvent.query.filter_by(id=event_id, league_id=league_id).first()
|
||||||
|
if event is None:
|
||||||
|
raise ValueError("Event not found.")
|
||||||
|
if event.voided_at is not None:
|
||||||
|
raise ValueError("Event is already voided.")
|
||||||
|
event.voided_at = datetime.now(timezone.utc)
|
||||||
|
event.voided_by_user_id = user_id
|
||||||
|
event.void_reason = reason.strip() or "Voided by owner."
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def list_event_rows_for_league(league_id: str) -> list[EventRow]:
|
||||||
|
rows = (
|
||||||
|
db.session.query(LedgerEvent, PokerSession, Player, User)
|
||||||
|
.join(
|
||||||
|
PokerSession,
|
||||||
|
(PokerSession.league_id == LedgerEvent.league_id)
|
||||||
|
& (PokerSession.id == LedgerEvent.session_id),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Player,
|
||||||
|
(Player.league_id == LedgerEvent.league_id)
|
||||||
|
& (Player.id == LedgerEvent.player_id),
|
||||||
|
)
|
||||||
|
.outerjoin(User, User.id == LedgerEvent.created_by_user_id)
|
||||||
|
.filter(
|
||||||
|
LedgerEvent.league_id == league_id,
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
PokerSession.session_date.asc(),
|
||||||
|
PokerSession.sequence_on_date.asc(),
|
||||||
|
LedgerEvent.created_at.asc(),
|
||||||
|
LedgerEvent.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_event_row(event, session, player, user) for event, session, player, user in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]:
|
||||||
|
"""All events for a session including voided — for display only, not accounting."""
|
||||||
|
rows = (
|
||||||
|
db.session.query(LedgerEvent, PokerSession, Player, User)
|
||||||
|
.join(
|
||||||
|
PokerSession,
|
||||||
|
(PokerSession.league_id == LedgerEvent.league_id)
|
||||||
|
& (PokerSession.id == LedgerEvent.session_id),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Player,
|
||||||
|
(Player.league_id == LedgerEvent.league_id)
|
||||||
|
& (Player.id == LedgerEvent.player_id),
|
||||||
|
)
|
||||||
|
.outerjoin(User, User.id == LedgerEvent.created_by_user_id)
|
||||||
|
.filter(
|
||||||
|
LedgerEvent.league_id == league_id,
|
||||||
|
LedgerEvent.session_id == session_id,
|
||||||
|
)
|
||||||
|
.order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_event_row(event, session, player, user) for event, session, player, user in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]:
|
||||||
|
rows = (
|
||||||
|
db.session.query(LedgerEvent, PokerSession, Player, User)
|
||||||
|
.join(
|
||||||
|
PokerSession,
|
||||||
|
(PokerSession.league_id == LedgerEvent.league_id)
|
||||||
|
& (PokerSession.id == LedgerEvent.session_id),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Player,
|
||||||
|
(Player.league_id == LedgerEvent.league_id)
|
||||||
|
& (Player.id == LedgerEvent.player_id),
|
||||||
|
)
|
||||||
|
.outerjoin(User, User.id == LedgerEvent.created_by_user_id)
|
||||||
|
.filter(
|
||||||
|
LedgerEvent.league_id == league_id,
|
||||||
|
LedgerEvent.session_id == session_id,
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_event_row(event, session, player, user) for event, session, player, user in rows]
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for
|
||||||
|
|
||||||
|
from boker.auth import (
|
||||||
|
current_user_id,
|
||||||
|
generate_invite_token,
|
||||||
|
generate_reset_token,
|
||||||
|
hash_password,
|
||||||
|
log_user_in,
|
||||||
|
log_user_out,
|
||||||
|
login_required,
|
||||||
|
normalize_email,
|
||||||
|
verify_invite_token,
|
||||||
|
verify_password,
|
||||||
|
verify_reset_token,
|
||||||
|
)
|
||||||
|
from boker.db import database_extensions_available, db
|
||||||
|
from boker.extensions import limiter
|
||||||
|
|
||||||
|
account_bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
|
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
|
def db_ready() -> bool:
|
||||||
|
return database_extensions_available() and db is not None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next_url(default: str) -> str:
|
||||||
|
next_url = request.args.get("next", "").strip()
|
||||||
|
if not next_url:
|
||||||
|
return default
|
||||||
|
|
||||||
|
parsed = urlsplit(next_url)
|
||||||
|
if parsed.scheme or parsed.netloc:
|
||||||
|
return default
|
||||||
|
if not next_url.startswith("/"):
|
||||||
|
return default
|
||||||
|
return next_url
|
||||||
|
|
||||||
|
|
||||||
|
def _verification_next(default: str) -> str:
|
||||||
|
next_url = flask_session.get("pending_verification_next")
|
||||||
|
if isinstance(next_url, str) and next_url.startswith("/") and not urlsplit(next_url).netloc:
|
||||||
|
return next_url
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _verification_sent_at_valid(sent_at) -> bool:
|
||||||
|
if sent_at is None:
|
||||||
|
return False
|
||||||
|
if sent_at.tzinfo is None:
|
||||||
|
sent_at = sent_at.replace(tzinfo=timezone.utc)
|
||||||
|
return datetime.now(timezone.utc) - sent_at <= EMAIL_VERIFICATION_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def _new_verification_code() -> str:
|
||||||
|
return f"{secrets.randbelow(1000000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_verification_code(user) -> str:
|
||||||
|
from boker.db_models import utc_now
|
||||||
|
|
||||||
|
code = _new_verification_code()
|
||||||
|
user.email_verification_code_hash = hash_password(code)
|
||||||
|
user.email_verification_sent_at = utc_now()
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _send_verification_code(user) -> None:
|
||||||
|
from boker.emails import send_email_verification_code
|
||||||
|
|
||||||
|
code = _issue_verification_code(user)
|
||||||
|
db.session.commit()
|
||||||
|
send_email_verification_code(user.email, code)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_email_verification(user, next_url: str) -> str:
|
||||||
|
flask_session["pending_verification_user_id"] = user.id
|
||||||
|
flask_session["pending_verification_next"] = next_url
|
||||||
|
try:
|
||||||
|
_send_verification_code(user)
|
||||||
|
flash("Check your email for a verification code.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash("Account created, but we could not send a verification code. Try resending it.", "error")
|
||||||
|
return url_for("account.verify_email")
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.get("/")
|
||||||
|
@login_required
|
||||||
|
def home():
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/register", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def register():
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(safe_next_url(url_for("leagues.index")))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return render_template("account_register.html", form={})
|
||||||
|
|
||||||
|
form = {
|
||||||
|
"email": (request.form.get("email") or request.args.get("email") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
from boker.league_repositories import create_user, find_user_by_email
|
||||||
|
|
||||||
|
email = normalize_email(form["email"])
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
confirm_password = request.form.get("confirm_password", "")
|
||||||
|
existing_user = find_user_by_email(email)
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
flash("Enter a valid email address.", "error")
|
||||||
|
elif len(password) < 8:
|
||||||
|
flash("Password must be at least 8 characters.", "error")
|
||||||
|
elif password != confirm_password:
|
||||||
|
flash("Passwords do not match.", "error")
|
||||||
|
elif existing_user and existing_user.disabled_at is None:
|
||||||
|
flash("An account already exists for that email.", "error")
|
||||||
|
else:
|
||||||
|
if existing_user:
|
||||||
|
user = existing_user
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
user.disabled_at = None
|
||||||
|
user.email_verified_at = None
|
||||||
|
user.email_verification_code_hash = None
|
||||||
|
user.email_verification_sent_at = None
|
||||||
|
else:
|
||||||
|
user = create_user(email, password)
|
||||||
|
db.session.flush()
|
||||||
|
return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))
|
||||||
|
|
||||||
|
return render_template("account_register.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("20 per minute")
|
||||||
|
def login():
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(safe_next_url(url_for("leagues.index")))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return render_template("account_login.html", form={})
|
||||||
|
|
||||||
|
form = {
|
||||||
|
"email": request.form.get("email", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
from boker.league_repositories import find_user_by_email
|
||||||
|
|
||||||
|
user = find_user_by_email(form["email"])
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
if user is None or not verify_password(user.password_hash, password):
|
||||||
|
flash("Invalid email or password.", "error")
|
||||||
|
elif user.disabled_at is not None:
|
||||||
|
flash("That account is disabled.", "error")
|
||||||
|
elif user.email_verified_at is None:
|
||||||
|
flask_session["pending_verification_user_id"] = user.id
|
||||||
|
flask_session["pending_verification_next"] = safe_next_url(url_for("leagues.index"))
|
||||||
|
try:
|
||||||
|
_send_verification_code(user)
|
||||||
|
flash("Verify your email to continue. We sent you a new code.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash("Verify your email to continue. We could not send a new code.", "error")
|
||||||
|
return redirect(url_for("account.verify_email"))
|
||||||
|
else:
|
||||||
|
log_user_in(user.id)
|
||||||
|
next_url = safe_next_url(url_for("leagues.index"))
|
||||||
|
flash("Logged in.", "success")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return render_template("account_login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/verify-email", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def verify_email():
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(safe_next_url(url_for("leagues.index")))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.login"))
|
||||||
|
|
||||||
|
from boker.db_models import User, utc_now
|
||||||
|
|
||||||
|
user_id = flask_session.get("pending_verification_user_id")
|
||||||
|
user = db.session.get(User, user_id) if user_id else None
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
flask_session.pop("pending_verification_user_id", None)
|
||||||
|
flask_session.pop("pending_verification_next", None)
|
||||||
|
flash("Start again to verify your email.", "error")
|
||||||
|
return redirect(url_for("account.register"))
|
||||||
|
|
||||||
|
if user.email_verified_at is not None:
|
||||||
|
log_user_in(user.id)
|
||||||
|
next_url = _verification_next(url_for("leagues.index"))
|
||||||
|
flask_session.pop("pending_verification_user_id", None)
|
||||||
|
flask_session.pop("pending_verification_next", None)
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
code = "".join(ch for ch in request.form.get("code", "") if ch.isdigit())
|
||||||
|
if len(code) != 6:
|
||||||
|
flash("Enter the six-digit verification code.", "error")
|
||||||
|
elif not _verification_sent_at_valid(user.email_verification_sent_at):
|
||||||
|
flash("That code has expired. Request a new one.", "error")
|
||||||
|
elif not user.email_verification_code_hash or not verify_password(user.email_verification_code_hash, code):
|
||||||
|
flash("That verification code is not correct.", "error")
|
||||||
|
else:
|
||||||
|
user.email_verified_at = utc_now()
|
||||||
|
user.email_verification_code_hash = None
|
||||||
|
user.email_verification_sent_at = None
|
||||||
|
db.session.commit()
|
||||||
|
log_user_in(user.id)
|
||||||
|
next_url = _verification_next(url_for("leagues.index"))
|
||||||
|
flask_session.pop("pending_verification_user_id", None)
|
||||||
|
flask_session.pop("pending_verification_next", None)
|
||||||
|
flash("Email verified.", "success")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return render_template("account_verify_email.html", email=user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/verify-email/resend")
|
||||||
|
@limiter.limit("3 per minute")
|
||||||
|
def resend_verification_code():
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.login"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user_id = flask_session.get("pending_verification_user_id")
|
||||||
|
user = db.session.get(User, user_id) if user_id else None
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
flash("Start again to verify your email.", "error")
|
||||||
|
return redirect(url_for("account.register"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_send_verification_code(user)
|
||||||
|
flash("A new verification code has been sent.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash("We could not send a new code. Check your mail configuration.", "error")
|
||||||
|
return redirect(url_for("account.verify_email"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/logout")
|
||||||
|
def logout():
|
||||||
|
log_user_out()
|
||||||
|
flash("Logged out.", "success")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.get("/settings")
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
return render_template("account_settings.html", user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/settings/email")
|
||||||
|
@login_required
|
||||||
|
def update_email():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
new_email = normalize_email(request.form.get("email", ""))
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
|
||||||
|
if not new_email or "@" not in new_email:
|
||||||
|
flash("Enter a valid email address.", "error")
|
||||||
|
elif not verify_password(user.password_hash, current_password):
|
||||||
|
flash("Current password is incorrect.", "error")
|
||||||
|
else:
|
||||||
|
from boker.league_repositories import find_user_by_email
|
||||||
|
|
||||||
|
existing = find_user_by_email(new_email)
|
||||||
|
if existing and existing.id != user.id:
|
||||||
|
flash("That email is already in use.", "error")
|
||||||
|
else:
|
||||||
|
user.email = new_email
|
||||||
|
db.session.commit()
|
||||||
|
flash("Email updated.", "success")
|
||||||
|
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/settings/password")
|
||||||
|
@login_required
|
||||||
|
def update_password():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
confirm_password = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
if not verify_password(user.password_hash, current_password):
|
||||||
|
flash("Current password is incorrect.", "error")
|
||||||
|
elif len(new_password) < 8:
|
||||||
|
flash("New password must be at least 8 characters.", "error")
|
||||||
|
elif new_password != confirm_password:
|
||||||
|
flash("Passwords do not match.", "error")
|
||||||
|
else:
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Password changed.", "success")
|
||||||
|
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/disable")
|
||||||
|
@login_required
|
||||||
|
def disable_account():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from boker.db_models import League, User, utc_now
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
confirm = request.form.get("confirm", "").strip()
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
|
||||||
|
if confirm != "DISABLE":
|
||||||
|
flash("Confirmation text did not match.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
if not verify_password(user.password_hash, current_password):
|
||||||
|
flash("Password is incorrect.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
owned_leagues = League.query.filter_by(created_by_user_id=user.id, archived_at=None).all()
|
||||||
|
for league in owned_leagues:
|
||||||
|
league.archived_at = utc_now()
|
||||||
|
|
||||||
|
user.disabled_at = utc_now()
|
||||||
|
db.session.commit()
|
||||||
|
log_user_out()
|
||||||
|
flash("Your account has been disabled.", "success")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_account():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from boker.db_models import League, LeagueMembership, User, utc_now
|
||||||
|
from boker.league_repositories import delete_league
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
confirm = request.form.get("confirm", "").strip()
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
|
||||||
|
if confirm != "DELETE":
|
||||||
|
flash("Confirmation text did not match.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
if not verify_password(user.password_hash, current_password):
|
||||||
|
flash("Password is incorrect.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
for league in League.query.filter_by(created_by_user_id=user.id).all():
|
||||||
|
delete_league(league)
|
||||||
|
|
||||||
|
LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
log_user_out()
|
||||||
|
flash("Your account and all data have been permanently deleted.", "success")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/forgot-password", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute")
|
||||||
|
def forgot_password():
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.forgot_password"))
|
||||||
|
|
||||||
|
from boker.emails import send_password_reset
|
||||||
|
from flask import current_app
|
||||||
|
from boker.league_repositories import find_user_by_email
|
||||||
|
|
||||||
|
email = normalize_email(request.form.get("email", ""))
|
||||||
|
user = find_user_by_email(email)
|
||||||
|
|
||||||
|
if user and user.disabled_at is None:
|
||||||
|
token = generate_reset_token(user.id)
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
|
||||||
|
try:
|
||||||
|
send_password_reset(email, reset_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash("If that email is registered, a reset link has been sent.", "success")
|
||||||
|
return redirect(url_for("account.login"))
|
||||||
|
|
||||||
|
return render_template("forgot_password.html")
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/reset-password/<token>", methods=["GET", "POST"])
|
||||||
|
def reset_password(token):
|
||||||
|
if current_user_id():
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
user_id = verify_reset_token(token)
|
||||||
|
if user_id is None:
|
||||||
|
flash("That reset link is invalid or has expired.", "error")
|
||||||
|
return redirect(url_for("account.forgot_password"))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.login"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
flash("That reset link is invalid or has expired.", "error")
|
||||||
|
return redirect(url_for("account.forgot_password"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
confirm_password = request.form.get("confirm_password", "")
|
||||||
|
|
||||||
|
if len(new_password) < 8:
|
||||||
|
flash("Password must be at least 8 characters.", "error")
|
||||||
|
elif new_password != confirm_password:
|
||||||
|
flash("Passwords do not match.", "error")
|
||||||
|
else:
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Password reset. You can now log in.", "success")
|
||||||
|
return redirect(url_for("account.login"))
|
||||||
|
|
||||||
|
return render_template("reset_password.html", token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.get("/accept-invite/<token>")
|
||||||
|
def accept_invite(token):
|
||||||
|
data = verify_invite_token(token)
|
||||||
|
if data is None:
|
||||||
|
flash("That invitation link is invalid or has expired.", "error")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
from boker.db_models import User
|
||||||
|
from boker.league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email
|
||||||
|
|
||||||
|
invite_email = normalize_email(data.get("email", ""))
|
||||||
|
invite_next = url_for("account.accept_invite", token=token)
|
||||||
|
if not current_user_id():
|
||||||
|
if find_user_by_email(invite_email):
|
||||||
|
flash("Sign in to accept your invitation.", "info")
|
||||||
|
return redirect(url_for("account.login", next=invite_next))
|
||||||
|
|
||||||
|
flash("Create an account to accept your invitation.", "info")
|
||||||
|
return redirect(url_for("account.register", next=invite_next, email=invite_email))
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None or user.email != invite_email:
|
||||||
|
flash("This invitation was sent to a different email address.", "error")
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
league = find_league_by_id(data["league_id"])
|
||||||
|
if league is None:
|
||||||
|
flash("That league no longer exists.", "error")
|
||||||
|
return redirect(url_for("leagues.index"))
|
||||||
|
|
||||||
|
existing = find_membership(league.id, user.id)
|
||||||
|
if existing:
|
||||||
|
flash(f"You are already a member of {league.name}.", "info")
|
||||||
|
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
|
||||||
|
|
||||||
|
add_league_member(league.id, user.id, data["role"], data.get("invited_by", ""))
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
|
||||||
|
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from boker.auth import (
|
||||||
|
current_user_id,
|
||||||
|
generate_reset_token,
|
||||||
|
hash_password,
|
||||||
|
normalize_email,
|
||||||
|
site_admin_required,
|
||||||
|
)
|
||||||
|
from boker.db import db
|
||||||
|
from boker.db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now
|
||||||
|
from boker.league_repositories import delete_league, transfer_league_ownership
|
||||||
|
from boker.utils import slugify
|
||||||
|
|
||||||
|
|
||||||
|
internal_bp = Blueprint("internal", __name__, url_prefix="/internal")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_admin() -> User:
|
||||||
|
return User.query.filter_by(id=current_user_id()).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_or_404(user_id: str) -> User:
|
||||||
|
return User.query.filter_by(id=user_id).one_or_404()
|
||||||
|
|
||||||
|
|
||||||
|
def _league_or_404(league_id: str) -> League:
|
||||||
|
return League.query.filter_by(id=league_id).one_or_404()
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_for_league(league_id: str) -> User | None:
|
||||||
|
row = (
|
||||||
|
db.session.query(User)
|
||||||
|
.join(LeagueMembership, LeagueMembership.user_id == User.id)
|
||||||
|
.filter(
|
||||||
|
LeagueMembership.league_id == league_id,
|
||||||
|
LeagueMembership.role == "owner",
|
||||||
|
LeagueMembership.disabled_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _temporary_password(length: int = 14) -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
_CASH_IN_TYPES = ("buyin", "debt_repayment", "front_collected")
|
||||||
|
_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _page_number() -> int:
|
||||||
|
try:
|
||||||
|
return max(1, int(request.args.get("page", "1")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _paginate(query, page: int, per_page: int = _PAGE_SIZE) -> dict:
|
||||||
|
total = query.order_by(None).count()
|
||||||
|
pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
page = min(page, pages)
|
||||||
|
items = query.limit(per_page).offset((page - 1) * per_page).all()
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"page": page,
|
||||||
|
"pages": pages,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
"has_next": page < pages,
|
||||||
|
"prev_page": page - 1,
|
||||||
|
"next_page": page + 1,
|
||||||
|
"start": ((page - 1) * per_page + 1) if total else 0,
|
||||||
|
"end": min(page * per_page, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _site_stats() -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
day_ago = now - timedelta(days=1)
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
week_ago_date = week_ago.date()
|
||||||
|
month_ago = now - timedelta(days=30)
|
||||||
|
|
||||||
|
cash_in_cents = (
|
||||||
|
db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
|
||||||
|
.filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type.in_(_CASH_IN_TYPES))
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
cash_out_cents = (
|
||||||
|
db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
|
||||||
|
.filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type == "paid_out")
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
# avg distinct players per session (via buyin events)
|
||||||
|
session_player_counts = (
|
||||||
|
db.session.query(
|
||||||
|
LedgerEvent.session_id,
|
||||||
|
db.func.count(db.func.distinct(LedgerEvent.player_id)).label("n"),
|
||||||
|
)
|
||||||
|
.filter(LedgerEvent.event_type == "buyin", LedgerEvent.voided_at.is_(None))
|
||||||
|
.group_by(LedgerEvent.session_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
avg_players = db.session.query(
|
||||||
|
db.func.coalesce(db.func.avg(session_player_counts.c.n), 0)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
users_in_leagues = (
|
||||||
|
db.session.query(db.func.count(db.func.distinct(LeagueMembership.user_id)))
|
||||||
|
.filter(LeagueMembership.disabled_at.is_(None))
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
leagues_with_sessions = (
|
||||||
|
db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
leagues_active_7d = (
|
||||||
|
db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
|
||||||
|
.filter(PokerSession.session_date >= week_ago_date)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
sessions_with_events = (
|
||||||
|
db.session.query(db.func.count(db.func.distinct(LedgerEvent.session_id)))
|
||||||
|
.filter(LedgerEvent.voided_at.is_(None))
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
session_count = PokerSession.query.count()
|
||||||
|
active_league_count = League.query.filter_by(archived_at=None).count()
|
||||||
|
active_users_24h = User.query.filter(User.last_login_at >= day_ago).count()
|
||||||
|
active_users_7d = User.query.filter(User.last_login_at >= week_ago).count()
|
||||||
|
active_users_30d = User.query.filter(User.last_login_at >= month_ago).count()
|
||||||
|
returning_users_7d = User.query.filter(User.created_at < week_ago, User.last_login_at >= week_ago).count()
|
||||||
|
returning_users_30d = User.query.filter(User.created_at < month_ago, User.last_login_at >= month_ago).count()
|
||||||
|
total_users = User.query.count()
|
||||||
|
voided_events = LedgerEvent.query.filter(LedgerEvent.voided_at.is_not(None)).count()
|
||||||
|
ledger_events = LedgerEvent.query.filter_by(voided_at=None).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": total_users,
|
||||||
|
"active_users": User.query.filter_by(disabled_at=None).count(),
|
||||||
|
"disabled_users": User.query.filter(User.disabled_at.is_not(None)).count(),
|
||||||
|
"verified_users": User.query.filter(User.email_verified_at.is_not(None)).count(),
|
||||||
|
"unverified_users": User.query.filter(User.email_verified_at.is_(None)).count(),
|
||||||
|
"users_in_leagues": int(users_in_leagues or 0),
|
||||||
|
"orphan_users": total_users - int(users_in_leagues or 0),
|
||||||
|
"site_admins": User.query.filter_by(is_site_admin=True, disabled_at=None).count(),
|
||||||
|
"new_users_24h": User.query.filter(User.created_at >= day_ago).count(),
|
||||||
|
"new_users_7d": User.query.filter(User.created_at >= week_ago).count(),
|
||||||
|
"active_users_24h": active_users_24h,
|
||||||
|
"active_users_7d": active_users_7d,
|
||||||
|
"active_users_30d": active_users_30d,
|
||||||
|
"returning_users_7d": returning_users_7d,
|
||||||
|
"returning_users_30d": returning_users_30d,
|
||||||
|
"leagues": League.query.count(),
|
||||||
|
"active_leagues": active_league_count,
|
||||||
|
"archived_leagues": League.query.filter(League.archived_at.is_not(None)).count(),
|
||||||
|
"public_leagues": League.query.filter_by(visibility="public", archived_at=None).count(),
|
||||||
|
"leagues_with_sessions": int(leagues_with_sessions or 0),
|
||||||
|
"leagues_without_sessions": max(active_league_count - int(leagues_with_sessions or 0), 0),
|
||||||
|
"leagues_active_7d": int(leagues_active_7d or 0),
|
||||||
|
"sessions": session_count,
|
||||||
|
"open_sessions": PokerSession.query.filter_by(status="open").count(),
|
||||||
|
"sessions_24h": PokerSession.query.filter(PokerSession.session_date == today).count(),
|
||||||
|
"sessions_7d": PokerSession.query.filter(PokerSession.session_date >= week_ago_date).count(),
|
||||||
|
"sessions_without_events": max(session_count - int(sessions_with_events or 0), 0),
|
||||||
|
"ledger_events": ledger_events,
|
||||||
|
"ledger_events_24h": LedgerEvent.query.filter(LedgerEvent.created_at >= day_ago).count(),
|
||||||
|
"ledger_events_7d": LedgerEvent.query.filter(LedgerEvent.created_at >= week_ago).count(),
|
||||||
|
"voided_events": voided_events,
|
||||||
|
"void_rate": round((voided_events / (ledger_events + voided_events)) * 100, 1) if ledger_events or voided_events else 0,
|
||||||
|
"cash_in_cents": int(cash_in_cents or 0),
|
||||||
|
"cash_out_cents": int(cash_out_cents or 0),
|
||||||
|
"avg_cash_in_per_session_cents": int((cash_in_cents or 0) / session_count) if session_count else 0,
|
||||||
|
"avg_sessions_per_active_league": round(session_count / active_league_count, 1) if active_league_count else 0,
|
||||||
|
"avg_players_per_session": round(float(avg_players or 0), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _top_leagues(limit: int = 8):
|
||||||
|
rows = (
|
||||||
|
db.session.query(League, db.func.count(PokerSession.id).label("n"))
|
||||||
|
.outerjoin(PokerSession, PokerSession.league_id == League.id)
|
||||||
|
.filter(League.archived_at.is_(None))
|
||||||
|
.group_by(League.id)
|
||||||
|
.order_by(db.func.count(PokerSession.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [(league, count) for league, count in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _sessions_by_weekday() -> dict:
|
||||||
|
from sqlalchemy import func as f
|
||||||
|
rows = (
|
||||||
|
db.session.query(
|
||||||
|
f.strftime("%w", PokerSession.session_date).label("dow"),
|
||||||
|
f.count(PokerSession.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.group_by("dow")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
counts = {str(i): 0 for i in range(7)}
|
||||||
|
for row in rows:
|
||||||
|
counts[str(row.dow)] = row.cnt
|
||||||
|
return {
|
||||||
|
"labels": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||||
|
"data": [counts[str(i)] for i in range(7)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _growth_data(weeks: int = 8) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
labels, user_signups, session_counts, league_counts = [], [], [], []
|
||||||
|
for i in range(weeks - 1, -1, -1):
|
||||||
|
start = now - timedelta(weeks=i + 1)
|
||||||
|
end = now - timedelta(weeks=i)
|
||||||
|
start_date = start.date()
|
||||||
|
end_date = end.date()
|
||||||
|
labels.append(start.strftime("%b %-d"))
|
||||||
|
user_signups.append(User.query.filter(User.created_at >= start, User.created_at < end).count())
|
||||||
|
session_counts.append(
|
||||||
|
PokerSession.query.filter(
|
||||||
|
PokerSession.session_date >= start_date,
|
||||||
|
PokerSession.session_date < end_date,
|
||||||
|
).count()
|
||||||
|
)
|
||||||
|
league_counts.append(League.query.filter(League.created_at >= start, League.created_at < end).count())
|
||||||
|
return {
|
||||||
|
"labels": labels,
|
||||||
|
"user_signups": user_signups,
|
||||||
|
"session_counts": session_counts,
|
||||||
|
"league_counts": league_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_delta(current: int | float, previous: int | float) -> dict:
|
||||||
|
if previous == 0:
|
||||||
|
pct = None if current else 0
|
||||||
|
else:
|
||||||
|
pct = round(((current - previous) / previous) * 100, 1)
|
||||||
|
direction = "flat"
|
||||||
|
if current > previous:
|
||||||
|
direction = "up"
|
||||||
|
elif current < previous:
|
||||||
|
direction = "down"
|
||||||
|
return {"current": current, "previous": previous, "pct": pct, "direction": direction}
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_cards(growth: dict) -> list[dict]:
|
||||||
|
labels = growth["labels"]
|
||||||
|
user_series = growth["user_signups"]
|
||||||
|
session_series = growth["session_counts"]
|
||||||
|
league_series = growth["league_counts"]
|
||||||
|
current_start = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
previous_start = current_start - timedelta(days=7)
|
||||||
|
|
||||||
|
active_current = User.query.filter(User.last_login_at >= current_start).count()
|
||||||
|
active_previous = User.query.filter(
|
||||||
|
User.last_login_at >= previous_start,
|
||||||
|
User.last_login_at < current_start,
|
||||||
|
).count()
|
||||||
|
event_current = LedgerEvent.query.filter(
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
LedgerEvent.created_at >= current_start,
|
||||||
|
).count()
|
||||||
|
event_previous = LedgerEvent.query.filter(
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
LedgerEvent.created_at >= previous_start,
|
||||||
|
LedgerEvent.created_at < current_start,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "signups",
|
||||||
|
"label": "New users",
|
||||||
|
"value": user_series[-1] if user_series else 0,
|
||||||
|
"delta": _trend_delta(user_series[-1] if user_series else 0, user_series[-2] if len(user_series) > 1 else 0),
|
||||||
|
"labels": labels,
|
||||||
|
"series": user_series,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "active",
|
||||||
|
"label": "Active users",
|
||||||
|
"value": active_current,
|
||||||
|
"delta": _trend_delta(active_current, active_previous),
|
||||||
|
"labels": ["Previous", "Current"],
|
||||||
|
"series": [active_previous, active_current],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sessions",
|
||||||
|
"label": "Sessions",
|
||||||
|
"value": session_series[-1] if session_series else 0,
|
||||||
|
"delta": _trend_delta(session_series[-1] if session_series else 0, session_series[-2] if len(session_series) > 1 else 0),
|
||||||
|
"labels": labels,
|
||||||
|
"series": session_series,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ledger",
|
||||||
|
"label": "Ledger records",
|
||||||
|
"value": event_current,
|
||||||
|
"delta": _trend_delta(event_current, event_previous),
|
||||||
|
"labels": ["Previous", "Current"],
|
||||||
|
"series": [event_previous, event_current],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "leagues",
|
||||||
|
"label": "New leagues",
|
||||||
|
"value": league_series[-1] if league_series else 0,
|
||||||
|
"delta": _trend_delta(league_series[-1] if league_series else 0, league_series[-2] if len(league_series) > 1 else 0),
|
||||||
|
"labels": labels,
|
||||||
|
"series": league_series,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.get("/")
|
||||||
|
@site_admin_required
|
||||||
|
def dashboard():
|
||||||
|
recent_users = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||||
|
recent_leagues = League.query.order_by(League.created_at.desc()).limit(8).all()
|
||||||
|
top_leagues = _top_leagues()
|
||||||
|
owners = {league.id: _owner_for_league(league.id) for league in recent_leagues + [l for l, _ in top_leagues]}
|
||||||
|
growth = _growth_data()
|
||||||
|
return render_template(
|
||||||
|
"internal_dashboard.html",
|
||||||
|
admin_user=_current_admin(),
|
||||||
|
stats=_site_stats(),
|
||||||
|
growth=growth,
|
||||||
|
trend_cards=_trend_cards(growth),
|
||||||
|
weekday_data=_sessions_by_weekday(),
|
||||||
|
top_leagues=top_leagues,
|
||||||
|
recent_users=recent_users,
|
||||||
|
recent_leagues=recent_leagues,
|
||||||
|
owners=owners,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.get("/users")
|
||||||
|
@site_admin_required
|
||||||
|
def users():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
page = _page_number()
|
||||||
|
query = User.query
|
||||||
|
if q:
|
||||||
|
query = query.filter(User.email.ilike(f"%{normalize_email(q)}%"))
|
||||||
|
pagination = _paginate(query.order_by(User.created_at.desc()), page)
|
||||||
|
return render_template("internal_users.html", users=pagination["items"], pagination=pagination, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.get("/users/<user_id>")
|
||||||
|
@site_admin_required
|
||||||
|
def user_detail(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
memberships = (
|
||||||
|
db.session.query(LeagueMembership, League)
|
||||||
|
.join(League, League.id == LeagueMembership.league_id)
|
||||||
|
.filter(LeagueMembership.user_id == user.id)
|
||||||
|
.order_by(LeagueMembership.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
owned_leagues = League.query.filter_by(created_by_user_id=user.id).order_by(League.created_at.desc()).all()
|
||||||
|
event_count = LedgerEvent.query.filter_by(created_by_user_id=user.id).count()
|
||||||
|
return render_template(
|
||||||
|
"internal_user_detail.html",
|
||||||
|
user=user,
|
||||||
|
memberships=memberships,
|
||||||
|
owned_leagues=owned_leagues,
|
||||||
|
event_count=event_count,
|
||||||
|
current_admin_id=current_user_id(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/email")
|
||||||
|
@site_admin_required
|
||||||
|
def update_user_email(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
new_email = normalize_email(request.form.get("email", ""))
|
||||||
|
|
||||||
|
if not new_email or "@" not in new_email:
|
||||||
|
flash("Enter a valid email address.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
existing = User.query.filter(User.email == new_email, User.id != user.id).one_or_none()
|
||||||
|
if existing:
|
||||||
|
flash("That email is already in use.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
user.email = new_email
|
||||||
|
db.session.commit()
|
||||||
|
flash("User email updated.", "success")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/password-reset")
|
||||||
|
@site_admin_required
|
||||||
|
def send_user_password_reset(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
flash("Disabled users cannot receive password resets.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
from boker.emails import send_password_reset
|
||||||
|
|
||||||
|
token = generate_reset_token(user.id)
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
|
||||||
|
try:
|
||||||
|
send_password_reset(user.email, reset_url)
|
||||||
|
flash("Password reset email sent.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash("Password reset email could not be sent. Check mail configuration.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/temporary-password")
|
||||||
|
@site_admin_required
|
||||||
|
def create_user_temporary_password(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
flash("Disabled users cannot receive temporary passwords.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
from boker.emails import send_temporary_password
|
||||||
|
|
||||||
|
temporary_password = _temporary_password()
|
||||||
|
user.password_hash = hash_password(temporary_password)
|
||||||
|
db.session.commit()
|
||||||
|
try:
|
||||||
|
send_temporary_password(user.email, temporary_password)
|
||||||
|
flash("Temporary password created and emailed.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash(f"Temporary password created but email failed: {temporary_password}", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/disable")
|
||||||
|
@site_admin_required
|
||||||
|
def disable_user(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
if user.id == current_user_id():
|
||||||
|
flash("You cannot disable your own admin account.", "error")
|
||||||
|
elif user.disabled_at is None:
|
||||||
|
user.disabled_at = utc_now()
|
||||||
|
db.session.commit()
|
||||||
|
flash("User disabled.", "success")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/restore")
|
||||||
|
@site_admin_required
|
||||||
|
def restore_user(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
user.disabled_at = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("User restored.", "success")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/users/<user_id>/delete")
|
||||||
|
@site_admin_required
|
||||||
|
def delete_user(user_id: str):
|
||||||
|
user = _user_or_404(user_id)
|
||||||
|
confirm = request.form.get("confirm", "").strip()
|
||||||
|
if user.id == current_user_id():
|
||||||
|
flash("You cannot delete your own admin account.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
if confirm != "DELETE":
|
||||||
|
flash("Confirmation text did not match.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
owned_leagues = League.query.filter_by(created_by_user_id=user.id).count()
|
||||||
|
if owned_leagues:
|
||||||
|
flash("Transfer or delete this user's owned leagues before deleting the account.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
|
||||||
|
try:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash("User deleted.", "success")
|
||||||
|
return redirect(url_for("internal.users"))
|
||||||
|
except IntegrityError:
|
||||||
|
db.session.rollback()
|
||||||
|
flash("User has audit history and cannot be deleted safely. Disable the account instead.", "error")
|
||||||
|
return redirect(url_for("internal.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.get("/leagues")
|
||||||
|
@site_admin_required
|
||||||
|
def leagues():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
page = _page_number()
|
||||||
|
query = db.session.query(League)
|
||||||
|
if q:
|
||||||
|
term = f"%{q.strip()}%"
|
||||||
|
query = (
|
||||||
|
query.outerjoin(LeagueMembership, LeagueMembership.league_id == League.id)
|
||||||
|
.outerjoin(User, User.id == LeagueMembership.user_id)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
League.name.ilike(term),
|
||||||
|
League.slug.ilike(term),
|
||||||
|
User.email.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
pagination = _paginate(query.order_by(League.created_at.desc()), page)
|
||||||
|
leagues = pagination["items"]
|
||||||
|
owners = {league.id: _owner_for_league(league.id) for league in leagues}
|
||||||
|
return render_template("internal_leagues.html", leagues=leagues, owners=owners, pagination=pagination, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.get("/leagues/<league_id>")
|
||||||
|
@site_admin_required
|
||||||
|
def league_detail(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
members = (
|
||||||
|
db.session.query(LeagueMembership, User)
|
||||||
|
.join(User, User.id == LeagueMembership.user_id)
|
||||||
|
.filter(LeagueMembership.league_id == league.id)
|
||||||
|
.order_by(LeagueMembership.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
stats = {
|
||||||
|
"members": len([m for m, _u in members if m.disabled_at is None]),
|
||||||
|
"players": Player.query.filter_by(league_id=league.id).count(),
|
||||||
|
"sessions": PokerSession.query.filter_by(league_id=league.id).count(),
|
||||||
|
"events": LedgerEvent.query.filter_by(league_id=league.id).count(),
|
||||||
|
"voided_events": LedgerEvent.query.filter(LedgerEvent.league_id == league.id, LedgerEvent.voided_at.is_not(None)).count(),
|
||||||
|
}
|
||||||
|
owner = _owner_for_league(league.id)
|
||||||
|
return render_template("internal_league_detail.html", league=league, owner=owner, members=members, stats=stats)
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/leagues/<league_id>/archive")
|
||||||
|
@site_admin_required
|
||||||
|
def archive_league(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
league.archived_at = utc_now()
|
||||||
|
db.session.commit()
|
||||||
|
flash("League archived.", "success")
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/leagues/<league_id>/settings")
|
||||||
|
@site_admin_required
|
||||||
|
def update_league_settings(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
|
||||||
|
raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
description = request.form.get("description", "").strip()
|
||||||
|
visibility = request.form.get("visibility", "private").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
eligible_min = int(raw_eligible)
|
||||||
|
break_even_cents = round(float(raw_break_even) * 100)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
if len(name) < 2:
|
||||||
|
flash("League name must be at least 2 characters.", "error")
|
||||||
|
elif visibility not in ("private", "public"):
|
||||||
|
flash("Invalid visibility value.", "error")
|
||||||
|
elif eligible_min < 1 or eligible_min > 100:
|
||||||
|
flash("Eligible minimum must be between 1 and 100.", "error")
|
||||||
|
elif break_even_cents < 0 or break_even_cents > 10000:
|
||||||
|
flash("Break-even threshold must be between $0.00 and $100.00.", "error")
|
||||||
|
else:
|
||||||
|
league.name = name
|
||||||
|
league.slug = slugify(name)
|
||||||
|
league.description = description or None
|
||||||
|
league.visibility = visibility
|
||||||
|
league.eligible_min_sessions = eligible_min
|
||||||
|
league.break_even_cents = break_even_cents
|
||||||
|
db.session.commit()
|
||||||
|
flash("League settings updated.", "success")
|
||||||
|
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/leagues/<league_id>/restore")
|
||||||
|
@site_admin_required
|
||||||
|
def restore_league(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
league.archived_at = None
|
||||||
|
db.session.commit()
|
||||||
|
flash("League restored.", "success")
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/leagues/<league_id>/transfer")
|
||||||
|
@site_admin_required
|
||||||
|
def transfer_league_owner(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
new_owner_email = normalize_email(request.form.get("new_owner_email", ""))
|
||||||
|
new_owner = User.query.filter_by(email=new_owner_email, disabled_at=None).one_or_none()
|
||||||
|
current_owner = _owner_for_league(league.id)
|
||||||
|
|
||||||
|
if current_owner is None:
|
||||||
|
flash("Current owner could not be found.", "error")
|
||||||
|
elif new_owner is None:
|
||||||
|
flash("New owner must be an active user.", "error")
|
||||||
|
else:
|
||||||
|
membership = LeagueMembership.query.filter_by(league_id=league.id, user_id=new_owner.id).one_or_none()
|
||||||
|
if membership is None:
|
||||||
|
membership = LeagueMembership(league_id=league.id, user_id=new_owner.id, role="manager")
|
||||||
|
db.session.add(membership)
|
||||||
|
db.session.flush()
|
||||||
|
membership.disabled_at = None
|
||||||
|
result = transfer_league_ownership(league, current_owner.id, new_owner.id)
|
||||||
|
if result is None:
|
||||||
|
flash("Ownership transfer failed.", "error")
|
||||||
|
else:
|
||||||
|
db.session.commit()
|
||||||
|
flash("League ownership transferred.", "success")
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
db.session.rollback()
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
|
||||||
|
@internal_bp.post("/leagues/<league_id>/delete")
|
||||||
|
@site_admin_required
|
||||||
|
def delete_internal_league(league_id: str):
|
||||||
|
league = _league_or_404(league_id)
|
||||||
|
confirm = request.form.get("confirm", "").strip()
|
||||||
|
if confirm != "DELETE":
|
||||||
|
flash("Confirmation text did not match.", "error")
|
||||||
|
return redirect(url_for("internal.league_detail", league_id=league.id))
|
||||||
|
|
||||||
|
delete_league(league)
|
||||||
|
db.session.commit()
|
||||||
|
flash("League deleted.", "success")
|
||||||
|
return redirect(url_for("internal.leagues"))
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -4,23 +4,50 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
from charts import cumulative_profit_series, player_session_series, session_breakdown_series
|
from boker.charts import cumulative_profit_series, player_session_series, session_breakdown_series
|
||||||
from config import ELIGIBLE_MIN_SESSIONS
|
from boker.config import ELIGIBLE_MIN_SESSIONS
|
||||||
from services import (
|
from boker.services import (
|
||||||
apply_rank_changes,
|
apply_rank_changes,
|
||||||
build_leaderboard,
|
build_leaderboard,
|
||||||
build_session_summaries,
|
build_session_summaries,
|
||||||
session_events,
|
session_events,
|
||||||
)
|
)
|
||||||
from storage import load_events
|
from boker.storage import load_events
|
||||||
from utils import session_label, session_sort_key
|
from boker.utils import session_label, session_sort_key
|
||||||
|
|
||||||
public_bp = Blueprint("public", __name__)
|
public_bp = Blueprint("public", __name__)
|
||||||
|
|
||||||
|
|
||||||
@public_bp.get("/")
|
@public_bp.get("/")
|
||||||
def home():
|
def home():
|
||||||
return redirect(url_for("public.leaderboard"))
|
return render_template("landing.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/help")
|
||||||
|
def help():
|
||||||
|
return render_template("support.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/privacy")
|
||||||
|
def privacy():
|
||||||
|
return render_template("privacy.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/terms")
|
||||||
|
def terms():
|
||||||
|
return render_template("terms.html")
|
||||||
|
|
||||||
|
|
||||||
|
@public_bp.get("/explore")
|
||||||
|
def explore():
|
||||||
|
from boker.auth import is_logged_in
|
||||||
|
from boker.db import database_extensions_available
|
||||||
|
from boker.league_repositories import league_counts, list_public_leagues
|
||||||
|
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
leagues = list_public_leagues(q) if database_extensions_available() else []
|
||||||
|
counts = {league.id: league_counts(league.id) for league in leagues}
|
||||||
|
return render_template("explore.html", leagues=leagues, counts=counts, q=q)
|
||||||
|
|
||||||
|
|
||||||
@public_bp.get("/leaderboard")
|
@public_bp.get("/leaderboard")
|
||||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from models import PlayerStats, SessionEntry, SessionSummary
|
from boker.models import PlayerStats, SessionEntry, SessionSummary
|
||||||
from storage import EventRow
|
from boker.storage import EventRow
|
||||||
from utils import entry_sort_key, net_result_bucket, session_sort_key
|
from boker.utils import entry_sort_key, net_result_bucket, session_sort_key
|
||||||
|
|
||||||
SESSION_MARKER_TYPES = {"session_open", "session_close"}
|
SESSION_MARKER_TYPES = {"session_open", "session_close"}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]:
|
|||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]:
|
def summarize_player_runs(entries: list[SessionEntry], break_even_cents: int = 100) -> dict[str, int | str | None]:
|
||||||
ordered_entries = sorted(entries, key=entry_sort_key)
|
ordered_entries = sorted(entries, key=entry_sort_key)
|
||||||
|
|
||||||
longest_win_streak = 0
|
longest_win_streak = 0
|
||||||
@@ -116,7 +116,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
|
|||||||
|
|
||||||
for entry in ordered_entries:
|
for entry in ordered_entries:
|
||||||
net = entry.net_cents
|
net = entry.net_cents
|
||||||
bucket = net_result_bucket(net)
|
bucket = net_result_bucket(net, break_even_cents)
|
||||||
|
|
||||||
if best_entry is None or net > best_entry.net_cents:
|
if best_entry is None or net > best_entry.net_cents:
|
||||||
best_entry = entry
|
best_entry = entry
|
||||||
@@ -141,7 +141,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
|
|||||||
current_loss_streak = 0
|
current_loss_streak = 0
|
||||||
|
|
||||||
for entry in reversed(ordered_entries):
|
for entry in reversed(ordered_entries):
|
||||||
bucket = net_result_bucket(entry.net_cents)
|
bucket = net_result_bucket(entry.net_cents, break_even_cents)
|
||||||
|
|
||||||
if bucket == "win":
|
if bucket == "win":
|
||||||
if current_loss_streak > 0:
|
if current_loss_streak > 0:
|
||||||
@@ -166,7 +166,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str |
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
|
def build_leaderboard(sessions: list[SessionSummary], break_even_cents: int = 100) -> list[PlayerStats]:
|
||||||
player_entries: dict[str, list[SessionEntry]] = defaultdict(list)
|
player_entries: dict[str, list[SessionEntry]] = defaultdict(list)
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
for entry in session.entries:
|
for entry in session.entries:
|
||||||
@@ -175,9 +175,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]:
|
|||||||
leaderboard: list[PlayerStats] = []
|
leaderboard: list[PlayerStats] = []
|
||||||
for player_name, entries in player_entries.items():
|
for player_name, entries in player_entries.items():
|
||||||
nets = [entry.net_cents for entry in entries]
|
nets = [entry.net_cents for entry in entries]
|
||||||
run_summary = summarize_player_runs(entries)
|
run_summary = summarize_player_runs(entries, break_even_cents)
|
||||||
wins = [value for value in nets if net_result_bucket(value) == "win"]
|
wins = [value for value in nets if net_result_bucket(value, break_even_cents) == "win"]
|
||||||
losses = [value for value in nets if net_result_bucket(value) == "loss"]
|
losses = [value for value in nets if net_result_bucket(value, break_even_cents) == "loss"]
|
||||||
|
|
||||||
sessions_played = len(entries)
|
sessions_played = len(entries)
|
||||||
winning_sessions = len(wins)
|
winning_sessions = len(wins)
|
||||||
@@ -30,6 +30,8 @@ class EventRow(TypedDict):
|
|||||||
amount_cents: int
|
amount_cents: int
|
||||||
note: str
|
note: str
|
||||||
actor: str
|
actor: str
|
||||||
|
voided_at: str
|
||||||
|
void_reason: str
|
||||||
|
|
||||||
|
|
||||||
VALID_EVENT_TYPES = {
|
VALID_EVENT_TYPES = {
|
||||||
@@ -79,6 +81,8 @@ def load_events(csv_path: Path) -> list[EventRow]:
|
|||||||
amount_cents=int(row["amount_cents"] or 0),
|
amount_cents=int(row["amount_cents"] or 0),
|
||||||
note=row.get("note", ""),
|
note=row.get("note", ""),
|
||||||
actor=row.get("actor", ""),
|
actor=row.get("actor", ""),
|
||||||
|
voided_at="",
|
||||||
|
void_reason="",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3,14 +3,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from models import SessionEntry, SessionSummary
|
from boker.models import SessionEntry, SessionSummary
|
||||||
|
|
||||||
BREAK_EVEN_TOLERANCE_CENTS = 100
|
BREAK_EVEN_TOLERANCE_CENTS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str, fallback: str = "league") -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-")
|
||||||
|
return slug or fallback
|
||||||
|
|
||||||
|
|
||||||
def cents_to_dollars(cents: int) -> Markup:
|
def cents_to_dollars(cents: int) -> Markup:
|
||||||
value = cents / 100
|
value = cents / 100
|
||||||
return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
|
return Markup(f'<span class="currency-symbol">$</span>{value:,.2f}')
|
||||||
@@ -76,9 +82,9 @@ def entry_sort_key(entry: SessionEntry) -> tuple[str, int, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def net_result_bucket(value_cents: int) -> str:
|
def net_result_bucket(value_cents: int, tolerance_cents: int = BREAK_EVEN_TOLERANCE_CENTS) -> str:
|
||||||
if value_cents > BREAK_EVEN_TOLERANCE_CENTS:
|
if value_cents > tolerance_cents:
|
||||||
return "win"
|
return "win"
|
||||||
if value_cents < -BREAK_EVEN_TOLERANCE_CENTS:
|
if value_cents < -tolerance_cents:
|
||||||
return "loss"
|
return "loss"
|
||||||
return "even"
|
return "even"
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
|
||||||
|
|
||||||
ELIGIBLE_MIN_SESSIONS = 3
|
|
||||||
APP_VERSION = "1.8.6"
|
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(env_path: Path) -> None:
|
|
||||||
if not env_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip('"').strip("'")
|
|
||||||
os.environ.setdefault(key, value)
|
|
||||||
|
|
||||||
|
|
||||||
load_local_env(BASE_DIR / ".env")
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
APP_VERSION: str = APP_VERSION
|
|
||||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying")
|
|
||||||
ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin")
|
|
||||||
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me")
|
|
||||||
SESSION_COOKIE_HTTPONLY: bool = True
|
|
||||||
SESSION_COOKIE_SAMESITE: str = "Lax"
|
|
||||||
SESSION_COOKIE_NAME: str = "poker_portal_session"
|
|
||||||
DATA_PATH: Path = DATA_PATH
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Single-database Alembic environment managed through Flask-Migrate.
|
||||||
|
|
||||||
|
Install dependencies with:
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
Then run migrations with:
|
||||||
|
|
||||||
|
flask --app app db upgrade
|
||||||
|
|
||||||
|
For a quick local development bootstrap without Alembic, the app also exposes:
|
||||||
|
|
||||||
|
flask --app app init-db
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# A generic, single-database Alembic config used by Flask-Migrate.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_db = current_app.extensions["migrate"].db
|
||||||
|
target_metadata = target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
return target_db.engine
|
||||||
|
except AttributeError:
|
||||||
|
return target_db.get_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def get_url() -> str:
|
||||||
|
return str(get_engine().url).replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=get_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
with get_engine().connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""public multi-league foundation
|
||||||
|
|
||||||
|
Revision ID: 0001_public_foundation
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0001_public_foundation"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("email"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"leagues",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("visibility", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("visibility IN ('private', 'public')", name="ck_leagues_visibility"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_leagues_created_by_user_id"), "leagues", ["created_by_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_leagues_slug"), "leagues", ["slug"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"league_memberships",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("league_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("invited_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint("role IN ('owner', 'manager', 'viewer')", name="ck_memberships_role"),
|
||||||
|
sa.ForeignKeyConstraint(["invited_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_league_memberships_league_id"), "league_memberships", ["league_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_league_memberships_user_id"), "league_memberships", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"players",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("league_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("normalized_name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("linked_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("status IN ('active', 'archived')", name="ck_players_status"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["linked_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("league_id", "id", name="uq_players_league_id"),
|
||||||
|
sa.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"),
|
||||||
|
sa.UniqueConstraint("league_id", "slug", name="uq_players_slug"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_players_league_id"), "players", ["league_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"seasons",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("league_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_seasons_date_order"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"),
|
||||||
|
sa.UniqueConstraint("league_id", "name", name="uq_seasons_name"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_seasons_league_id"), "seasons", ["league_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("league_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("season_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("session_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("sequence_on_date", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"),
|
||||||
|
sa.CheckConstraint("status IN ('open', 'closed')", name="ck_sessions_status"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id", "season_id"], ["seasons.league_id", "seasons.id"], name="fk_sessions_season_same_league", ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"),
|
||||||
|
sa.UniqueConstraint("league_id", "session_date", "sequence_on_date", name="uq_sessions_date_sequence"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_sessions_league_id"), "sessions", ["league_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_sessions_session_date"), "sessions", ["session_date"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ledger_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("league_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("session_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("player_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("event_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("amount_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("legacy_event_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("legacy_player_name", sa.String(length=160), nullable=True),
|
||||||
|
sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("voided_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("void_reason", sa.Text(), nullable=True),
|
||||||
|
sa.CheckConstraint("event_type IN ('buyin', 'cashout', 'debt_repayment', 'front', 'front_collected', 'front_writeoff', 'note', 'paid', 'paid_out', 'payout_carry_in', 'rollover_in', 'rollover_out', 'session_close', 'session_open', 'writeoff')", name="ck_events_type"),
|
||||||
|
sa.CheckConstraint("player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')", name="ck_events_player_required"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id", "player_id"], ["players.league_id", "players.id"], name="fk_events_player_same_league", ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["league_id", "session_id"], ["sessions.league_id", "sessions.id"], name="fk_events_session_same_league", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["voided_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_ledger_events_league_id"), "ledger_events", ["league_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_ledger_events_player_id"), "ledger_events", ["player_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_ledger_events_session_id"), "ledger_events", ["session_id"], unique=False)
|
||||||
|
op.create_index("ix_events_league_created", "ledger_events", ["league_id", "created_at"], unique=False)
|
||||||
|
op.create_index("ix_events_league_session_created", "ledger_events", ["league_id", "session_id", "created_at"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_events_league_session_created", table_name="ledger_events")
|
||||||
|
op.drop_index("ix_events_league_created", table_name="ledger_events")
|
||||||
|
op.drop_index(op.f("ix_ledger_events_session_id"), table_name="ledger_events")
|
||||||
|
op.drop_index(op.f("ix_ledger_events_player_id"), table_name="ledger_events")
|
||||||
|
op.drop_index(op.f("ix_ledger_events_league_id"), table_name="ledger_events")
|
||||||
|
op.drop_table("ledger_events")
|
||||||
|
op.drop_index(op.f("ix_sessions_session_date"), table_name="sessions")
|
||||||
|
op.drop_index(op.f("ix_sessions_league_id"), table_name="sessions")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_index(op.f("ix_seasons_league_id"), table_name="seasons")
|
||||||
|
op.drop_table("seasons")
|
||||||
|
op.drop_index(op.f("ix_players_league_id"), table_name="players")
|
||||||
|
op.drop_table("players")
|
||||||
|
op.drop_index(op.f("ix_league_memberships_user_id"), table_name="league_memberships")
|
||||||
|
op.drop_index(op.f("ix_league_memberships_league_id"), table_name="league_memberships")
|
||||||
|
op.drop_table("league_memberships")
|
||||||
|
op.drop_index(op.f("ix_leagues_slug"), table_name="leagues")
|
||||||
|
op.drop_index(op.f("ix_leagues_created_by_user_id"), table_name="leagues")
|
||||||
|
op.drop_table("leagues")
|
||||||
|
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add league public key
|
||||||
|
|
||||||
|
Revision ID: 0002_league_public_key
|
||||||
|
Revises: 0001_public_foundation
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0002_league_public_key"
|
||||||
|
down_revision = "0001_public_foundation"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True))
|
||||||
|
op.execute("UPDATE leagues SET public_key = substr(replace(id, '-', ''), 1, 12) WHERE public_key IS NULL")
|
||||||
|
|
||||||
|
with op.batch_alter_table("leagues") as batch_op:
|
||||||
|
batch_op.alter_column("public_key", existing_type=sa.String(length=12), nullable=False)
|
||||||
|
batch_op.create_index("ix_leagues_public_key", ["public_key"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("leagues") as batch_op:
|
||||||
|
batch_op.drop_index("ix_leagues_public_key")
|
||||||
|
batch_op.drop_column("public_key")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add per-league eligible_min_sessions and break_even_cents
|
||||||
|
|
||||||
|
Revision ID: 0003_league_settings
|
||||||
|
Revises: 0002_league_public_key
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0003_league_settings"
|
||||||
|
down_revision = "0003_site_admin"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("leagues") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column(
|
||||||
|
"eligible_min_sessions", sa.Integer(), nullable=False, server_default="3"
|
||||||
|
))
|
||||||
|
batch_op.add_column(sa.Column(
|
||||||
|
"break_even_cents", sa.Integer(), nullable=False, server_default="100"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("leagues") as batch_op:
|
||||||
|
batch_op.drop_column("break_even_cents")
|
||||||
|
batch_op.drop_column("eligible_min_sessions")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""site admin (stub — already applied)
|
||||||
|
|
||||||
|
Revision ID: 0003_site_admin
|
||||||
|
Revises: 0002_league_public_key
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0003_site_admin"
|
||||||
|
down_revision = "0002_league_public_key"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add email verification code fields
|
||||||
|
|
||||||
|
Revision ID: 0004_email_verification_codes
|
||||||
|
Revises: 0003_league_settings
|
||||||
|
Create Date: 2026-06-27
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0004_email_verification_codes"
|
||||||
|
down_revision = "0003_league_settings"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("email_verification_code_hash", sa.String(length=255), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("email_verification_sent_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
batch_op.drop_column("email_verification_sent_at")
|
||||||
|
batch_op.drop_column("email_verification_code_hash")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""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 context, 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:
|
||||||
|
if context.is_offline_mode():
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"is_site_admin",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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:
|
||||||
|
if context.is_offline_mode():
|
||||||
|
op.drop_column("users", "is_site_admin")
|
||||||
|
return
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -1 +1,9 @@
|
|||||||
Flask==3.1.0
|
Flask==3.1.0
|
||||||
|
Flask-Limiter>=3.5,<4.0
|
||||||
|
Flask-Mail>=0.10,<1.0
|
||||||
|
Flask-Migrate>=4.0,<5.0
|
||||||
|
Flask-SQLAlchemy>=3.1,<4.0
|
||||||
|
Flask-WTF>=1.2,<2.0
|
||||||
|
gunicorn>=22,<24
|
||||||
|
psycopg[binary]>=3.2,<4.0
|
||||||
|
redis>=5,<6
|
||||||
-583
@@ -1,583 +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["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.leaderboard"))
|
|
||||||
|
|
||||||
|
|
||||||
@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,
|
|
||||||
)
|
|
||||||
+4403
-59
File diff suppressed because it is too large.
Load diff
+40
-28
@@ -1,34 +1,36 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ---- surfaces (darkest → lightest) ---- */
|
/* Core surfaces */
|
||||||
--bg: #0f0f12;
|
--bg: #0a0a0d;
|
||||||
--surface: #16161c;
|
--surface: #111115;
|
||||||
--surface-raised:#1a1a20;
|
--surface-raised:#16161c;
|
||||||
--surface-head: #1c1c24;
|
--surface-head: #18181f;
|
||||||
--surface-sunk: #131318;
|
--surface-sunk: #0d0d11;
|
||||||
--field: #1f1f27;
|
--field: #1a1a22;
|
||||||
--field-alt: #20202a;
|
--field-alt: #1c1c25;
|
||||||
|
|
||||||
/* ---- borders / dividers ---- */
|
/* ---- borders / dividers ---- */
|
||||||
--border: #26262f;
|
--border: #22222b;
|
||||||
--border-strong: #2e2e38;
|
--border-strong: #2a2a34;
|
||||||
--border-hi: #34343f;
|
--border-hi: #32323d;
|
||||||
--divider: #1d1d25;
|
--divider: #191921;
|
||||||
--slash: #3a3a45;
|
--slash: #383843;
|
||||||
|
|
||||||
/* ---- text ---- */
|
/* ---- text ---- */
|
||||||
--text: #f3f1f8;
|
--text: #f4f2f9;
|
||||||
--text-strong: #ecebf1;
|
--text-strong: #edeaf5;
|
||||||
--text-body: #e7e5ec;
|
--text-body: #e8e6ef;
|
||||||
--text-2: #bcbac6;
|
--text-2: #bdbbc8;
|
||||||
--num: #b0aeba;
|
--num: #b2b0bb;
|
||||||
--muted: #8f8d99;
|
--muted: #8c8a96;
|
||||||
--muted-2: #84828e;
|
--muted-2: #82808c;
|
||||||
--faint: #7d7b87;
|
--faint: #7a7885;
|
||||||
--faint-2: #757380;
|
--faint-2: #72707d;
|
||||||
--faintest: #66646f;
|
--faintest: #64626d;
|
||||||
--faintest-2: #57555f;
|
--faintest-2: #55535e;
|
||||||
|
|
||||||
/* ---- accent (violet) ---- */
|
/* Brand accent */
|
||||||
--accent: #9b8cf0;
|
--accent: #9b8cf0;
|
||||||
--accent-ink: #12101e;
|
--accent-ink: #12101e;
|
||||||
--accent-tint: #1f1d2e;
|
--accent-tint: #1f1d2e;
|
||||||
@@ -65,12 +67,22 @@
|
|||||||
--line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c;
|
--line-1: #9b8cf0; --line-2: #6fc093; --line-3: #e0b15c;
|
||||||
--line-4: #cf6f86; --line-5: #8f93c2;
|
--line-4: #cf6f86; --line-5: #8f93c2;
|
||||||
|
|
||||||
|
/* Elevation */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.28);
|
||||||
|
--shadow-md: 0 3px 12px rgba(0,0,0,.32), 0 1px 4px rgba(0,0,0,.24);
|
||||||
|
--shadow-lg: 0 10px 28px rgba(0,0,0,.38), 0 3px 10px rgba(0,0,0,.26);
|
||||||
|
--shadow-xl: 0 18px 48px rgba(0,0,0,.46), 0 6px 18px rgba(0,0,0,.30);
|
||||||
|
|
||||||
|
/* ---- glass panel effect ---- */
|
||||||
|
--glass-border: 1px solid rgba(255,255,255,.055);
|
||||||
|
--glass-bg: rgba(255,255,255,.02);
|
||||||
|
|
||||||
/* ---- spacing scale (4pt) ---- */
|
/* ---- spacing scale (4pt) ---- */
|
||||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px;
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px;
|
||||||
--s6:24px; --s8:32px; --s10:40px; --s12:48px;
|
--s6:24px; --s8:32px; --s10:40px; --s12:48px;
|
||||||
|
|
||||||
/* ---- radii ---- */
|
/* ---- radii ---- */
|
||||||
--r-pill:6px; --r-sm:9px; --r-md:11px; --r-lg:14px;
|
--r-pill:5px; --r-sm:7px; --r-md:9px; --r-lg:12px;
|
||||||
|
|
||||||
/* ---- containers ---- */
|
/* ---- containers ---- */
|
||||||
--w-public:1320px;
|
--w-public:1320px;
|
||||||
@@ -79,6 +91,6 @@
|
|||||||
--gutter: clamp(16px, 4vw, 40px);
|
--gutter: clamp(16px, 4vw, 40px);
|
||||||
|
|
||||||
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
|
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
|
||||||
--text-muted: #bcbac6;
|
--text-muted: #bdbbc8;
|
||||||
--line: #26262f;
|
--line: #22222b;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26">
|
||||||
<rect width="26" height="26" rx="5" fill="#9b8cf0"/>
|
<rect width="26" height="26" rx="5" fill="#9b8cf0"/>
|
||||||
<path d="M13 5.22 20.78 13 13 20.78 5.22 13Z" fill="#0f0f12"/>
|
<path d="M13 5.22 20.78 13 13 20.78 5.22 13Z" fill="#12101e"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}403 · Access Denied · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--error{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="error-pg error-pg--403">
|
||||||
|
<div class="error-pg__suit" aria-hidden="true">♣</div>
|
||||||
|
<div class="error-pg__body">
|
||||||
|
<span class="error-pg__code">403</span>
|
||||||
|
<h1 class="error-pg__title">Access denied.</h1>
|
||||||
|
<p class="error-pg__sub">You don't have permission to view this page. If you think this is a mistake, contact your league owner.</p>
|
||||||
|
<div class="error-pg__actions">
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('public.home') }}">Go home →</a>
|
||||||
|
{% if not is_logged_in %}
|
||||||
|
<a class="btn btn--ghost btn--lg" href="{{ url_for('account.login') }}">Sign in</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn--ghost btn--lg" href="javascript:history.back()">Go back</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}404 · Page Not Found · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--error{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="error-pg">
|
||||||
|
<div class="error-pg__suit" aria-hidden="true">♠</div>
|
||||||
|
<div class="error-pg__body">
|
||||||
|
<span class="error-pg__code">404</span>
|
||||||
|
<h1 class="error-pg__title">Page not found.</h1>
|
||||||
|
<p class="error-pg__sub">That URL doesn't exist or the page has been moved. Double-check the address or head back to safety.</p>
|
||||||
|
<div class="error-pg__actions">
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('public.home') }}">Go home →</a>
|
||||||
|
<a class="btn btn--ghost btn--lg" href="javascript:history.back()">Go back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}500 · Something Went Wrong · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--error{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="error-pg error-pg--500">
|
||||||
|
<div class="error-pg__suit" aria-hidden="true">♦</div>
|
||||||
|
<div class="error-pg__body">
|
||||||
|
<span class="error-pg__code">500</span>
|
||||||
|
<h1 class="error-pg__title">Something went wrong.</h1>
|
||||||
|
<p class="error-pg__sub">The app hit an unexpected error. Try again in a minute, or contact support if it keeps happening.</p>
|
||||||
|
<p class="error-pg__trace">Incident {{ crash_id }}</p>
|
||||||
|
<div class="error-pg__actions">
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('public.home') }}">Go home →</a>
|
||||||
|
<a class="btn btn--ghost btn--lg" href="{{ url_for('public.help') }}#contact">Contact support</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<aside class="sidebar sidebar--admin">
|
||||||
|
<div class="sidebar__header">
|
||||||
|
<div class="sidebar__badge sidebar__badge--admin">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="16" height="16">
|
||||||
|
<path d="M8 1L14 4v5c0 3.5-2.5 6-6 6.5C2.5 15 0 12.5 0 9V4l8-3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" fill="none"/>
|
||||||
|
<path d="M5.5 8l2 2 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar__meta">
|
||||||
|
<span class="sidebar__league-name">Admin Panel</span>
|
||||||
|
<span class="sidebar__role">site admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar__nav" aria-label="Admin navigation">
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'internal.dashboard' }}"
|
||||||
|
href="{{ url_for('internal.dashboard') }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Overview</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('internal.users', 'internal.user_detail') }}"
|
||||||
|
href="{{ url_for('internal.users') }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="6" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M1 14c0-2.761 2.239-5 5-5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="11.5" cy="5.5" r="2" stroke="currentColor" stroke-width="1.3"/>
|
||||||
|
<path d="M9 14c0-1.657 1.119-3.084 2.5-3.084S14 12.343 14 14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Users</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('internal.leagues', 'internal.league_detail') }}"
|
||||||
|
href="{{ url_for('internal.leagues') }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 14V6l5-3 5 3v8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 14v-4h4v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Leagues</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
<a class="sidebar__back" href="{{ url_for('leagues.index') }}">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 2L3 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Back to app
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar__header">
|
||||||
|
<div class="sidebar__badge">{{ league.name[0]|upper }}</div>
|
||||||
|
<div class="sidebar__meta">
|
||||||
|
<span class="sidebar__league-name">{{ league.name }}</span>
|
||||||
|
{% if is_owner is defined and is_owner %}
|
||||||
|
<span class="sidebar__role">Owner</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar__nav" aria-label="League navigation">
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'leagues.dashboard' }}"
|
||||||
|
href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Overview</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('leagues.sessions', 'leagues.session_detail', 'leagues.session_view') }}"
|
||||||
|
href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="3" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M5 1v4M11 1v4M1 7h14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Sessions</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('leagues.players', 'leagues.player_detail') }}"
|
||||||
|
href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="6" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M1 14c0-2.761 2.239-5 5-5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="11.5" cy="5.5" r="2" stroke="currentColor" stroke-width="1.3"/>
|
||||||
|
<path d="M9 14c0-1.657 1.119-3.084 2.5-3.084S14 12.343 14 14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Players</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'leagues.leaderboard' }}"
|
||||||
|
href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="9" width="4" height="6" rx="1" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="6" y="5" width="4" height="10" rx="1" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<rect x="11" y="1" width="4" height="14" rx="1" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Leaderboard</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'leagues.seasons' }}"
|
||||||
|
href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M8 4v4l3 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Seasons</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if can_manage is defined and can_manage %}
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'leagues.ledger' }}"
|
||||||
|
href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2" y="1" width="12" height="14" rx="2" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M5 5h6M5 8h6M5 11h3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Ledger</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_owner is defined and is_owner %}
|
||||||
|
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'leagues.league_settings' }}"
|
||||||
|
href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.93 2.93l1.41 1.41M11.66 11.66l1.41 1.41M2.93 13.07l1.41-1.41M11.66 4.34l1.41-1.41" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar__footer">
|
||||||
|
{% if is_logged_in %}
|
||||||
|
<a class="sidebar__back" href="{{ url_for('leagues.index') }}">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 2L3 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
All leagues
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="subnav">
|
||||||
|
<div class="subnav__inner">
|
||||||
|
<a class="subnav__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a>
|
||||||
|
<span class="subnav__divider"></span>
|
||||||
|
<nav class="subnav__nav">
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.dashboard' }}" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Overview</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint in ('leagues.sessions', 'leagues.session_detail') }}" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint in ('leagues.players', 'leagues.player_detail') }}" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">Players</a>
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.leaderboard' }}" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
|
||||||
|
{% if can_manage is defined and can_manage %}
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.ledger' }}" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_owner is defined and is_owner %}
|
||||||
|
<a class="subnav__link {{ 'is-active' if request.endpoint == 'leagues.league_settings' }}" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Settings</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sign in · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--auth{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="auth-split">
|
||||||
|
<div class="auth-split__form-col">
|
||||||
|
<div class="auth-split__form-inner">
|
||||||
|
<a class="auth-split__brand" href="{{ url_for('public.home') }}">
|
||||||
|
<div class="auth-split__brand-mark"></div>
|
||||||
|
<span class="auth-split__brand-name">myboker<span class="auth-split__brand-tld">.org</span></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="auth-split__title">Welcome back.</h1>
|
||||||
|
<p class="auth-split__sub">Sign in to manage your leagues, sessions, and ledger.</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" type="email" name="email" autocomplete="email"
|
||||||
|
value="{{ form.email or '' }}" placeholder="you@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" name="password"
|
||||||
|
autocomplete="current-password" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
<div class="auth-links">
|
||||||
|
<a href="{{ url_for('account.forgot_password') }}">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">Sign in →</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-alt">No account yet? <a href="{{ url_for('account.register') }}">Create one</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-split__visual auth-split__visual--club" aria-hidden="true">
|
||||||
|
<div class="auth-visual__body">
|
||||||
|
<div class="auth-visual__icon">
|
||||||
|
<span class="auth-visual__suit-sym">♣</span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-visual__title">Track every chip.</p>
|
||||||
|
<p class="auth-visual__sub">Sessions, leaderboards, and debts — all in one place for your private poker league.</p>
|
||||||
|
<div class="auth-visual__preview">
|
||||||
|
<div class="auth-visual__preview-kicker">Live leaderboard</div>
|
||||||
|
<div class="auth-visual__preview-row">
|
||||||
|
<span>Rivera</span>
|
||||||
|
<strong style="color:var(--pos);">+$1,420</strong>
|
||||||
|
</div>
|
||||||
|
<div class="auth-visual__preview-row">
|
||||||
|
<span>Chen</span>
|
||||||
|
<strong style="color:var(--pos);">+$640</strong>
|
||||||
|
</div>
|
||||||
|
<div class="auth-visual__preview-row" style="opacity:.5">
|
||||||
|
<span>Okafor</span>
|
||||||
|
<strong style="color:var(--neg);">−$210</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Create Account · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--auth{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="auth-split">
|
||||||
|
<div class="auth-split__form-col">
|
||||||
|
<div class="auth-split__form-inner">
|
||||||
|
<a class="auth-split__brand" href="{{ url_for('public.home') }}">
|
||||||
|
<div class="auth-split__brand-mark"></div>
|
||||||
|
<span class="auth-split__brand-name">myboker<span class="auth-split__brand-tld">.org</span></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="auth-split__title">Start your league.</h1>
|
||||||
|
<p class="auth-split__sub">Create an account to manage leagues, players, and sessions.</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" type="email" name="email" autocomplete="email"
|
||||||
|
value="{{ form.email or '' }}" placeholder="you@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" name="password"
|
||||||
|
autocomplete="new-password" placeholder="At least 8 characters" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="confirm_password">Confirm password</label>
|
||||||
|
<input id="confirm_password" type="password" name="confirm_password"
|
||||||
|
autocomplete="new-password" placeholder="Repeat your password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">Create account →</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-alt">Already have an account? <a href="{{ url_for('account.login') }}">Sign in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-split__visual auth-split__visual--diamond" aria-hidden="true">
|
||||||
|
<div class="auth-visual__body">
|
||||||
|
<div class="auth-visual__icon">
|
||||||
|
<span class="auth-visual__suit-sym">♦</span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-visual__title">Free to start.</p>
|
||||||
|
<p class="auth-visual__sub">No credit card, no monthly fees, no payment processor taking a cut of your buy-ins.</p>
|
||||||
|
<div class="auth-visual__preview">
|
||||||
|
<div class="auth-visual__preview-kicker">What you get</div>
|
||||||
|
<div class="auth-visual__preview-row">
|
||||||
|
<span>Multiple leagues</span>
|
||||||
|
<strong style="color:var(--pos);">✓</strong>
|
||||||
|
</div>
|
||||||
|
<div class="auth-visual__preview-row">
|
||||||
|
<span>Full ledger history</span>
|
||||||
|
<strong style="color:var(--pos);">✓</strong>
|
||||||
|
</div>
|
||||||
|
<div class="auth-visual__preview-row">
|
||||||
|
<span>Platform fee</span>
|
||||||
|
<strong style="color:var(--muted);">$0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Account settings · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="settings-profile">
|
||||||
|
<div class="settings-avatar">{{ user.email[0] | upper }}</div>
|
||||||
|
<div class="settings-profile__meta">
|
||||||
|
<h1 class="settings-profile__title">Account Settings</h1>
|
||||||
|
<p class="settings-profile__email">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.index') }}">My leagues</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post" action="{{ url_for('account.update_email') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Profile</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Email address</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" value="{{ user.email }}" required autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Current password <span class="form-opt-hint">(required to change)</span></span>
|
||||||
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Update email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post" action="{{ url_for('account.update_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Security</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Change password</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Current password</span>
|
||||||
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input type="password" name="new_password" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm new password</span>
|
||||||
|
<input type="password" name="confirm_password" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Change password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;color:var(--neg);">Danger zone</p>
|
||||||
|
<h2 class="panel__title">Destructive actions</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Disable account</strong>
|
||||||
|
<p class="muted-text">Disables your account and archives all leagues you own. Your data is preserved — you can sign up again with the same email to restore access.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-disable-account">Disable account</button>
|
||||||
|
</div>
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Delete account</strong>
|
||||||
|
<p class="muted-text">Permanently deletes your account and all leagues, sessions, and ledger data you own. This cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--warning btn--sm danger-row__btn" type="button" data-modal="modal-delete-account">Delete account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable account modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-disable-account" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Account</span>
|
||||||
|
<h2 class="panel__title">Disable account</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
Your account will be disabled and all leagues you own will be archived. Your data is preserved — you can sign up again with the same email to restore access.<br><br>
|
||||||
|
Type <strong>DISABLE</strong> and enter your password to confirm.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('account.disable_account') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:10px;">
|
||||||
|
<input
|
||||||
|
class="confirm-text-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm"
|
||||||
|
placeholder='Type "DISABLE"'
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="DISABLE"
|
||||||
|
data-submit="disable-confirm-btn"
|
||||||
|
data-requires-password="disable-password-input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="confirm-password-input"
|
||||||
|
id="disable-password-input"
|
||||||
|
type="password"
|
||||||
|
name="current_password"
|
||||||
|
placeholder="Your password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
data-submit="disable-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="disable-confirm-btn" type="submit" disabled>Disable my account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete account modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-delete-account" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Account</span>
|
||||||
|
<h2 class="panel__title">Delete account</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
Your account and all leagues, sessions, and ledger data you own will be <strong>permanently deleted</strong>. This cannot be undone.<br><br>
|
||||||
|
Type <strong>DELETE</strong> and enter your password to confirm.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('account.delete_account') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:10px;">
|
||||||
|
<input
|
||||||
|
class="confirm-text-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm"
|
||||||
|
placeholder='Type "DELETE"'
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="DELETE"
|
||||||
|
data-submit="delete-confirm-btn"
|
||||||
|
data-requires-password="delete-password-input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="confirm-password-input"
|
||||||
|
id="delete-password-input"
|
||||||
|
type="password"
|
||||||
|
name="current_password"
|
||||||
|
placeholder="Your password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
data-submit="delete-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="delete-confirm-btn" type="submit" disabled>Permanently delete account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.removeAttribute('hidden');
|
||||||
|
const txt = el.querySelector('.confirm-text-input');
|
||||||
|
const pwd = el.querySelector('.confirm-password-input');
|
||||||
|
if (txt) { txt.value = ''; txt.focus(); }
|
||||||
|
if (pwd) pwd.value = '';
|
||||||
|
refreshModal(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshModal(modalEl) {
|
||||||
|
const txt = modalEl.querySelector('.confirm-text-input');
|
||||||
|
if (!txt) return;
|
||||||
|
const btnId = txt.dataset.submit;
|
||||||
|
const pwdId = txt.dataset.requiresPassword;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const pwd = pwdId ? document.getElementById(pwdId) : null;
|
||||||
|
if (!btn) return;
|
||||||
|
const textOk = txt.value === txt.dataset.match;
|
||||||
|
const pwdOk = !pwd || pwd.value.length >= 1;
|
||||||
|
btn.disabled = !(textOk && pwdOk);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(trigger => {
|
||||||
|
trigger.addEventListener('click', () => openModal(trigger.dataset.modal));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => btn.closest('.modal-backdrop').setAttribute('hidden', ''));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||||
|
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.setAttribute('hidden', ''); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.confirm-text-input, .confirm-password-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => refreshModal(inp.closest('.modal-backdrop')));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Verify Email · myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--auth{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="auth-split">
|
||||||
|
<div class="auth-split__form-col">
|
||||||
|
<div class="auth-split__form-inner">
|
||||||
|
<a class="auth-split__brand" href="{{ url_for('public.home') }}">
|
||||||
|
<div class="auth-split__brand-mark"></div>
|
||||||
|
<span class="auth-split__brand-name">myboker<span class="auth-split__brand-tld">.org</span></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="auth-split__title">Verify your email.</h1>
|
||||||
|
<p class="auth-split__sub">Enter the six-digit code sent to {{ email }}.</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="auth-field auth-field--code">
|
||||||
|
<label for="code">Verification code</label>
|
||||||
|
<input id="code" class="auth-code-input" type="text" name="code"
|
||||||
|
inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" placeholder="000000" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('account.resend_verification_code') }}" class="auth-resend-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="auth-link-button" type="submit">Resend code</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-split__visual auth-split__visual--diamond" aria-hidden="true">
|
||||||
|
<div class="auth-visual__body">
|
||||||
|
<div class="auth-visual__icon">
|
||||||
|
<span class="auth-visual__suit-sym">♦</span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-visual__title">One quick check.</p>
|
||||||
|
<p class="auth-visual__sub">Email verification keeps league invites tied to the right account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
|
||||||
+71
-13
@@ -10,11 +10,12 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="l-bg" aria-hidden="true"></div>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar__inner">
|
<div class="topbar__inner">
|
||||||
<a class="brand" href="{{ url_for('public.leaderboard') }}">
|
<a class="brand" href="{{ url_for('public.home') }}">
|
||||||
<span class="brand__mark"></span>
|
<span class="brand__mark"></span>
|
||||||
<span class="brand__name">myboker.org</span>
|
<span class="brand__name">myboker<span class="brand__tld">.org</span></span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-toggle" type="button" aria-controls="mainnav" aria-expanded="false" aria-label="Open navigation">
|
<button class="nav-toggle" type="button" aria-controls="mainnav" aria-expanded="false" aria-label="Open navigation">
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -22,26 +23,38 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</button>
|
</button>
|
||||||
<nav class="mainnav" id="mainnav">
|
<nav class="mainnav" id="mainnav">
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'public.leaderboard' }}" href="{{ url_for('public.leaderboard') }}">Leaderboard</a>
|
{% if is_logged_in %}
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint in ('public.sessions', 'public.session_detail') }}" href="{{ url_for('public.sessions') }}">Sessions</a>
|
<a class="navlink {{ 'is-active' if request.endpoint and request.endpoint.startswith('leagues.') }}" href="{{ url_for('leagues.index') }}">Leagues</a>
|
||||||
{% if is_admin %}
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.explore' }}" href="{{ url_for('public.explore') }}">Explore</a>
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">Admin</a>
|
<a class="navlink {{ 'is-active' if request.endpoint and request.endpoint.startswith('account.') and request.endpoint not in ('account.login', 'account.register') }}" href="{{ url_for('account.settings') }}">Account</a>
|
||||||
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.help' }}" href="{{ url_for('public.help') }}">Help</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="navlink {{ 'is-active' if request.endpoint == 'admin.login' }}" href="{{ url_for('admin.login') }}">Admin</a>
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.explore' }}" href="{{ url_for('public.explore') }}">Explore</a>
|
||||||
|
<a class="navlink {{ 'is-active' if request.endpoint == 'public.help' }}" href="{{ url_for('public.help') }}">Help</a>
|
||||||
|
<a class="navlink navlink--mobile-auth {{ 'is-active' if request.endpoint == 'account.login' }}" href="{{ url_for('account.login') }}">Login</a>
|
||||||
|
<a class="navlink navlink--mobile-auth {{ 'is-active' if request.endpoint == 'account.register' }}" href="{{ url_for('account.register') }}">Register</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<span class="topbar__spacer"></span>
|
<span class="topbar__spacer"></span>
|
||||||
{% if is_admin %}
|
{% if is_logged_in %}
|
||||||
<div class="topbar__admin-actions">
|
<div class="topbar__admin-actions">
|
||||||
<form method="post" action="{{ url_for('admin.logout') }}" style="margin:0">
|
<form method="post" action="{{ url_for('account.logout') }}" style="margin:0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--ghost btn--sm" type="submit">Logout</button>
|
<button class="btn btn--ghost btn--sm" type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="topbar__admin-actions topbar__account-actions">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('account.login') }}">Login</a>
|
||||||
|
<a class="btn btn--outline btn--sm" href="{{ url_for('account.register') }}">Register</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="app-shell">
|
||||||
|
{% block sidebar %}{% endblock %}
|
||||||
<main class="page {% block page_class %}page--public{% endblock %}">
|
<main class="page {% block page_class %}page--public{% endblock %}">
|
||||||
|
{% block subnav %}{% endblock %}
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="flash-stack">
|
<div class="flash-stack">
|
||||||
@@ -55,11 +68,22 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<footer class="site-footer" aria-label="Site footer">
|
<footer class="site-footer" aria-label="Site footer">
|
||||||
<span>© 2026 myboker.org</span>
|
<div class="site-footer__copy">
|
||||||
<span>Version {{ app_version }}</span>
|
<span>© 2026 <strong>myboker.org</strong></span>
|
||||||
<span>cedarline.digital</span>
|
<span class="site-footer__version">v{{ app_version }}</span>
|
||||||
|
</div>
|
||||||
|
<nav class="site-footer__links" aria-label="Footer links">
|
||||||
|
<a href="{{ url_for('public.help') }}">Help</a>
|
||||||
|
<a href="{{ url_for('public.help') }}#contact">Contact</a>
|
||||||
|
<a href="{{ url_for('public.privacy') }}">Privacy</a>
|
||||||
|
<a href="{{ url_for('public.terms') }}">Terms</a>
|
||||||
|
</nav>
|
||||||
|
<div class="site-footer__brand">
|
||||||
|
<a href="https://cedarline.digital" target="_blank" rel="noopener">cedarline.digital</a>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
</div><!-- /.app-shell -->
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const toggle = document.querySelector('.nav-toggle');
|
const toggle = document.querySelector('.nav-toggle');
|
||||||
@@ -91,5 +115,39 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
function positionBubble(tip) {
|
||||||
|
const bubble = tip.querySelector('.info-tip__bubble');
|
||||||
|
if (!bubble) return;
|
||||||
|
const rect = tip.getBoundingClientRect();
|
||||||
|
const bw = bubble.offsetWidth || 220;
|
||||||
|
const bh = bubble.offsetHeight || 60;
|
||||||
|
let left = rect.left + rect.width / 2 - bw / 2;
|
||||||
|
let top = rect.top - bh - 8;
|
||||||
|
left = Math.max(8, Math.min(left, window.innerWidth - bw - 8));
|
||||||
|
if (top < 8) top = rect.bottom + 8;
|
||||||
|
bubble.style.left = left + 'px';
|
||||||
|
bubble.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.info-tip').forEach(tip => {
|
||||||
|
tip.addEventListener('mouseenter', () => { positionBubble(tip); tip.classList.add('is-open'); });
|
||||||
|
tip.addEventListener('mouseleave', () => tip.classList.remove('is-open'));
|
||||||
|
tip.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wasOpen = tip.classList.contains('is-open');
|
||||||
|
document.querySelectorAll('.info-tip.is-open').forEach(t => t.classList.remove('is-open'));
|
||||||
|
if (!wasOpen) { positionBubble(tip); tip.classList.add('is-open'); }
|
||||||
|
});
|
||||||
|
tip.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tip.click(); }
|
||||||
|
if (e.key === 'Escape') tip.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.info-tip.is-open').forEach(t => t.classList.remove('is-open'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Help · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:12px;">
|
||||||
|
<p class="hero__kicker">Documentation</p>
|
||||||
|
<h1 class="hero__title">How myboker works</h1>
|
||||||
|
<p class="hero__sub">Everything you need to run a home poker league — sessions, ledgers, leaderboards, and settlement.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-grid">
|
||||||
|
|
||||||
|
<nav class="docs-nav">
|
||||||
|
<p class="kicker">Contents</p>
|
||||||
|
<a href="#leagues">Leagues</a>
|
||||||
|
<a href="#players">Players</a>
|
||||||
|
<a href="#sessions">Sessions</a>
|
||||||
|
<a href="#events">Ledger events</a>
|
||||||
|
<a href="#settlement">Settlement</a>
|
||||||
|
<a href="#leaderboard">Leaderboard & stats</a>
|
||||||
|
<a href="#visibility">Public vs private</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="docs-body">
|
||||||
|
|
||||||
|
<section class="docs-section" id="leagues">
|
||||||
|
<h2 class="docs-h2">Leagues</h2>
|
||||||
|
<p>A <strong>league</strong> is the top-level container for your home game. It holds all your players, sessions, and ledger events in one place. One account can manage multiple leagues — useful if you run separate games (e.g. a Thursday game and a weekend game).</p>
|
||||||
|
<p>When you create a league you give it a name and optional description. A unique public key is automatically generated so you can share a link to it later if you make it public.</p>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Tip:</strong> Leagues are long-lived. You don't need to create a new league every season — use <em>sessions</em> to separate individual nights of play.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="players">
|
||||||
|
<h2 class="docs-h2">Players</h2>
|
||||||
|
<p>Players are the people in your league. They <strong>do not need a myboker account</strong> — only the league owner or manager needs one. You just add names.</p>
|
||||||
|
<p>Each player has a display name that appears on the leaderboard and in ledger events. You can add an optional note (like a payment handle) to help with settlement later.</p>
|
||||||
|
<p><strong>Archiving</strong> a player hides them from the active roster but preserves all their history. You can reactivate them at any time.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Active</strong>
|
||||||
|
<span>Shows on the roster and is available to record events against.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Archived</strong>
|
||||||
|
<span>Hidden from the roster. History and stats are preserved. Reactivate any time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="sessions">
|
||||||
|
<h2 class="docs-h2">Sessions</h2>
|
||||||
|
<p>A <strong>session</strong> is a single night of poker. Sessions are date-stamped and sequenced — if you play twice on the same day, they become S1 and S2 automatically.</p>
|
||||||
|
<p>Sessions have two states: <strong>open</strong> and <strong>closed</strong>. While open, you can record buy-ins, cashouts, and other ledger events. When everyone has settled up and cashed out, you close the session. Closing doesn't delete anything — you can always reopen it.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Date</strong>
|
||||||
|
<span>The calendar date the game was played. Used to order sessions and group same-day games.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Label</strong>
|
||||||
|
<span>Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Notes</strong>
|
||||||
|
<span>Optional freeform context visible in the session list (e.g. "Holiday game", "Marcus's place").</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Open</strong>
|
||||||
|
<span>Session is live — events can be added and settlement is still in progress.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Closed</strong>
|
||||||
|
<span>Session is settled. Books are considered final. Can be reopened if needed.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="events">
|
||||||
|
<h2 class="docs-h2">Ledger events</h2>
|
||||||
|
<p>All activity in a session is recorded as <strong>ledger events</strong>. The ledger is <strong>append-only</strong> — events are never deleted. This keeps the record clean and auditable, even if you need to correct a mistake (you just add a correcting entry).</p>
|
||||||
|
<p>There are eight event types:</p>
|
||||||
|
<div class="docs-events">
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-buyin">buy-in</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player buys chips with cash.</strong>
|
||||||
|
<p>Cash comes into the pot. Records how much the player has invested. Most common event — record one every time someone puts money on the table.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-front">front</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house covers a player's buy-in.</strong>
|
||||||
|
<p>When a player doesn't have cash handy, the organizer (house) can front them chips. This creates a debt — the player owes that amount back to the house. Track fronts carefully so you know who owes what at the end of the night.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-cashout">cashout</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player exchanges their chips for their cash value.</strong>
|
||||||
|
<p>Records the chip count a player walks away with. This is the "you are owed X" event — but it doesn't mean cash has left the house yet. Use <em>paid out</em> to record the actual cash handover.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-paid_out">paid out</span>
|
||||||
|
<div>
|
||||||
|
<strong>Cash physically leaves the house to the player.</strong>
|
||||||
|
<p>Closes the loop on a cashout. Once a player has been paid out, their balance goes to $0. Record this separately from cashout because you may pay people out at different times (end of night, next day via transfer, etc.).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_in">rollover in</span>
|
||||||
|
<div>
|
||||||
|
<strong>Chips carried in from a previous session.</strong>
|
||||||
|
<p>If a player didn't cash out at the end of a session and instead carries their chip stack forward to the next game, record a rollover in at the start of the new session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_out">rollover out</span>
|
||||||
|
<div>
|
||||||
|
<strong>Chips carried out to a future session.</strong>
|
||||||
|
<p>Recorded when a player leaves a session without cashing out, intending to carry their stack forward. Paired with a rollover in at the next session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-debt_repayment">debt repayment</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player repays an outstanding front.</strong>
|
||||||
|
<p>When a player who was fronted chips pays the house back (cash, transfer, etc.), record a debt repayment. This reduces their outstanding balance with the house.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-writeoff">write-off</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house forgives a debt.</strong>
|
||||||
|
<p>If a front won't be collected (player left, debt forgiven, etc.), write it off. The debt is cleared from the open balance without cash changing hands. This is irreversible — add a note explaining why.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="settlement">
|
||||||
|
<h2 class="docs-h2">Settlement</h2>
|
||||||
|
<p>The ledger tracks two types of open balances at all times:</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to players</strong>
|
||||||
|
<span>Cash the house owes to players who have cashed out but haven't been paid yet. Pay these out to clear the balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to house</strong>
|
||||||
|
<span>Cash players owe to the house from unpaid fronts. Collect debt repayments or write these off to clear the balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash in</strong>
|
||||||
|
<span>Total real cash that has come into the pot across all sessions — buy-ins and repaid fronts only, not fronts themselves.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash paid out</strong>
|
||||||
|
<span>Total real cash that has left the house to players. Ideally this approaches cash in over time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Fronts vs buy-ins:</strong> A front doesn't add to "cash in" because no cash actually entered the pot — the house covered it. Cash in only counts real money on the table.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="leaderboard">
|
||||||
|
<h2 class="docs-h2">Leaderboard & stats</h2>
|
||||||
|
<p>The leaderboard ranks players by their all-time performance. Stats are computed from ledger events, not manually entered.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Net</strong>
|
||||||
|
<span>Total cash out minus total cash in across all sessions. Positive means up, negative means down.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>ROI</strong>
|
||||||
|
<span>Net divided by total invested, expressed as a percentage. Compares performance independent of how much someone plays.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Win rate</strong>
|
||||||
|
<span>Percentage of sessions where the player finished with a positive net. Ties (exactly break even) count as a loss.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Sessions played</strong>
|
||||||
|
<span>Number of sessions with at least one ledger event for this player.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest win</strong>
|
||||||
|
<span>The single session where the player netted the most profit.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest loss</strong>
|
||||||
|
<span>The single session where the player lost the most money.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="visibility">
|
||||||
|
<h2 class="docs-h2">Public vs private</h2>
|
||||||
|
<p>Each league has a <strong>visibility</strong> setting you can change in league settings.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Private</strong>
|
||||||
|
<span>Only members (owners and managers) can see this league. The league won't appear on the Explore page. This is the default.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Public</strong>
|
||||||
|
<span>Anyone with the link — or who finds it on the Explore page — can view the leaderboard and session history. No account required to view. Members still need an account to manage it.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Note:</strong> Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or account emails.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Explore leagues · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="explore-header">
|
||||||
|
<p class="explore-header__eyebrow">Public leagues</p>
|
||||||
|
<h1 class="explore-header__title">Explore</h1>
|
||||||
|
<p class="explore-header__sub">Browse poker leagues that have made their results publicly visible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="{{ url_for('public.explore') }}" class="explore-search-row">
|
||||||
|
<input class="field-input" type="text" name="q" value="{{ q }}" placeholder="Search by league name…" autocomplete="off">
|
||||||
|
<button class="btn btn--primary" type="submit">Search</button>
|
||||||
|
{% if q %}<a class="btn btn--ghost" href="{{ url_for('public.explore') }}">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if leagues %}
|
||||||
|
<div class="explore-cards">
|
||||||
|
{% for league in leagues %}
|
||||||
|
{% set c = counts.get(league.id, {}) %}
|
||||||
|
<a class="ex-card" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">
|
||||||
|
<div class="ex-card__initial">{{ league.name[0] | upper }}</div>
|
||||||
|
<h2 class="ex-card__name">{{ league.name }}</h2>
|
||||||
|
{% if league.description %}
|
||||||
|
<p class="ex-card__desc">{{ league.description }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="ex-card__desc" style="opacity:.35;">No description</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="ex-card__stats">
|
||||||
|
<div class="ex-card__stat">
|
||||||
|
<span class="kicker">Players</span>
|
||||||
|
<strong>{{ c.get('players', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="ex-card__stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<strong>{{ c.get('sessions', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
{% if c.get('open_sessions', 0) > 0 %}
|
||||||
|
<div class="ex-card__stat">
|
||||||
|
<span class="kicker">Live</span>
|
||||||
|
<strong style="color:var(--pos);">{{ c.get('open_sessions', 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ex-card__footer">
|
||||||
|
<span class="ex-card__link">View league →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif q %}
|
||||||
|
<div class="panel empty-panel" style="max-width:480px;">
|
||||||
|
<h3 class="panel__title">No results for "{{ q }}"</h3>
|
||||||
|
<p class="muted-text">Try a shorter search or browse all leagues.</p>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('public.explore') }}">Browse all</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel empty-panel" style="max-width:480px;">
|
||||||
|
<h3 class="panel__title">No public leagues yet</h3>
|
||||||
|
<p class="muted-text">League owners can set their league to public in league settings.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Forgot Password · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account recovery</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Forgot password</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Enter your email and we'll send a reset link if an account exists.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" autocomplete="email" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Send reset link</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('account.login') }}">Back to login</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Overview · Admin · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header int-page-header">
|
||||||
|
<div>
|
||||||
|
<div class="db-header__chips">
|
||||||
|
<span class="badge badge--paid">Site Admin</span>
|
||||||
|
{% if stats.open_sessions > 0 %}
|
||||||
|
<span class="badge badge--open">{{ stats.open_sessions }} live session{{ 's' if stats.open_sessions != 1 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h1 class="db-header__title">Overview</h1>
|
||||||
|
<p class="db-header__sub">Signed in as {{ admin_user.email }}. Site health, growth, and recent activity at a glance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero metrics -->
|
||||||
|
<div class="int-hero-grid">
|
||||||
|
<div class="int-hero-card">
|
||||||
|
<div class="int-hero-card__icon int-hero-card__icon--users">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||||
|
<path d="M13 18v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 1 16.5V18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<circle cx="7" cy="6.5" r="3.5" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M17 18v-1.5a3.5 3.5 0 0 0-2.5-3.37M13.5 3.13a3.5 3.5 0 0 1 0 6.74" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="int-hero-card__num">{{ stats.users }}</span>
|
||||||
|
<span class="int-hero-card__label">Total users</span>
|
||||||
|
<span class="int-hero-card__sub">{{ stats.active_users }} active · +{{ stats.new_users_7d }} this week</span>
|
||||||
|
</div>
|
||||||
|
<div class="int-hero-card">
|
||||||
|
<div class="int-hero-card__icon int-hero-card__icon--leagues">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||||
|
<path d="M2 18V8l8-5 8 5v10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 18v-6h6v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="int-hero-card__num">{{ stats.active_leagues }}</span>
|
||||||
|
<span class="int-hero-card__label">Active leagues</span>
|
||||||
|
<span class="int-hero-card__sub">{{ stats.public_leagues }} public · {{ stats.archived_leagues }} archived</span>
|
||||||
|
</div>
|
||||||
|
<div class="int-hero-card">
|
||||||
|
<div class="int-hero-card__icon int-hero-card__icon--sessions">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||||
|
<rect x="2" y="3" width="16" height="14" rx="2.5" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M6 1v4M14 1v4M2 8h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="int-hero-card__num">{{ stats.sessions }}</span>
|
||||||
|
<span class="int-hero-card__label">Total sessions</span>
|
||||||
|
<span class="int-hero-card__sub">{{ stats.open_sessions }} open · {{ stats.sessions_7d }} this week · {{ '%.1f'|format(stats.avg_players_per_session) }} avg players</span>
|
||||||
|
</div>
|
||||||
|
<div class="int-hero-card">
|
||||||
|
<div class="int-hero-card__icon int-hero-card__icon--health">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||||
|
<path d="M10 2.5l6.5 3v4.7c0 4.1-2.75 7-6.5 7.8-3.75-.8-6.5-3.7-6.5-7.8V5.5l6.5-3z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||||
|
<path d="M7.2 10.1l1.8 1.8 3.9-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="int-hero-card__num">{{ stats.active_users_30d }}</span>
|
||||||
|
<span class="int-hero-card__label">Active users · 30d</span>
|
||||||
|
<span class="int-hero-card__sub">{{ stats.users_in_leagues }} in leagues · {{ stats.orphan_users }} solo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="int-trend-grid int-section-grid">
|
||||||
|
{% for card in trend_cards %}
|
||||||
|
<section class="int-trend-card">
|
||||||
|
<div>
|
||||||
|
<span class="int-trend-card__label">{{ card.label }}</span>
|
||||||
|
<span class="int-trend-card__value">{{ card.value }}</span>
|
||||||
|
<span class="int-trend-card__delta int-trend-card__delta--{{ card.delta.direction }}">
|
||||||
|
{% if card.delta.pct is none %}
|
||||||
|
New
|
||||||
|
{% else %}
|
||||||
|
{{ '+' if card.delta.current > card.delta.previous else '' }}{{ '%.1f'|format(card.delta.pct) }}%
|
||||||
|
{% endif %}
|
||||||
|
<span>vs previous</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="int-sparkline-wrap">
|
||||||
|
<canvas id="trend-{{ card.key }}"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row 1: growth -->
|
||||||
|
<div class="cards-2 int-section-grid">
|
||||||
|
<section class="panel int-chart-panel">
|
||||||
|
<div class="int-chart-head">
|
||||||
|
<p class="kicker" style="margin:0;">Growth</p>
|
||||||
|
<h2 class="panel__title">User signups · 8 weeks</h2>
|
||||||
|
</div>
|
||||||
|
<div class="int-chart-wrap">
|
||||||
|
<canvas id="chart-users"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel int-chart-panel">
|
||||||
|
<div class="int-chart-head">
|
||||||
|
<p class="kicker" style="margin:0;">Activity</p>
|
||||||
|
<h2 class="panel__title">Sessions per week · 8 weeks</h2>
|
||||||
|
</div>
|
||||||
|
<div class="int-chart-wrap">
|
||||||
|
<canvas id="chart-sessions"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row 2: leagues + weekday -->
|
||||||
|
<div class="cards-2 int-section-grid">
|
||||||
|
<section class="panel int-chart-panel">
|
||||||
|
<div class="int-chart-head">
|
||||||
|
<p class="kicker" style="margin:0;">Growth</p>
|
||||||
|
<h2 class="panel__title">New leagues per week · 8 weeks</h2>
|
||||||
|
</div>
|
||||||
|
<div class="int-chart-wrap">
|
||||||
|
<canvas id="chart-leagues"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel int-chart-panel">
|
||||||
|
<div class="int-chart-head">
|
||||||
|
<p class="kicker" style="margin:0;">Patterns</p>
|
||||||
|
<h2 class="panel__title">Sessions by day of week</h2>
|
||||||
|
</div>
|
||||||
|
<div class="int-chart-wrap">
|
||||||
|
<canvas id="chart-weekday"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breakdown + top leagues -->
|
||||||
|
<div class="cards-2 int-section-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Activity breakdown</h2>
|
||||||
|
<span class="panel__tag">7d</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="int-breakdown-grid">
|
||||||
|
<div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">New users (7d)</span><span class="int-breakdown-row__val">{{ stats.new_users_7d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Active users (7d)</span><span class="int-breakdown-row__val">{{ stats.active_users_7d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Active users (30d)</span><span class="int-breakdown-row__val">{{ stats.active_users_30d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Users in leagues</span><span class="int-breakdown-row__val">{{ stats.users_in_leagues }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Unverified accounts</span><span class="int-breakdown-row__val">{{ stats.unverified_users }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Disabled accounts</span><span class="int-breakdown-row__val">{{ stats.disabled_users }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Sessions (7d)</span><span class="int-breakdown-row__val">{{ stats.sessions_7d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Open sessions</span><span class="int-breakdown-row__val">{{ stats.open_sessions }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Leagues active (7d)</span><span class="int-breakdown-row__val">{{ stats.leagues_active_7d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Avg players / session</span><span class="int-breakdown-row__val">{{ '%.1f'|format(stats.avg_players_per_session) }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Ledger events (7d)</span><span class="int-breakdown-row__val">{{ stats.ledger_events_7d }}</span></div>
|
||||||
|
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Avg sessions / league</span><span class="int-breakdown-row__val">{{ '%.1f'|format(stats.avg_sessions_per_active_league) }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Top leagues</h2>
|
||||||
|
<a class="panel__tag" href="{{ url_for('internal.leagues') }}">All leagues →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>League</th><th>Owner</th><th style="text-align:right;">Sessions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league, session_count in top_leagues %}
|
||||||
|
{% set owner = owners.get(league.id) %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a>
|
||||||
|
{% if league.visibility == 'public' %}<span class="badge" style="font-size:10px;margin-left:4px;">public</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{% if owner %}{{ owner.email }}{% else %}<span class="muted-text">—</span>{% endif %}</td>
|
||||||
|
<td style="text-align:right;font-variant-numeric:tabular-nums;font-weight:600;">{{ session_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="muted-text">No leagues yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel int-section-grid">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Totals at a glance</h2>
|
||||||
|
<span class="panel__tag">All time</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="int-summary-grid">
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">{{ stats.users }}</span><span class="int-summary-item__label">Total users</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">{{ stats.leagues }}</span><span class="int-summary-item__label">Total leagues</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">{{ stats.sessions }}</span><span class="int-summary-item__label">Total sessions</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">{{ stats.ledger_events }}</span><span class="int-summary-item__label">Ledger events</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">${{ '%.0f'|format(stats.cash_in_cents / 100) }}</span><span class="int-summary-item__label">Recorded buy-ins</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">${{ '%.0f'|format(stats.cash_out_cents / 100) }}</span><span class="int-summary-item__label">Recorded payouts</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">${{ '%.0f'|format(stats.avg_cash_in_per_session_cents / 100) }}</span><span class="int-summary-item__label">Avg volume / session</span></div>
|
||||||
|
<div class="int-summary-item"><span class="int-summary-item__val">{{ stats.verified_users }}</span><span class="int-summary-item__label">Verified accounts</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent users + leagues -->
|
||||||
|
<div class="cards-2 int-section-grid int-section-grid--last">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Recent users</h2>
|
||||||
|
<a class="panel__tag" href="{{ url_for('internal.users') }}">All users →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Email</th><th>Status</th><th>Joined</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in recent_users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('internal.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||||
|
{% if user.is_site_admin %}<span class="badge badge--paid" style="font-size:10px;">Admin</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% elif not user.email_verified_at %}<span class="badge">Unverified</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px;">{{ user.created_at.strftime('%b %-d') if user.created_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Recent leagues</h2>
|
||||||
|
<a class="panel__tag" href="{{ url_for('internal.leagues') }}">All leagues →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>League</th><th>Visibility</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league in recent_leagues %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||||
|
<td><span class="badge">{{ league.visibility }}</span></td>
|
||||||
|
<td style="color:var(--muted);font-size:12px;">{{ league.created_at.strftime('%b %-d') if league.created_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const labels = {{ growth.labels | tojson }};
|
||||||
|
const userSignups = {{ growth.user_signups | tojson }};
|
||||||
|
const sessionData = {{ growth.session_counts | tojson }};
|
||||||
|
const leagueData = {{ growth.league_counts | tojson }};
|
||||||
|
const wdLabels = {{ weekday_data.labels | tojson }};
|
||||||
|
const wdData = {{ weekday_data.data | tojson }};
|
||||||
|
const trendCards = {{ trend_cards | tojson }};
|
||||||
|
|
||||||
|
const axisStyle = {
|
||||||
|
grid: { color: 'rgba(255,255,255,.05)' },
|
||||||
|
ticks: { color: 'rgba(255,255,255,.38)', font: { size: 11 } },
|
||||||
|
};
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: 'rgba(20,20,28,.92)',
|
||||||
|
borderColor: 'rgba(255,255,255,.08)',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: 'rgba(255,255,255,.5)',
|
||||||
|
bodyColor: '#fff',
|
||||||
|
padding: 10,
|
||||||
|
};
|
||||||
|
const baseOpts = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: tooltipStyle },
|
||||||
|
scales: {
|
||||||
|
x: axisStyle,
|
||||||
|
y: { ...axisStyle, beginAtZero: true, ticks: { ...axisStyle.ticks, precision: 0 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineDataset = (data, color) => ({
|
||||||
|
data,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color.replace('1)', '.12)'),
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
tension: .35,
|
||||||
|
fill: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('chart-users'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [lineDataset(userSignups, 'rgba(155,140,240,1)')]
|
||||||
|
},
|
||||||
|
options: baseOpts,
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('chart-sessions'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [lineDataset(sessionData, 'rgba(111,192,147,1)')]
|
||||||
|
},
|
||||||
|
options: baseOpts,
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('chart-leagues'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{ data: leagueData, backgroundColor: 'rgba(111,192,147,.45)', borderColor: 'rgba(111,192,147,.9)', borderWidth: 1, borderRadius: 4 }]
|
||||||
|
},
|
||||||
|
options: baseOpts,
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('chart-weekday'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: wdLabels,
|
||||||
|
datasets: [{ data: wdData, backgroundColor: 'rgba(224,177,92,.45)', borderColor: 'rgba(224,177,92,.9)', borderWidth: 1, borderRadius: 4 }]
|
||||||
|
},
|
||||||
|
options: baseOpts,
|
||||||
|
});
|
||||||
|
|
||||||
|
trendCards.forEach((card) => {
|
||||||
|
const canvas = document.getElementById('trend-' + card.key);
|
||||||
|
if (!canvas) return;
|
||||||
|
new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: card.labels,
|
||||||
|
datasets: [lineDataset(card.series, 'rgba(155,140,240,1)')]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: { x: { display: false }, y: { display: false, beginAtZero: true } },
|
||||||
|
elements: { point: { radius: 0 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ league.name }} · Admin · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header" style="margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<div class="db-header__chips">
|
||||||
|
<a class="badge" href="{{ url_for('internal.leagues') }}">← Leagues</a>
|
||||||
|
{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}
|
||||||
|
<span class="badge">{{ league.visibility }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="db-header__title">{{ league.name }}</h1>
|
||||||
|
<p class="db-header__sub">Owner: {% if owner %}<a href="{{ url_for('internal.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>{% else %}<span class="muted-text">missing</span>{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Manage league</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-stat-grid">
|
||||||
|
<div class="db-stat"><span class="db-stat__num">{{ stats.members }}</span><span class="db-stat__label">Active members</span></div>
|
||||||
|
<div class="db-stat"><span class="db-stat__num">{{ stats.players }}</span><span class="db-stat__label">Players</span></div>
|
||||||
|
<div class="db-stat"><span class="db-stat__num">{{ stats.sessions }}</span><span class="db-stat__label">Sessions</span></div>
|
||||||
|
<div class="db-stat"><span class="db-stat__num">{{ stats.events }}</span><span class="db-stat__label">Ledger events</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards-2">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Members</h2>
|
||||||
|
<span class="panel__tag">{{ members|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>Email</th><th>Role</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership, member_user in members %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('internal.user_detail', user_id=member_user.id) }}">{{ member_user.email }}</a></td>
|
||||||
|
<td>{{ membership.role }}</td>
|
||||||
|
<td>{% if membership.disabled_at %}<span class="badge badge--closed">Removed</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="muted-text">No members.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">League settings</h2>
|
||||||
|
<span class="panel__tag">Internal manage</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<form method="post" action="{{ url_for('internal.update_league_settings', league_id=league.id) }}" class="form-card form-stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="field-label" for="league-name">Name</label>
|
||||||
|
<input class="field-input" id="league-name" name="name" value="{{ league.name }}" required>
|
||||||
|
|
||||||
|
<label class="field-label" for="league-description">Description</label>
|
||||||
|
<textarea class="field-input" id="league-description" name="description" rows="3">{{ league.description or '' }}</textarea>
|
||||||
|
|
||||||
|
<label class="field-label" for="league-visibility">Visibility</label>
|
||||||
|
<select class="field-input" id="league-visibility" name="visibility">
|
||||||
|
<option value="private" {% if league.visibility == 'private' %}selected{% endif %}>Private</option>
|
||||||
|
<option value="public" {% if league.visibility == 'public' %}selected{% endif %}>Public</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="inline-form">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="eligible-min">Eligible minimum</label>
|
||||||
|
<input class="field-input" id="eligible-min" type="number" min="1" max="100" name="eligible_min_sessions" value="{{ league.eligible_min_sessions }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="break-even">Break-even dollars</label>
|
||||||
|
<input class="field-input" id="break-even" type="number" min="0" max="100" step="0.01" name="break_even_dollars" value="{{ '%.2f'|format(league.break_even_cents / 100) }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards-2">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">League actions</h2>
|
||||||
|
<span class="panel__tag">Support tools</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body form-stack">
|
||||||
|
<form method="post" action="{{ url_for('internal.transfer_league_owner', league_id=league.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="field-label" for="new-owner-email">Transfer ownership to active user</label>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input class="field-input" id="new-owner-email" type="email" name="new_owner_email" placeholder="owner@example.com" required>
|
||||||
|
<button class="btn btn--warning btn--sm" type="submit">Transfer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if league.archived_at %}
|
||||||
|
<form method="post" action="{{ url_for('internal.restore_league', league_id=league.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Restore league</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('internal.archive_league', league_id=league.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--warning btn--sm" type="submit">Archive league</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('internal.delete_internal_league', league_id=league.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="field-label" for="confirm-delete-league">Type DELETE to permanently delete this league</label>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input class="field-input" id="confirm-delete-league" name="confirm" autocomplete="off">
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leagues · Admin · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header int-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="db-header__title">Leagues</h1>
|
||||||
|
<p class="db-header__sub">Search by league name, slug, or owner email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-list-tools">
|
||||||
|
<form class="admin-searchbar" method="get" action="{{ url_for('internal.leagues') }}">
|
||||||
|
<input class="field-input" type="search" name="q" value="{{ q }}" placeholder="Search league or owner">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Search</button>
|
||||||
|
{% if q %}<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues') }}">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel admin-list-panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Matching leagues</h2>
|
||||||
|
<span class="panel__tag">{{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>League</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league in leagues %}
|
||||||
|
{% set owner = owners.get(league.id) %}
|
||||||
|
<tr>
|
||||||
|
<td class="admin-table__primary"><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||||
|
<td>{% if owner %}<a href="{{ url_for('internal.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>{% else %}<span class="muted-text">Missing</span>{% endif %}</td>
|
||||||
|
<td><span class="badge">{{ league.visibility }}</span></td>
|
||||||
|
<td>{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('internal.league_detail', league_id=league.id) }}">View</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted-text">No leagues found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="pagination-row">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues', q=q, page=pagination.prev_page) }}">Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">Previous</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="pagination-row__label">Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues', q=q, page=pagination.next_page) }}">Next</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">Next</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ user.email }} · Admin · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header" style="margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<div class="db-header__chips">
|
||||||
|
<a class="badge" href="{{ url_for('internal.users') }}">← Users</a>
|
||||||
|
{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}
|
||||||
|
{% if user.is_site_admin %}<span class="badge badge--paid">Site admin</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<h1 class="db-header__title">{{ user.email }}</h1>
|
||||||
|
<p class="db-header__sub">Created {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'unknown' }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-stat-grid">
|
||||||
|
<div class="db-stat">
|
||||||
|
<span class="db-stat__num">{{ 'Yes' if user.email_verified_at else 'No' }}</span>
|
||||||
|
<span class="db-stat__label">Email verified</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<span class="db-stat__num">{{ memberships|length }}</span>
|
||||||
|
<span class="db-stat__label">League memberships</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<span class="db-stat__num">{{ owned_leagues|length }}</span>
|
||||||
|
<span class="db-stat__label">Owned leagues</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<span class="db-stat__num">{{ event_count }}</span>
|
||||||
|
<span class="db-stat__label">Ledger events created</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards-2">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Account</h2>
|
||||||
|
<span class="panel__tag">Support tools</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body form-stack">
|
||||||
|
<form method="post" action="{{ url_for('internal.update_user_email', user_id=user.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="field-label" for="email">Email</label>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input class="field-input" id="email" type="email" name="email" value="{{ user.email }}" required>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Update</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<p class="muted-text">Last login: {{ user.last_login_at.strftime('%Y-%m-%d %H:%M') if user.last_login_at else 'Never' }}</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<form method="post" action="{{ url_for('internal.send_user_password_reset', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit" {% if user.disabled_at %}disabled{% endif %}>Email reset link</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('internal.create_user_temporary_password', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit" {% if user.disabled_at %}disabled{% endif %}>Email temp password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Danger zone</h2>
|
||||||
|
<span class="panel__tag">Account state</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body form-stack">
|
||||||
|
{% if user.disabled_at %}
|
||||||
|
<form method="post" action="{{ url_for('internal.restore_user', user_id=user.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Restore account</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('internal.disable_user', user_id=user.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--warning btn--sm" type="submit" {% if user.id == current_admin_id %}disabled{% endif %}>Disable account</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('internal.delete_user', user_id=user.id) }}" class="form-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label class="field-label" for="confirm-delete-user">Type DELETE to permanently delete this account</label>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input class="field-input" id="confirm-delete-user" name="confirm" autocomplete="off">
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit" {% if user.id == current_admin_id %}disabled{% endif %}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards-2">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Memberships</h2>
|
||||||
|
<span class="panel__tag">{{ memberships|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>League</th><th>Role</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership, league in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||||
|
<td>{{ membership.role }}</td>
|
||||||
|
<td>{% if membership.disabled_at %}<span class="badge badge--closed">Removed</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="muted-text">No memberships.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Owned leagues</h2>
|
||||||
|
<span class="panel__tag">{{ owned_leagues|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th>League</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league in owned_leagues %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||||
|
<td>{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Manage</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="muted-text">No owned leagues.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users · Admin · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header int-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="db-header__title">Users</h1>
|
||||||
|
<p class="db-header__sub">Search accounts by email and inspect account status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-list-tools">
|
||||||
|
<form class="admin-searchbar" method="get" action="{{ url_for('internal.users') }}">
|
||||||
|
<input class="field-input" type="search" name="q" value="{{ q }}" placeholder="Search email">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Search</button>
|
||||||
|
{% if q %}<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users') }}">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel admin-list-panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<h2 class="panel__title">Matching users</h2>
|
||||||
|
<span class="panel__tag">{{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Verified</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="admin-table__primary">
|
||||||
|
<a href="{{ url_for('internal.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||||
|
{% if user.is_site_admin %}<span class="badge badge--paid">Admin</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||||
|
<td>{% if user.email_verified_at %}Yes{% else %}<span class="muted-text">No</span>{% endif %}</td>
|
||||||
|
<td class="admin-table__muted">{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}</td>
|
||||||
|
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('internal.user_detail', user_id=user.id) }}">View</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted-text">No users found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="pagination-row">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users', q=q, page=pagination.prev_page) }}">Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">Previous</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="pagination-row__label">Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users', q=q, page=pagination.next_page) }}">Next</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">Next</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}myboker.org · Poker league records{% endblock %}
|
||||||
|
{% block page_class %}page--landing{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="lp-hero">
|
||||||
|
<div class="lp-hero__content">
|
||||||
|
<p class="lp-kicker">Home Poker Tracking</p>
|
||||||
|
<h1 class="lp-h1">
|
||||||
|
<span class="lp-h1__line">Your</span>
|
||||||
|
<span class="lp-h1__line">league,</span>
|
||||||
|
<span class="lp-h1__accent">on record.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="lp-sub">Sessions, leaderboards, and settlement tracking for home poker groups. No fees, no payment apps in the way.</p>
|
||||||
|
<div class="lp-cta">
|
||||||
|
{% if is_logged_in %}
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('leagues.index') }}">Open my leagues →</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Start your league →</a>
|
||||||
|
<a class="btn btn--ghost btn--lg" href="{{ url_for('account.login') }}">Sign in</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="lp-trust">
|
||||||
|
<span>No fees</span>
|
||||||
|
<span class="lp-trust__sep">·</span>
|
||||||
|
<span>Append-only ledger</span>
|
||||||
|
<span class="lp-trust__sep">·</span>
|
||||||
|
<span>Private by default</span>
|
||||||
|
{% if not is_logged_in %}
|
||||||
|
<span class="lp-trust__sep">·</span>
|
||||||
|
<a href="{{ url_for('public.explore') }}" style="color:inherit;text-decoration:underline;text-decoration-color:rgba(255,255,255,.2);">Explore public leagues</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lp-hero__visual" aria-hidden="true">
|
||||||
|
<div class="lp-demo__card">
|
||||||
|
<div class="lp-demo__head">
|
||||||
|
<div>
|
||||||
|
<span class="lp-demo__kicker">Friday Night Poker · Session #006</span>
|
||||||
|
<span class="lp-demo__title">Live event log <span class="lp-live-dot">● live</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-demo__meta">Jun 21 · 5 players</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__rows">
|
||||||
|
<div class="lp-feed__row">
|
||||||
|
<span class="lp-feed__time">10:14</span>
|
||||||
|
<span class="lp-feed__player">Rivera</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
|
||||||
|
<span class="lp-feed__amt">$100</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__row">
|
||||||
|
<span class="lp-feed__time">10:22</span>
|
||||||
|
<span class="lp-feed__player">Chen</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
|
||||||
|
<span class="lp-feed__amt">$50</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__row">
|
||||||
|
<span class="lp-feed__time">10:41</span>
|
||||||
|
<span class="lp-feed__player">Okafor</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--buyin">buy-in</span>
|
||||||
|
<span class="lp-feed__amt">$50</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__row">
|
||||||
|
<span class="lp-feed__time">11:56</span>
|
||||||
|
<span class="lp-feed__player">Novak</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--cashout">cashout</span>
|
||||||
|
<span class="lp-feed__amt lp-demo__num--pos">$160</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__row">
|
||||||
|
<span class="lp-feed__time">12:02</span>
|
||||||
|
<span class="lp-feed__player">Rivera</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--rebuy">re-buy</span>
|
||||||
|
<span class="lp-feed__amt">$50</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__row lp-demo__row--fade">
|
||||||
|
<span class="lp-feed__time">12:08</span>
|
||||||
|
<span class="lp-feed__player">Walsh</span>
|
||||||
|
<span class="lp-feed__pill lp-feed__pill--cashout">cashout</span>
|
||||||
|
<span class="lp-feed__amt lp-demo__num--neg">$30</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__summary">
|
||||||
|
<div class="lp-feed__summary-cell">
|
||||||
|
<span class="lp-recon-label">Cash in</span>
|
||||||
|
<span class="lp-recon-val">$350</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__summary-cell">
|
||||||
|
<span class="lp-recon-label">Paid out</span>
|
||||||
|
<span class="lp-recon-val lp-demo__num--pos">$190</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feed__summary-cell">
|
||||||
|
<span class="lp-recon-label">House holds</span>
|
||||||
|
<span class="lp-recon-val lp-demo__num--neg">$160</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lp-stats-bar">
|
||||||
|
<div class="lp-stats-bar__inner">
|
||||||
|
<div class="lp-stats-bar__item">
|
||||||
|
<span class="lp-stats-bar__val">$0</span>
|
||||||
|
<span class="lp-stats-bar__lbl">Platform fee, always</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-stats-bar__sep"></span>
|
||||||
|
<div class="lp-stats-bar__item">
|
||||||
|
<span class="lp-stats-bar__val">∞</span>
|
||||||
|
<span class="lp-stats-bar__lbl">Sessions & players</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-stats-bar__sep"></span>
|
||||||
|
<div class="lp-stats-bar__item">
|
||||||
|
<span class="lp-stats-bar__val">Append-only</span>
|
||||||
|
<span class="lp-stats-bar__lbl">Tamper-proof ledger</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-stats-bar__sep"></span>
|
||||||
|
<div class="lp-stats-bar__item">
|
||||||
|
<span class="lp-stats-bar__val">Private</span>
|
||||||
|
<span class="lp-stats-bar__lbl">Your data, your league</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="lp-features">
|
||||||
|
<div class="lp-features__inner">
|
||||||
|
<div class="lp-sec-head">
|
||||||
|
<span class="lp-eyebrow">What's included</span>
|
||||||
|
<h2 class="lp-sec-title">No spreadsheets. No Venmo math. No guessing.</h2>
|
||||||
|
<p class="lp-sec-sub">Everything your group needs to run a real poker league — built from the ground up for home games.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feat-grid">
|
||||||
|
<div class="lp-feat">
|
||||||
|
<div class="lp-feat__icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2" y="4" width="16" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M6 2v4M14 2v4M2 9h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M6 12.5h3M6 15.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="lp-feat__title">Game-night sessions</h3>
|
||||||
|
<p class="lp-feat__desc">Open a session at the start of the night, log buy-ins and cashouts as they happen, then close it when the last player leaves. Multiple sessions on the same date auto-sequence as S1, S2.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feat">
|
||||||
|
<div class="lp-feat__icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1.5" y="11" width="4.5" height="7" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<rect x="7.75" y="7" width="4.5" height="11" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<rect x="14" y="2" width="4.5" height="16" rx="1.2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="lp-feat__title">Rankings that earn their spot</h3>
|
||||||
|
<p class="lp-feat__desc">Set a minimum session count so regulars rank and one-time guests go provisional until they qualify. Sort by net, ROI, win rate, or recent form. Every number is derived directly from raw ledger events.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lp-feat">
|
||||||
|
<div class="lp-feat__icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="2" width="14" height="16" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M7 7h6M7 10.5h6M7 14h3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="lp-feat__title">Settlement without the spreadsheet</h3>
|
||||||
|
<p class="lp-feat__desc">Every event is logged and kept: buy-ins, cashouts, fronts, corrections. Nothing is deleted; mistakes get fixed by appending. When a session closes, the house knows exactly what it holds and who is owed what.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lp-showcase">
|
||||||
|
<div class="lp-showcase__inner">
|
||||||
|
<div class="lp-sec-head">
|
||||||
|
<span class="lp-eyebrow">In the app</span>
|
||||||
|
<h2 class="lp-sec-title">Your game. Your data. Always in sync.</h2>
|
||||||
|
<p class="lp-sec-sub">Every number your group tracks, in one place — from the moment chips hit the table.</p>
|
||||||
|
</div>
|
||||||
|
<div class="lp-showcase__grid" aria-hidden="true">
|
||||||
|
|
||||||
|
<div class="lp-demo__card">
|
||||||
|
<div class="lp-demo__head">
|
||||||
|
<div>
|
||||||
|
<span class="lp-demo__kicker">Friday Night Poker</span>
|
||||||
|
<span class="lp-demo__title">Sessions</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-demo__meta">6 total</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-list">
|
||||||
|
<div class="lp-sess-row">
|
||||||
|
<div class="lp-sess-date">
|
||||||
|
<span class="lp-sess-date__mon">Jun</span>
|
||||||
|
<span class="lp-sess-date__day">21</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-info">
|
||||||
|
<span class="lp-sess-label">Session 1</span>
|
||||||
|
<span class="lp-sess-meta">5 players · $250 pot</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-sess-pill lp-sess-pill--open">open</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-row">
|
||||||
|
<div class="lp-sess-date">
|
||||||
|
<span class="lp-sess-date__mon">Jun</span>
|
||||||
|
<span class="lp-sess-date__day">14</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-info">
|
||||||
|
<span class="lp-sess-label">Session 1</span>
|
||||||
|
<span class="lp-sess-meta">5 players · $300 pot</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-row">
|
||||||
|
<div class="lp-sess-date">
|
||||||
|
<span class="lp-sess-date__mon">Jun</span>
|
||||||
|
<span class="lp-sess-date__day">7</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-info">
|
||||||
|
<span class="lp-sess-label">Session 1</span>
|
||||||
|
<span class="lp-sess-meta">4 players · $200 pot</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-row lp-sess-row--fade">
|
||||||
|
<div class="lp-sess-date">
|
||||||
|
<span class="lp-sess-date__mon">May</span>
|
||||||
|
<span class="lp-sess-date__day">31</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-info">
|
||||||
|
<span class="lp-sess-label">Session 1</span>
|
||||||
|
<span class="lp-sess-meta">6 players · $350 pot</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-row lp-sess-row--fade2">
|
||||||
|
<div class="lp-sess-date">
|
||||||
|
<span class="lp-sess-date__mon">May</span>
|
||||||
|
<span class="lp-sess-date__day">24</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-sess-info">
|
||||||
|
<span class="lp-sess-label">Session 1</span>
|
||||||
|
<span class="lp-sess-meta">5 players · $250 pot</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-sess-pill lp-sess-pill--closed">closed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lp-demo__card">
|
||||||
|
<div class="lp-demo__head">
|
||||||
|
<div>
|
||||||
|
<span class="lp-demo__kicker">Friday Night Poker</span>
|
||||||
|
<span class="lp-demo__title">Session #006 · Jun 21</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-demo__meta">5 players · open</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-recon-grid">
|
||||||
|
<div class="lp-recon-cell">
|
||||||
|
<span class="lp-recon-label">Cash in</span>
|
||||||
|
<span class="lp-recon-val">$300</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-recon-cell">
|
||||||
|
<span class="lp-recon-label">Paid out</span>
|
||||||
|
<span class="lp-recon-val lp-demo__num--pos">$190</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-recon-cell">
|
||||||
|
<span class="lp-recon-label">House owes players</span>
|
||||||
|
<span class="lp-recon-val lp-demo__num--neg">$110</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-recon-cell">
|
||||||
|
<span class="lp-recon-label">Players owe house</span>
|
||||||
|
<span class="lp-recon-val">$0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-thead">
|
||||||
|
<span>Player</span>
|
||||||
|
<span>Invested</span>
|
||||||
|
<span>Net</span>
|
||||||
|
<span>Open</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-row">
|
||||||
|
<span class="lp-demo__player">Rivera</span>
|
||||||
|
<span class="lp-demo__num">$50</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+$120</span>
|
||||||
|
<span class="lp-result-due">due $120</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-row">
|
||||||
|
<span class="lp-demo__player">Chen</span>
|
||||||
|
<span class="lp-demo__num">$100</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+$90</span>
|
||||||
|
<span class="lp-result-due">due $90</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-row">
|
||||||
|
<span class="lp-demo__player">Walsh</span>
|
||||||
|
<span class="lp-demo__num">$50</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">-$50</span>
|
||||||
|
<span class="lp-result-settled">settled</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-row lp-demo__row--fade">
|
||||||
|
<span class="lp-demo__player">Okafor</span>
|
||||||
|
<span class="lp-demo__num">$50</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+$30</span>
|
||||||
|
<span class="lp-result-due">due $30</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-result-row lp-demo__row--fade2">
|
||||||
|
<span class="lp-demo__player">Novak</span>
|
||||||
|
<span class="lp-demo__num">$100</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">-$100</span>
|
||||||
|
<span class="lp-result-settled">settled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lp-callout">
|
||||||
|
<div class="lp-callout__inner">
|
||||||
|
<div class="lp-callout__text">
|
||||||
|
<span class="lp-eyebrow">Leaderboards</span>
|
||||||
|
<h2 class="lp-callout__title">Rankings built on raw data, not trust.</h2>
|
||||||
|
<p class="lp-callout__body">Every leaderboard stat is computed directly from the same append-only ledger that records each buy-in and cashout. Net profit, ROI, and win rate update the moment a session closes — no manual entry, no formulas to break.</p>
|
||||||
|
<ul class="lp-callout__list">
|
||||||
|
<li>Set a session minimum before a player earns a rank</li>
|
||||||
|
<li>Track rank movement with live ▲▼ deltas</li>
|
||||||
|
<li>Multiple sort modes: net, ROI, win rate, recent form</li>
|
||||||
|
<li>Provisional players shown separately until they qualify</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="lp-callout__demo" aria-hidden="true">
|
||||||
|
<div class="lp-demo__card">
|
||||||
|
<div class="lp-demo__head">
|
||||||
|
<div>
|
||||||
|
<span class="lp-demo__kicker">Friday Night Poker</span>
|
||||||
|
<span class="lp-demo__title">Leaderboard · Sort: Net</span>
|
||||||
|
</div>
|
||||||
|
<span class="lp-demo__meta">18 sessions · eligible</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__table">
|
||||||
|
<div class="lp-demo__thead lp-demo__thead--lb">
|
||||||
|
<span class="lp-demo__th">#</span>
|
||||||
|
<span class="lp-demo__th">Player</span>
|
||||||
|
<span class="lp-demo__th">Δ</span>
|
||||||
|
<span class="lp-demo__th">Net</span>
|
||||||
|
<span class="lp-demo__th">Win%</span>
|
||||||
|
<span class="lp-demo__th">ROI</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__row lp-demo__row--lb">
|
||||||
|
<span class="lp-demo__rank lp-demo__rank--1">1</span>
|
||||||
|
<span class="lp-demo__player">Rivera</span>
|
||||||
|
<span class="lp-demo__delta lp-demo__delta--up">▲1</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+$1,420</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">72%</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+46%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__row lp-demo__row--lb">
|
||||||
|
<span class="lp-demo__rank lp-demo__rank--2">2</span>
|
||||||
|
<span class="lp-demo__player">Chen</span>
|
||||||
|
<span class="lp-demo__delta">—</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+$640</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">56%</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--pos">+18%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__row lp-demo__row--lb">
|
||||||
|
<span class="lp-demo__rank lp-demo__rank--3">3</span>
|
||||||
|
<span class="lp-demo__player">Okafor</span>
|
||||||
|
<span class="lp-demo__delta lp-demo__delta--down">▼1</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−$210</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">43%</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−7%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__row lp-demo__row--lb lp-demo__row--fade">
|
||||||
|
<span class="lp-demo__rank">4</span>
|
||||||
|
<span class="lp-demo__player">Walsh</span>
|
||||||
|
<span class="lp-demo__delta">—</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−$850</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">31%</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−27%</span>
|
||||||
|
</div>
|
||||||
|
<div class="lp-demo__row lp-demo__row--lb lp-demo__row--fade2">
|
||||||
|
<span class="lp-demo__rank">5</span>
|
||||||
|
<span class="lp-demo__player">Novak</span>
|
||||||
|
<span class="lp-demo__delta lp-demo__delta--down">▼2</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−$1,000</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">25%</span>
|
||||||
|
<span class="lp-demo__num lp-demo__num--neg">−42%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if not is_logged_in %}
|
||||||
|
<section class="lp-bottom-cta">
|
||||||
|
<div class="lp-bottom-cta__inner">
|
||||||
|
<h2 class="lp-bottom-cta__title">Start tracking your league</h2>
|
||||||
|
<p class="lp-bottom-cta__sub">Free to use. No payment processor. Your data stays yours.</p>
|
||||||
|
<div class="lp-cta" style="justify-content:center;">
|
||||||
|
<a class="btn btn--primary btn--lg" href="{{ url_for('account.register') }}">Create an account →</a>
|
||||||
|
<a class="btn btn--ghost btn--lg" href="{{ url_for('public.explore') }}">Browse public leagues</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="db-header">
|
||||||
|
<div class="db-header__info">
|
||||||
|
<div class="db-header__chips">
|
||||||
|
<span class="db-chip">{{ league.visibility }}</span>
|
||||||
|
{% if is_owner %}<span class="db-chip db-chip--accent">Owner</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<h1 class="db-header__title">{{ league.name }}</h1>
|
||||||
|
{% if league.description %}<p class="db-header__sub">{{ league.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if is_owner %}
|
||||||
|
<div class="db-header__actions">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Settings</a>
|
||||||
|
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">New session</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if counts.open_sessions > 0 %}
|
||||||
|
<div class="db-live-alert">
|
||||||
|
<div class="db-live-alert__pulse"></div>
|
||||||
|
<span class="db-live-alert__text">
|
||||||
|
{{ counts.open_sessions }} session{{ 's' if counts.open_sessions != 1 else '' }} in progress
|
||||||
|
</span>
|
||||||
|
<a class="db-live-alert__link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">View sessions →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="db-stat-grid">
|
||||||
|
<div class="db-stat">
|
||||||
|
<div class="db-stat__icon db-stat__icon--accent">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="7" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M2 17c0-3.314 2.686-6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="14" cy="8" r="2.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M18 17c0-2.761-1.79-5-4-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="db-stat__num">{{ counts.players }}</span>
|
||||||
|
<span class="db-stat__label">Players</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<div class="db-stat__icon db-stat__icon--pos">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2.5" y="3.5" width="15" height="13" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M6 2v3M14 2v3M2.5 8h15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="db-stat__num">{{ counts.sessions }}</span>
|
||||||
|
<span class="db-stat__label">Sessions{% if counts.open_sessions > 0 %} · <span style="color:var(--pos);">{{ counts.open_sessions }} live</span>{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<div class="db-stat__icon db-stat__icon--warn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 3h10l3 3v11a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M7 8h6M7 11h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="db-stat__num">{{ counts.events }}</span>
|
||||||
|
<span class="db-stat__label">Ledger events</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat">
|
||||||
|
<div class="db-stat__icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 2v3M10 15v3M2 10h3M15 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="10" cy="10" r="4.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="db-stat__num">{{ counts.seasons }}</span>
|
||||||
|
<span class="db-stat__label">Seasons</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if cash_stats.has_data %}
|
||||||
|
<div class="db-cash">
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Total cash in</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.total_cash_in | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Total paid out</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.total_paid_out | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Avg pot / session</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.avg_pot | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Avg players</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.avg_players }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Avg buy-in</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.avg_buyin | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Biggest session</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.biggest_pot | money }}</span>
|
||||||
|
</div>
|
||||||
|
{% if cash_stats.avg_sessions_per_month > 0 %}
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Sessions / month</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.avg_sessions_per_month }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cash_stats.total_fronts > 0 %}
|
||||||
|
<div class="db-cash__div"></div>
|
||||||
|
<div class="db-cash__stat">
|
||||||
|
<span class="db-cash__label">Fronts issued</span>
|
||||||
|
<span class="db-cash__val">{{ cash_stats.total_fronts | money }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cash_stats.has_sessions %}
|
||||||
|
<div class="db-cal"
|
||||||
|
id="db-cal"
|
||||||
|
data-sessions='{{ cash_stats.session_map | tojson }}'
|
||||||
|
data-first-year="{{ cash_stats.first_session_year }}">
|
||||||
|
<div class="db-cal__hdr">
|
||||||
|
<span class="db-cal__title">Session history</span>
|
||||||
|
<div class="db-cal__nav-group">
|
||||||
|
<button class="db-cal__nav" id="db-cal-prev" aria-label="Previous year">‹</button>
|
||||||
|
<span class="db-cal__year" id="db-cal-year"></span>
|
||||||
|
<button class="db-cal__nav" id="db-cal-next" aria-label="Next year">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-cal__grid" id="db-cal-grid"></div>
|
||||||
|
<div class="db-cal__tip" id="db-cal-tip" hidden></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var cal = document.getElementById('db-cal');
|
||||||
|
if (!cal) return;
|
||||||
|
var sessions = JSON.parse(cal.dataset.sessions);
|
||||||
|
var firstYear = parseInt(cal.dataset.firstYear) || new Date().getFullYear();
|
||||||
|
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
var today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
var todayStr = today.toISOString().slice(0,10);
|
||||||
|
var currentYear = today.getFullYear();
|
||||||
|
var year = currentYear;
|
||||||
|
var grid = document.getElementById('db-cal-grid');
|
||||||
|
var yearLbl = document.getElementById('db-cal-year');
|
||||||
|
var prevBtn = document.getElementById('db-cal-prev');
|
||||||
|
var nextBtn = document.getElementById('db-cal-next');
|
||||||
|
var tip = document.getElementById('db-cal-tip');
|
||||||
|
|
||||||
|
function ds(d) { return d.toISOString().slice(0,10); }
|
||||||
|
|
||||||
|
function buildCalendar(y) {
|
||||||
|
var start = new Date(y, 0, 1);
|
||||||
|
var dow = start.getDay();
|
||||||
|
start.setDate(start.getDate() - (dow === 0 ? 6 : dow - 1));
|
||||||
|
var end = new Date(y, 11, 31);
|
||||||
|
var edow = end.getDay();
|
||||||
|
if (edow !== 0) end.setDate(end.getDate() + (7 - edow));
|
||||||
|
var weeks = [], cur = new Date(start);
|
||||||
|
while (cur <= end) {
|
||||||
|
var days = [], monthLabel = '';
|
||||||
|
for (var i = 0; i < 7; i++) {
|
||||||
|
var d = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + i);
|
||||||
|
var s = ds(d);
|
||||||
|
if (d.getDate() === 1 && !monthLabel) monthLabel = MONTHS[d.getMonth()];
|
||||||
|
days.push({ date: s, count: sessions[s] || 0, future: s > todayStr, out: d.getFullYear() !== y });
|
||||||
|
}
|
||||||
|
weeks.push({ days: days, month: monthLabel });
|
||||||
|
cur.setDate(cur.getDate() + 7);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(y) {
|
||||||
|
var weeks = buildCalendar(y);
|
||||||
|
grid.innerHTML = '';
|
||||||
|
weeks.forEach(function(week) {
|
||||||
|
var mc = document.createElement('div');
|
||||||
|
mc.className = 'db-cal__mc';
|
||||||
|
mc.textContent = week.month;
|
||||||
|
grid.appendChild(mc);
|
||||||
|
week.days.forEach(function(day) {
|
||||||
|
var cell = document.createElement('div');
|
||||||
|
var cls = 'db-cal__cell';
|
||||||
|
if (day.out) cls += ' db-cal__cell--out';
|
||||||
|
else if (day.future) cls += ' db-cal__cell--dim';
|
||||||
|
else if (day.count) cls += ' db-cal__cell--on' + (day.count > 1 ? ' db-cal__cell--multi' : '');
|
||||||
|
cell.className = cls;
|
||||||
|
cell.dataset.date = day.date;
|
||||||
|
cell.dataset.count = day.count;
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
yearLbl.textContent = y;
|
||||||
|
prevBtn.disabled = y <= firstYear;
|
||||||
|
nextBtn.disabled = y >= currentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTip(cell, e) {
|
||||||
|
var d = cell.dataset.date;
|
||||||
|
if (!d) return;
|
||||||
|
var parts = d.split('-').map(Number);
|
||||||
|
var dateObj = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
var label = dateObj.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric', year:'numeric' });
|
||||||
|
var count = parseInt(cell.dataset.count) || 0;
|
||||||
|
tip.textContent = count ? label + ' · ' + count + ' session' + (count > 1 ? 's' : '') : label;
|
||||||
|
tip.hidden = false;
|
||||||
|
moveTip(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTip(e) {
|
||||||
|
var tw = tip.offsetWidth, th = tip.offsetHeight;
|
||||||
|
var x = e.clientX - tw / 2, y = e.clientY - th - 10;
|
||||||
|
x = Math.max(8, Math.min(x, window.innerWidth - tw - 8));
|
||||||
|
if (y < 8) y = e.clientY + 14;
|
||||||
|
tip.style.left = x + 'px';
|
||||||
|
tip.style.top = y + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.addEventListener('mouseover', function(e) {
|
||||||
|
var cell = e.target.closest('.db-cal__cell');
|
||||||
|
if (cell && !cell.classList.contains('db-cal__cell--out')) showTip(cell, e);
|
||||||
|
else tip.hidden = true;
|
||||||
|
});
|
||||||
|
grid.addEventListener('mousemove', function(e) { if (!tip.hidden) moveTip(e); });
|
||||||
|
grid.addEventListener('mouseleave', function() { tip.hidden = true; });
|
||||||
|
prevBtn.addEventListener('click', function() { if (year > firstYear) render(--year); });
|
||||||
|
nextBtn.addEventListener('click', function() { if (year < currentYear) render(++year); });
|
||||||
|
|
||||||
|
render(year);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="db-nav-grid">
|
||||||
|
|
||||||
|
<a class="db-nav-tile db-nav-tile--sessions {% if counts.open_sessions > 0 %}db-nav-tile--live{% endif %}" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">
|
||||||
|
<div class="db-nav-tile__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M3 9h18M8 3v2M16 3v2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<circle cx="8" cy="14" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="12" cy="14" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="16" cy="14" r="1.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-nav-tile__body">
|
||||||
|
<div class="db-nav-tile__title-row">
|
||||||
|
<strong class="db-nav-tile__title">Sessions</strong>
|
||||||
|
{% if counts.open_sessions > 0 %}
|
||||||
|
<span class="live-badge">{{ counts.open_sessions }} live</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="db-nav-tile__desc">Open a session to start tracking buy-ins and cashouts in real time. Close it when everyone is done.</p>
|
||||||
|
</div>
|
||||||
|
<span class="db-nav-tile__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="db-nav-tile db-nav-tile--leaderboard" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">
|
||||||
|
<div class="db-nav-tile__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 20h18M6 20V13M10 20V9M14 20V11M18 20V5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-nav-tile__body">
|
||||||
|
<div class="db-nav-tile__title-row">
|
||||||
|
<strong class="db-nav-tile__title">Leaderboard</strong>
|
||||||
|
</div>
|
||||||
|
<p class="db-nav-tile__desc">Net profit, ROI, win rate, and rank delta. Filter by eligible players or switch to recent form.</p>
|
||||||
|
</div>
|
||||||
|
<span class="db-nav-tile__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="db-nav-tile db-nav-tile--players" href="{{ url_for('leagues.players', league_ref=league.url_ref) }}">
|
||||||
|
<div class="db-nav-tile__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="9" cy="8" r="3.5" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M2.5 21c0-3.866 2.91-7 6.5-7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
<circle cx="16.5" cy="9.5" r="3" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M21.5 21c0-3.314-2.239-6-5-6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-nav-tile__body">
|
||||||
|
<div class="db-nav-tile__title-row">
|
||||||
|
<strong class="db-nav-tile__title">Players</strong>
|
||||||
|
</div>
|
||||||
|
<p class="db-nav-tile__desc">View each player's full session history, net trend, and individual stats over time.</p>
|
||||||
|
</div>
|
||||||
|
<span class="db-nav-tile__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="db-nav-tile db-nav-tile--seasons" href="{{ url_for('leagues.seasons', league_ref=league.url_ref) }}">
|
||||||
|
<div class="db-nav-tile__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M12 7v5l3.5 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-nav-tile__body">
|
||||||
|
<div class="db-nav-tile__title-row">
|
||||||
|
<strong class="db-nav-tile__title">Seasons</strong>
|
||||||
|
</div>
|
||||||
|
<p class="db-nav-tile__desc">Browse sessions by season, view season standings, and manage date ranges.</p>
|
||||||
|
</div>
|
||||||
|
<span class="db-nav-tile__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<a class="db-nav-tile db-nav-tile--ledger" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">
|
||||||
|
<div class="db-nav-tile__icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 3h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H9l-4-4V4a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M9 19v-4H5M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-nav-tile__body">
|
||||||
|
<div class="db-nav-tile__title-row">
|
||||||
|
<strong class="db-nav-tile__title">League ledger</strong>
|
||||||
|
</div>
|
||||||
|
<p class="db-nav-tile__desc">Track what the house holds, what it owes, and outstanding fronts across all sessions.</p>
|
||||||
|
</div>
|
||||||
|
<span class="db-nav-tile__arrow">→</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header__title">Leaderboard</h1>
|
||||||
|
<p class="page-header__sub">{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="leaderboard-hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Cash paid out</span>
|
||||||
|
<div class="stat__val" style="color:var(--accent)">{{ cash_paid_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ session_count }}{% if total_session_count != session_count %}<span style="color:var(--faintest);font-size:16px"> / {{ total_session_count }}</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Players</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ all_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel stack" style="gap:0;margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker">Trend</span>
|
||||||
|
<h2 class="panel__title">Cumulative profit</h2>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" id="expandLeaderboardChart">Expand</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame chart-frame--tall">
|
||||||
|
<canvas id="leaderboardChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<div class="segmented">
|
||||||
|
<button class="seg {{ 'is-on' if mode=='eligible' }}" data-mode="eligible">
|
||||||
|
Eligible <span class="seg__count">{{ eligible_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="seg {{ 'is-on' if mode=='all' }}" data-mode="all">
|
||||||
|
All players <span class="seg__count">{{ all_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="seg {{ 'is-on' if mode=='recent' }}" data-mode="recent">
|
||||||
|
Recent <span class="seg__count">5 sess</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="toolbar__hint">
|
||||||
|
{% if mode == 'eligible' %}
|
||||||
|
{{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings.
|
||||||
|
{% elif mode == 'all' %}
|
||||||
|
Everyone who's sat down — rankings can swing on tiny samples.
|
||||||
|
{% else %}
|
||||||
|
Form guide — aggregated over the last 5 sessions only.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__right">
|
||||||
|
{% if seasons %}
|
||||||
|
<form class="session-filter-form" method="get" style="margin-right:8px;">
|
||||||
|
<input type="hidden" name="mode" value="{{ mode }}">
|
||||||
|
<select class="session-filter-select" name="season" onchange="this.form.submit()">
|
||||||
|
<option value="">All seasons</option>
|
||||||
|
{% for s in seasons %}
|
||||||
|
<option value="{{ s.id }}" {{ 'selected' if selected_season_id == s.id else '' }}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form class="session-filter-form" method="get">
|
||||||
|
<input type="hidden" name="mode" value="{{ mode }}">
|
||||||
|
{% if selected_season_id %}<input type="hidden" name="season" value="{{ selected_season_id }}">{% endif %}
|
||||||
|
<select class="session-filter-select" name="through_session" onchange="this.form.submit()">
|
||||||
|
<option value="">Latest session</option>
|
||||||
|
{% for s in available_sessions %}
|
||||||
|
<option value="{{ s.session_id }}" {{ 'selected' if selected_session_id == s.session_id else '' }}>{{ session_label(s) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if selected_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref, mode=mode) }}">Reset</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel tbl-scroll" style="margin-bottom:20px;">
|
||||||
|
<table class="tbl" id="leaderboardTable" data-sort-key="net" data-sort-direction="desc">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-rank" style="width:52px;">
|
||||||
|
<span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);">#</span>
|
||||||
|
</th>
|
||||||
|
<th class="col-player">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-arrow">↕</span></button>
|
||||||
|
</th>
|
||||||
|
<th class="col-rank-delta"><button type="button" class="sort-btn" data-sort-key="rankdelta" data-sort-type="number" data-initial-direction="desc">Δ Rank <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-arrow">↓</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win% <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-arrow">↕</span></button></th>
|
||||||
|
<th><button type="button" class="sort-btn" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-arrow">↕</span></button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for player in main_board %}
|
||||||
|
{% set idx = loop.index %}
|
||||||
|
{% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %}
|
||||||
|
<tr data-player-row="true" data-original-rank="{{ idx }}"
|
||||||
|
{% if player_href %}onclick="window.location='{{ player_href }}'"{% endif %}>
|
||||||
|
<td class="col-rank" data-sort-value="{{ idx }}">
|
||||||
|
<span class="rank-num {{ 'rank-1' if idx==1 else 'rank-2' if idx==2 else 'rank-3' if idx==3 else '' }}" data-rank-cell>#{{ idx }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-player" data-sort-value="{{ player.player_name|lower }}">
|
||||||
|
{% if player_href %}<a href="{{ player_href }}">{{ player.player_name }}</a>{% else %}{{ player.player_name }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-rank-delta" data-sort-value="{{ player.rank_change }}">
|
||||||
|
{% if player.rank_change > 0 %}
|
||||||
|
<span class="rank-delta rank-delta--up">▲{{ player.rank_change }}</span>
|
||||||
|
{% elif player.rank_change < 0 %}
|
||||||
|
<span class="rank-delta rank-delta--down">▼{{ player.rank_change | abs }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="rank-delta rank-delta--flat">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="num-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}"
|
||||||
|
data-sort-value="{{ player.total_net_cents }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</td>
|
||||||
|
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
|
<td class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}"
|
||||||
|
data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||||
|
<td class="num-pos" data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
|
||||||
|
<td class="num-neg" data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
|
||||||
|
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);">
|
||||||
|
No players yet — add the first event from a league session ledger.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mode == 'eligible' and provisional_board %}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<div class="toolbar" style="margin-top:0;margin-bottom:12px;">
|
||||||
|
<span class="kicker" style="margin:0;">Provisional · fewer than {{ eligible_min_sessions }} sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="provisional-grid">
|
||||||
|
{% for player in provisional_board %}
|
||||||
|
{% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %}
|
||||||
|
<a class="provisional-card" href="{{ player_href or '#' }}"
|
||||||
|
style="display:block;text-decoration:none;">
|
||||||
|
<p class="provisional-card__name">{{ player.player_name }}</p>
|
||||||
|
<div class="provisional-card__meta">
|
||||||
|
<span>{{ player.sessions_played }} sess</span>
|
||||||
|
<span class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}"
|
||||||
|
style="font-family:var(--font-mono);font-size:11px;font-weight:600;">{{ player.total_net_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Expand modal -->
|
||||||
|
<div id="chartModal" style="display:none;position:fixed;inset:0;z-index:80;align-items:center;justify-content:center;">
|
||||||
|
<div style="position:absolute;inset:0;background:rgba(8,10,12,.82);backdrop-filter:blur(6px);" data-close-modal></div>
|
||||||
|
<div style="position:relative;z-index:1;width:min(1280px,calc(100vw - 48px));margin:24px auto;padding:22px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--r-lg);box-shadow:0 24px 64px rgba(0,0,0,.4);">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;">
|
||||||
|
<h2 style="margin:0;">Cumulative profit</h2>
|
||||||
|
<button class="btn btn--ghost btn--sm" data-close-modal>Close</button>
|
||||||
|
</div>
|
||||||
|
<div style="height:min(70vh,780px);position:relative;">
|
||||||
|
<canvas id="leaderboardChartExpanded"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CHART_PALETTE = ['#9b8cf0','#6fc093','#e0b15c','#cf6f86','#8f93c2','#7cb9e0','#f4a261','#a78bb0','#5cb8a0','#e58f3a'];
|
||||||
|
const leaderboardChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
leaderboardChartData.datasets.forEach((ds, i) => {
|
||||||
|
ds.borderColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
||||||
|
ds.backgroundColor = CHART_PALETTE[i % CHART_PALETTE.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildChartConfig(data, compact) {
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: data.datasets.map(ds => ({
|
||||||
|
...ds,
|
||||||
|
pointRadius: compact ? 0 : 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
borderWidth: compact ? 2 : 2.5,
|
||||||
|
tension: 0.28,
|
||||||
|
fill: false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { color: textMuted, usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 14 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label(ctx) {
|
||||||
|
const v = ctx.parsed.y;
|
||||||
|
return v == null ? `${ctx.dataset.label}: —` : `${ctx.dataset.label}: $${Number(v).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: textMuted, maxRotation: 0, autoSkip: true, maxTicksLimit: compact ? 6 : 10 },
|
||||||
|
grid: { color: lineColor, display: !compact },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: textMuted,
|
||||||
|
maxTicksLimit: compact ? 8 : 10,
|
||||||
|
callback(v) { return (v < 0 ? '-' : '') + '$' + Math.abs(Number(v)).toFixed(0); }
|
||||||
|
},
|
||||||
|
grid: { color: lineColor },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = document.getElementById('leaderboardChart');
|
||||||
|
let mainChart = null;
|
||||||
|
if (ctx && leaderboardChartData.labels.length > 0) {
|
||||||
|
const filtered = leaderboardChartData.datasets.filter(ds => (ds.sessions_played || 0) > 1);
|
||||||
|
const chartData = {
|
||||||
|
labels: leaderboardChartData.labels,
|
||||||
|
datasets: filtered.length ? filtered : leaderboardChartData.datasets,
|
||||||
|
};
|
||||||
|
mainChart = new Chart(ctx, buildChartConfig(chartData, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('chartModal');
|
||||||
|
const expandBtn = document.getElementById('expandLeaderboardChart');
|
||||||
|
let expandedChart = null;
|
||||||
|
|
||||||
|
expandBtn?.addEventListener('click', () => {
|
||||||
|
if (!leaderboardChartData.labels.length) return;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
if (expandedChart) expandedChart.destroy();
|
||||||
|
expandedChart = new Chart(
|
||||||
|
document.getElementById('leaderboardChartExpanded'),
|
||||||
|
buildChartConfig(leaderboardChartData, false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (expandedChart) { expandedChart.destroy(); expandedChart = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Segmented mode switcher
|
||||||
|
document.querySelectorAll('[data-mode]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('mode', btn.dataset.mode);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table sort
|
||||||
|
const tbl = document.getElementById('leaderboardTable');
|
||||||
|
const sortBtns = tbl ? Array.from(tbl.querySelectorAll('.sort-btn[data-sort-key]')) : [];
|
||||||
|
const colMap = { rank: 0, player: 1, rankdelta: 2, net: 3, winpct: 4, roi: 5, avgwin: 6, avgloss: 7, sessions: 8 };
|
||||||
|
|
||||||
|
function setActiveSort(key, dir) {
|
||||||
|
sortBtns.forEach(b => {
|
||||||
|
const active = b.dataset.sortKey === key;
|
||||||
|
b.classList.toggle('is-active', active);
|
||||||
|
const arrow = b.querySelector('.sort-arrow');
|
||||||
|
if (arrow) arrow.textContent = active ? (dir === 'asc' ? '↑' : '↓') : '↕';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTable(key, sortType, forcedDir) {
|
||||||
|
if (!tbl) return;
|
||||||
|
const tbody = tbl.tBodies[0];
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row]'));
|
||||||
|
const curKey = tbl.dataset.sortKey;
|
||||||
|
const curDir = tbl.dataset.sortDirection || 'desc';
|
||||||
|
let dir = forcedDir;
|
||||||
|
if (!dir) {
|
||||||
|
if (curKey === key) dir = curDir === 'desc' ? 'asc' : 'desc';
|
||||||
|
else {
|
||||||
|
const btn = sortBtns.find(b => b.dataset.sortKey === key);
|
||||||
|
dir = btn?.dataset.initialDirection || 'desc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mult = dir === 'asc' ? 1 : -1;
|
||||||
|
const ci = colMap[key] ?? 0;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const av = a.children[ci]?.dataset.sortValue ?? '';
|
||||||
|
const bv = b.children[ci]?.dataset.sortValue ?? '';
|
||||||
|
if (sortType === 'number') {
|
||||||
|
const n = (Number(av) - Number(bv)) * mult;
|
||||||
|
if (n !== 0) return n;
|
||||||
|
} else {
|
||||||
|
const s = av.localeCompare(bv) * mult;
|
||||||
|
if (s !== 0) return s;
|
||||||
|
}
|
||||||
|
return Number(a.dataset.originalRank) - Number(b.dataset.originalRank);
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
tbl.dataset.sortKey = key;
|
||||||
|
tbl.dataset.sortDirection = dir;
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
const rc = row.querySelector('[data-rank-cell]');
|
||||||
|
if (rc) rc.textContent = `#${i + 1}`;
|
||||||
|
});
|
||||||
|
setActiveSort(key, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBtns.forEach(b => {
|
||||||
|
b.addEventListener('click', () => sortTable(b.dataset.sortKey, b.dataset.sortType));
|
||||||
|
});
|
||||||
|
setActiveSort('net', 'desc');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ledger · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="cash-strip">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Cash in <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total real cash brought into the pot across all sessions. Counts buy-ins and collected fronts only — not fronts that haven't been repaid.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.cash_in_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Cash paid out <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total cash physically returned to players via 'paid out' events. Should approach Cash In once everyone has settled.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--pos)">{{ totals.cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rolled);">
|
||||||
|
<span class="kicker">House holds <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Cash in minus cash paid out — what the house physically holds right now before settling remaining claims.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.book_held_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--neg);">
|
||||||
|
<span class="kicker">Due to players <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Cash the house still owes to players who cashed out but haven't been paid yet. Record a 'paid out' event to clear these.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-neg' if totals.due_to_players_cents > 0 else 'num-muted' }}">{{ totals.due_to_players_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--owes);">
|
||||||
|
<span class="kicker">Due to house <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Outstanding fronts — chips the house covered that players haven't repaid yet. Collect or write off using the Debt resolution panel below.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-neg' if totals.due_to_house_cents > 0 else 'num-muted' }}">{{ totals.due_to_house_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if totals.net_position_cents >= 0 else 'var(--neg)' }};">
|
||||||
|
<span class="kicker">Net position <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">House holds + owed by players − owed to players. Should be near zero once all sessions are settled.</span></span></span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if totals.net_position_cents > 0 else 'num-neg' if totals.net_position_cents < 0 else 'num-muted' }}">{{ totals.net_position_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Events <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Total number of active ledger entries across all sessions.</span></span></span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ totals.events }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open sessions alert -->
|
||||||
|
{% if open_sessions %}
|
||||||
|
<div class="panel" style="margin-top:20px; border-left:3px solid var(--pos);">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;display:flex;align-items:center;gap:8px;"><span class="live-badge">{{ open_sessions | length }} live</span> Open sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-item-list">
|
||||||
|
{% for s in open_sessions %}
|
||||||
|
<div class="audit-item">
|
||||||
|
<span>{{ session_label(s) }}</span>
|
||||||
|
<span class="audit-item__right">
|
||||||
|
<span class="stat__sub">{{ s.entries | length }} entries · {{ s.total_real_cash_in_cents | money }} in</span>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session_ids[s.session_id]) }}">View →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Debt resolution -->
|
||||||
|
<div class="panel debt-panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Front receivables <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A front is chips the house covered for a player who didn't have cash. This section tracks who still owes money back to the house.</span></span></span>
|
||||||
|
<h2 class="panel__title">Debt resolution</h2>
|
||||||
|
</div>
|
||||||
|
<div class="debt-summary">
|
||||||
|
<span class="badge badge--owes">Open {{ totals.due_to_house_cents | money }}</span>
|
||||||
|
<span class="badge badge--paid">Collected {{ totals.collected_cents | money }}</span>
|
||||||
|
<span class="badge badge--written_off">Written off {{ 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.current_due_to_house_cents | money }}</b>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="debt-action-grid">
|
||||||
|
<form class="form-card debt-form" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<h3>Collect debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select class="debt-select" data-target="collect" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}
|
||||||
|
{% set e = item.entry %}
|
||||||
|
<option value="{{ session_ids[s.session_id] }}||{{ player_ids[e.player_name] }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.current_due_to_house_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="session_id" value="">
|
||||||
|
<input type="hidden" name="player_id" value="">
|
||||||
|
<input type="hidden" name="event_type" value="debt_repayment">
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="field__opt">(optional)</span></span>
|
||||||
|
<textarea name="note" rows="2" placeholder="E-transfer, cash, etc."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Mark collected</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="form-card debt-form" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<h3>Write off debt</h3>
|
||||||
|
<label>
|
||||||
|
<span>Outstanding debt</span>
|
||||||
|
<select class="debt-select" data-target="writeoff" required>
|
||||||
|
<option value="">Select debt</option>
|
||||||
|
{% for item in debt_entries %}
|
||||||
|
{% set s = item.session %}
|
||||||
|
{% set e = item.entry %}
|
||||||
|
<option value="{{ session_ids[s.session_id] }}||{{ player_ids[e.player_name] }}">
|
||||||
|
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.current_due_to_house_cents | money }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="session_id" value="">
|
||||||
|
<input type="hidden" name="player_id" value="">
|
||||||
|
<input type="hidden" name="event_type" value="writeoff">
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0.01" name="amount" placeholder="0.00" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="field__opt">(optional)</span></span>
|
||||||
|
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--ghost" type="submit">Write off</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="debt-empty">
|
||||||
|
<div class="debt-empty__mark">$0</div>
|
||||||
|
<p>No open player debts right now.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Due to players -->
|
||||||
|
{% if due_to_player_items %}
|
||||||
|
<div class="panel" style="margin-top:16px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Unpaid cashout claims</span>
|
||||||
|
<h2 class="panel__title">Due to players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--owes">{{ totals.due_to_players_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-item-list">
|
||||||
|
{% for item in due_to_player_items %}
|
||||||
|
<div class="audit-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.entry.player_name }}</strong>
|
||||||
|
<span class="stat__sub">{{ session_label(item.session) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-neg">{{ item.entry.current_due_to_player_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Import / Export -->
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="panel panel--pad import-export-panel" style="margin-top:16px;">
|
||||||
|
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
|
||||||
|
<p class="csv__desc">Back up or migrate the raw event ledger for this league.</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.import_ledger_csv', league_ref=league.url_ref) }}" enctype="multipart/form-data" class="import-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="csv__btns">
|
||||||
|
<a class="csv-btn-ghost" href="{{ url_for('leagues.export_ledger_csv', league_ref=league.url_ref) }}">↓ Export CSV</a>
|
||||||
|
<label class="csv-btn-ghost" for="csvImport">↑ Import CSV</label>
|
||||||
|
</div>
|
||||||
|
<input id="csvImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
|
||||||
|
<label class="csv__drop" id="csvDrop" for="csvImport">Drop ledger.csv here to import</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel panel--pad import-export-panel" style="margin-top:16px;">
|
||||||
|
<h2 class="panel__title" style="margin:0 0 4px">Export</h2>
|
||||||
|
<p class="csv__desc">Download the raw event ledger for this league.</p>
|
||||||
|
<div class="csv__btns">
|
||||||
|
<a class="csv-btn-ghost" href="{{ url_for('leagues.export_ledger_csv', league_ref=league.url_ref) }}">↓ Export CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Sessions + events -->
|
||||||
|
<div class="admin-bottom-grid" style="margin-top:16px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">All sessions</span>
|
||||||
|
<h2 class="panel__title">Ledger totals</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Players</th>
|
||||||
|
<th>Cash in</th>
|
||||||
|
<th>Paid out</th>
|
||||||
|
<th>Due to house</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session_ids[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_real_cash_in_cents | money }}</td>
|
||||||
|
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_real_cash_out_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><span class="status-pill status-pill--{{ s.status }}">{{ s.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted-text">No sessions yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">All events</span>
|
||||||
|
<h2 class="panel__title">Ledger feed</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--closed">append-only</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed feed feed--inline">
|
||||||
|
{% for event in raw_events %}
|
||||||
|
<div class="event-card event-card--inline {% if event.voided_at %}event-card--voided{% endif %}">
|
||||||
|
<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.voided_at %}<span class="void-badge">voided</span>{% endif %}
|
||||||
|
{% if event.note and not event.voided_at %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
{% if event.voided_at %}
|
||||||
|
<p class="tiny-text void-reason">{{ event.void_reason }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="tiny-text">{{ event.session_date }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:20px;">No events recorded.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.debt-select').forEach(function(sel) {
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
var parts = this.value.split('||');
|
||||||
|
var form = this.closest('form');
|
||||||
|
form.querySelector('[name=session_id]').value = parts[0] || '';
|
||||||
|
form.querySelector('[name=player_id]').value = parts[1] || '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var csvDrop = document.getElementById('csvDrop');
|
||||||
|
var csvInput = document.getElementById('csvImport');
|
||||||
|
if (csvDrop && csvInput) {
|
||||||
|
csvDrop.addEventListener('dragover', function(e) { e.preventDefault(); csvDrop.classList.add('is-drag'); });
|
||||||
|
csvDrop.addEventListener('dragleave', function() { csvDrop.classList.remove('is-drag'); });
|
||||||
|
csvDrop.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
csvDrop.classList.remove('is-drag');
|
||||||
|
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||||
|
csvInput.files = e.dataTransfer.files;
|
||||||
|
csvInput.form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New League · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">League setup</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Create league</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Leagues are long-lived containers. Seasons can be added later.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Pick something recognizable — it appears on your dashboard and the leaderboard.</span></span></span>
|
||||||
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="Friday Poker" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. Add context like 'Friday night home game, $20 buy-in'. Visible to league members.</span></span></span>
|
||||||
|
<textarea name="description" placeholder="Private home league">{{ form.description or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Create league</button>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('leagues.index') }}">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ player.player_name }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div>
|
||||||
|
{% if player_rank %}
|
||||||
|
<p class="player-rank">Rank #{{ player_rank }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="hero__title">{{ player.player_name }}</h1>
|
||||||
|
<p class="hero__sub">{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-stat-grid" style="margin-bottom:20px;">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Total net</span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Win rate</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ "%.1f"|format(player.win_pct) }}%</div>
|
||||||
|
<div class="stat__sub">{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">ROI</span>
|
||||||
|
<div class="stat__val {{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else 'num-muted' }}">{{ "%.1f"|format(player.roi_pct) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Sessions</span>
|
||||||
|
<div class="stat__val" style="color:var(--text-strong)">{{ player.sessions_played }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Current W/L</span>
|
||||||
|
<div class="stat-pair">
|
||||||
|
<span class="stat-pair__val" style="color:var(--pos)">{{ player.current_win_streak }}W</span>
|
||||||
|
<span class="stat-pair__val" style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat__sub">Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Best / worst</span>
|
||||||
|
<div class="stat-pair">
|
||||||
|
<span class="stat-pair__val" style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
|
||||||
|
<span class="stat-pair__val" style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Total invested</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ player.total_invested_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="kicker">Gross cashouts</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ player.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cumulative profit chart -->
|
||||||
|
<div class="panel" style="margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Trend</span>
|
||||||
|
<h2 class="panel__title">Cumulative profit</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame chart-frame--tall">
|
||||||
|
<canvas id="playerCumulativeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row: session results + result mix -->
|
||||||
|
<div class="split" style="margin-bottom:20px;">
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Session by session</span>
|
||||||
|
<h2 class="panel__title">Results</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-frame">
|
||||||
|
<canvas id="playerSessionChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<span class="kicker" style="margin:0;">Breakdown</span>
|
||||||
|
<h2 class="panel__title">Result mix</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="result-mix" aria-label="Player result mix">
|
||||||
|
{% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %}
|
||||||
|
<div class="result-mix__bar">
|
||||||
|
{% if player.winning_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--win" style="flex-grow:{{ player.winning_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.break_even_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--even" style="flex-grow:{{ player.break_even_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.losing_sessions > 0 %}
|
||||||
|
<span class="result-mix__seg result-mix__seg--loss" style="flex-grow:{{ player.losing_sessions }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend">
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--win"></i>Wins</span>
|
||||||
|
<strong><b>{{ player.winning_sessions }}</b><em>{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--even"></i>Even</span>
|
||||||
|
<strong><b>{{ player.break_even_sessions }}</b><em>{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
<div class="result-mix__legend-row">
|
||||||
|
<span><i class="result-mix__key result-mix__key--loss"></i>Losses</span>
|
||||||
|
<strong><b>{{ player.losing_sessions }}</b><em>{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}%</em></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if result_total > 0 %}
|
||||||
|
<div class="result-mix__note">±${{ "%.2f"|format(league.break_even_cents / 100) }} counts as even</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Perf vs Cash two-panel split -->
|
||||||
|
<div class="split" style="margin-bottom:20px;">
|
||||||
|
|
||||||
|
<!-- Poker performance -->
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<div class="section-marker">
|
||||||
|
<span class="section-marker__dot section-marker__dot--accent"></span>
|
||||||
|
Poker performance
|
||||||
|
</div>
|
||||||
|
<h2 class="panel__title">Game results</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total Net</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;border-top:1px solid var(--divider);"
|
||||||
|
class="{{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else '' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win %</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Record</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.winning_sessions }}W</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.losing_sessions }}L</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span>{{ player.break_even_sessions }}E</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">ROI</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);"
|
||||||
|
class="{{ 'num-pos' if player.roi_pct > 0 else 'num-neg' if player.roi_pct < 0 else '' }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Win</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.avg_win_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Average Loss</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.avg_loss_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Best / Worst</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.biggest_win_cents | money }}</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">/</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.biggest_loss_cents | money }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Win / Loss Streak</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
<span style="color:var(--pos)">{{ player.current_win_streak }}W</span>
|
||||||
|
<span style="color:var(--faintest);margin:0 4px;">·</span>
|
||||||
|
<span style="color:var(--neg)">{{ player.current_loss_streak }}L</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Longest Win / Loss</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">
|
||||||
|
{{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Poker inputs and settlement. Fronts/rollovers are part of poker basis,
|
||||||
|
while paid/collected rows are real cash movement. -->
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<div class="section-marker">
|
||||||
|
<span class="section-marker__dot section-marker__dot--rolled"></span>
|
||||||
|
Poker basis + settlement
|
||||||
|
</div>
|
||||||
|
<h2 class="panel__title">Investment and settlement</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Buy-ins paid</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_buy_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Fronted</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--warn);">{{ player.total_front_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled in</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if player.total_payout_carry_in_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Payout carry-in</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_payout_carry_in_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Total invested</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_invested_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross cashout result</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_cash_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Gross player claim</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--num);">{{ player.total_gross_payout_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Rolled out</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--rolled);">{{ player.total_rollover_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash paid out</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_paid_out_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if player.total_debt_repayment_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Cash debt repaid</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--pos);">{{ player.total_debt_repayment_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.current_due_to_player_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">House owes player</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.current_due_to_player_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.current_due_to_house_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Owes house</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--owes);">{{ player.current_due_to_house_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if player.total_writeoff_cents > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:11px 20px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--divider);">Written off</td>
|
||||||
|
<td style="padding:11px 20px;text-align:right;font-family:var(--font-mono);border-top:1px solid var(--divider);color:var(--neg);">{{ player.total_writeoff_cents | money }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const playerChartData = {{ chart_data | tojson }};
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||||
|
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||||
|
|
||||||
|
const sharedOpts = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { color: textMuted, usePointStyle: true, boxWidth: 9, boxHeight: 9, padding: 16 } },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: textMuted, maxRotation: 0 }, grid: { color: lineColor } },
|
||||||
|
y: { ticks: { color: textMuted }, grid: { color: lineColor } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cumulativeCtx = document.getElementById('playerCumulativeChart');
|
||||||
|
if (cumulativeCtx) {
|
||||||
|
new Chart(cumulativeCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cumulative Profit',
|
||||||
|
data: playerChartData.cumulative_values,
|
||||||
|
borderColor: playerChartData.color,
|
||||||
|
backgroundColor: playerChartData.color + '38',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.22,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedOpts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCtx = document.getElementById('playerSessionChart');
|
||||||
|
if (sessionCtx) {
|
||||||
|
new Chart(sessionCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: playerChartData.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Session Net',
|
||||||
|
data: playerChartData.net_values,
|
||||||
|
backgroundColor: playerChartData.net_colors,
|
||||||
|
borderColor: playerChartData.net_colors,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: sharedOpts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Players · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header__title">Players</h1>
|
||||||
|
<p class="page-header__sub">Roster for {{ league.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post" style="margin-bottom:20px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Roster</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Add player</h2>
|
||||||
|
</div>
|
||||||
|
<div class="session-create-row">
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The player's display name. Appears on the leaderboard and in all ledger events. Players don't need a myboker account.</span></span></span>
|
||||||
|
<input type="text" name="display_name" value="{{ form.display_name or '' }}" placeholder="Alex" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. Useful for payment handles like 'Venmo @alex'. Visible only to league managers, not on public pages.</span></span></span>
|
||||||
|
<input type="text" name="notes" value="{{ form.notes or '' }}" placeholder="Venmo @alex">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Add player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set active_players = players | selectattr('status', 'equalto', 'active') | list %}
|
||||||
|
{% set archived_players = players | selectattr('status', 'equalto', 'archived') | list %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Roster</p>
|
||||||
|
<h2 class="panel__title">Active players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ active_players|length }} players</span>
|
||||||
|
</div>
|
||||||
|
{% if active_players %}
|
||||||
|
<div class="player-grid">
|
||||||
|
{% for player in active_players %}
|
||||||
|
{% set stats = stats_by_name.get(player.display_name) %}
|
||||||
|
{% set initials = player.display_name.split()|map('first')|join('')|upper %}
|
||||||
|
{% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %}
|
||||||
|
<div class="player-card">
|
||||||
|
<div class="player-card__head">
|
||||||
|
<div class="player-avatar {{ avatar_cls }}">{{ initials[:2] }}</div>
|
||||||
|
<div class="player-card__name-block">
|
||||||
|
<a class="player-card__name" href="{{ url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player.id) }}">{{ player.display_name }}</a>
|
||||||
|
<span class="player-card__sub">
|
||||||
|
{% if stats %}{{ stats.sessions_played }} session{{ 's' if stats.sessions_played != 1 else '' }}{% else %}No sessions yet{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="card-menu">
|
||||||
|
<button class="card-menu__trigger" type="button" aria-label="Player options">···</button>
|
||||||
|
<div class="card-menu__dropdown" hidden>
|
||||||
|
<button class="card-menu__item" type="button"
|
||||||
|
data-edit-name="{{ player.display_name }}"
|
||||||
|
data-edit-notes="{{ player.notes or '' }}"
|
||||||
|
data-edit-action="{{ url_for('leagues.edit_player', league_ref=league.url_ref, player_id=player.id) }}">Edit player</button>
|
||||||
|
{% if player.status == 'active' %}
|
||||||
|
<button class="card-menu__item card-menu__item--warn" type="button"
|
||||||
|
data-archive-name="{{ player.display_name }}"
|
||||||
|
data-archive-action="{{ url_for('leagues.archive_player', league_ref=league.url_ref, player_id=player.id) }}">Archive</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats %}
|
||||||
|
<div class="player-card__stats">
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Net</span>
|
||||||
|
<span class="player-card__stat-val {{ 'num-pos' if stats.total_net_cents > 0 else 'num-neg' if stats.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if stats.total_net_cents > 0 else '' }}{{ stats.total_net_cents | money }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Win rate</span>
|
||||||
|
<span class="player-card__stat-val">{{ "%.0f"|format(stats.win_pct * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__stat">
|
||||||
|
<span class="kicker">Best win</span>
|
||||||
|
<span class="player-card__stat-val" style="color:var(--pos)">{{ stats.biggest_win_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel">
|
||||||
|
<p class="kicker">Empty roster</p>
|
||||||
|
<h3 class="panel__title">No players yet</h3>
|
||||||
|
<p class="muted-text">Add the people who play in this league. Players don't need accounts.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if archived_players %}
|
||||||
|
<div class="panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Archive</p>
|
||||||
|
<h2 class="panel__title">Archived players</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ archived_players|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-list">
|
||||||
|
{% for player in archived_players %}
|
||||||
|
{% set stats = stats_by_name.get(player.display_name) %}
|
||||||
|
<div class="player-row" style="opacity:0.6;">
|
||||||
|
<div>
|
||||||
|
<strong>{{ player.display_name }}</strong>
|
||||||
|
{% if stats %}<span>{{ stats.sessions_played }} sessions · {{ stats.total_net_cents | money }} net</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="player-row__actions">
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="card-menu">
|
||||||
|
<button class="card-menu__trigger" type="button" aria-label="Player options">···</button>
|
||||||
|
<div class="card-menu__dropdown" hidden>
|
||||||
|
<button class="card-menu__item" type="button"
|
||||||
|
data-edit-name="{{ player.display_name }}"
|
||||||
|
data-edit-notes="{{ player.notes or '' }}"
|
||||||
|
data-edit-action="{{ url_for('leagues.edit_player', league_ref=league.url_ref, player_id=player.id) }}">Edit player</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.reactivate_player', league_ref=league.url_ref, player_id=player.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="card-menu__item" type="submit">Reactivate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="modal-backdrop" id="modal-edit-player" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-player-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Roster</span>
|
||||||
|
<h2 class="panel__title" id="modal-edit-player-title">Edit player</h2>
|
||||||
|
</div>
|
||||||
|
<form id="edit-player-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" name="display_name" id="edit-player-name" required minlength="2">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="notes" id="edit-player-notes" placeholder="Venmo @alex">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-archive-player" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-archive-player-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Roster</span>
|
||||||
|
<h2 class="panel__title" id="modal-archive-player-title">Archive player?</h2>
|
||||||
|
</div>
|
||||||
|
<p style="padding:0 20px 16px;margin:0;font:400 14px/1.5 var(--font-ui);color:var(--text-2);">Archived players are hidden from the active roster. Their history is preserved. You can reactivate them any time.</p>
|
||||||
|
<form id="archive-player-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Archive player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var editForm = document.getElementById('edit-player-form');
|
||||||
|
var editName = document.getElementById('edit-player-name');
|
||||||
|
var editNotes = document.getElementById('edit-player-notes');
|
||||||
|
var editModal = document.getElementById('modal-edit-player');
|
||||||
|
var archiveForm = document.getElementById('archive-player-form');
|
||||||
|
var archiveTitle = document.getElementById('modal-archive-player-title');
|
||||||
|
var archiveModal = document.getElementById('modal-archive-player');
|
||||||
|
|
||||||
|
function closeAllDropdowns() {
|
||||||
|
document.querySelectorAll('.card-menu__dropdown').forEach(function (d) {
|
||||||
|
d.setAttribute('hidden', '');
|
||||||
|
d.style.cssText = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the menu outside clipped card panels.
|
||||||
|
document.querySelectorAll('.card-menu__trigger').forEach(function (trigger) {
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var dropdown = this.nextElementSibling;
|
||||||
|
var isOpen = !dropdown.hasAttribute('hidden');
|
||||||
|
closeAllDropdowns();
|
||||||
|
if (!isOpen) {
|
||||||
|
var tr = trigger.getBoundingClientRect();
|
||||||
|
dropdown.style.position = 'fixed';
|
||||||
|
dropdown.style.right = 'auto';
|
||||||
|
dropdown.style.top = 'auto';
|
||||||
|
dropdown.removeAttribute('hidden');
|
||||||
|
var dw = dropdown.offsetWidth;
|
||||||
|
var dh = dropdown.offsetHeight;
|
||||||
|
var left = tr.right - dw;
|
||||||
|
var top = tr.bottom + 4;
|
||||||
|
if (top + dh > window.innerHeight - 8) top = tr.top - dh - 4;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
dropdown.style.left = left + 'px';
|
||||||
|
dropdown.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', closeAllDropdowns);
|
||||||
|
|
||||||
|
document.querySelectorAll('.card-menu__item[data-edit-action]').forEach(function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
closeAllDropdowns();
|
||||||
|
editForm.action = this.dataset.editAction;
|
||||||
|
editName.value = this.dataset.editName;
|
||||||
|
editNotes.value = this.dataset.editNotes;
|
||||||
|
editModal.removeAttribute('hidden');
|
||||||
|
setTimeout(function () { editName.focus(); }, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.card-menu__item[data-archive-action]').forEach(function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
closeAllDropdowns();
|
||||||
|
archiveTitle.textContent = 'Archive ' + this.dataset.archiveName + '?';
|
||||||
|
archiveForm.action = this.dataset.archiveAction;
|
||||||
|
archiveModal.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
|
||||||
|
backdrop.addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Seasons · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header__title">Seasons</h1>
|
||||||
|
<p class="page-header__sub">Organise sessions into named seasons for {{ league.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
{% set has_dated_seasons = active_seasons | selectattr('start_date') | list | length > 0 %}
|
||||||
|
{% if has_dated_seasons %}
|
||||||
|
<div class="panel" style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;">
|
||||||
|
<div>
|
||||||
|
<p class="panel__title" style="margin:0;">Auto-assign sessions</p>
|
||||||
|
<p class="muted-text" style="margin:4px 0 0;font-size:13px;">Assign unassigned sessions to seasons based on their date. Sessions that match zero or multiple seasons are skipped.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('leagues.auto_assign_seasons', league_ref=league.url_ref) }}" style="margin:0;flex-shrink:0;margin-left:20px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Auto-assign</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Seasons</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">New season</h2>
|
||||||
|
</div>
|
||||||
|
<div class="session-create-row">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="e.g. Season 1" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Start date <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="date" name="start_date" value="{{ form.start_date or '' }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>End date <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="date" name="end_date" value="{{ form.end_date or '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Create season</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}{# can_manage #}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Active</p>
|
||||||
|
<h2 class="panel__title">Current seasons</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ active_seasons|length }} season{{ 's' if active_seasons|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% if active_seasons %}
|
||||||
|
<div>
|
||||||
|
{% for season in active_seasons %}
|
||||||
|
<div class="session-row session-row--closed">
|
||||||
|
<div class="session-row__info">
|
||||||
|
<div class="session-row__text">
|
||||||
|
<div class="session-row__title">
|
||||||
|
<a href="{{ url_for('leagues.sessions', league_ref=league.url_ref, season=season.id) }}">{{ season.name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__meta">
|
||||||
|
{% if season.start_date %}
|
||||||
|
<span>{{ season.start_date.strftime('%b %-d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if season.start_date and season.end_date %}
|
||||||
|
<span>—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if season.end_date %}
|
||||||
|
<span>{{ season.end_date.strftime('%b %-d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if not season.start_date and not season.end_date %}
|
||||||
|
<span class="muted-text">No dates set</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="session-row__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-modal="modal-edit-season"
|
||||||
|
data-season-id="{{ season.id }}"
|
||||||
|
data-season-name="{{ season.name }}"
|
||||||
|
data-season-start="{{ season.start_date.isoformat() if season.start_date else '' }}"
|
||||||
|
data-season-end="{{ season.end_date.isoformat() if season.end_date else '' }}">Edit</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.archive_season', league_ref=league.url_ref, season_id=season.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Archive</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel">
|
||||||
|
<p class="muted-text">No active seasons. Create one above.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if archived_seasons %}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Archived</p>
|
||||||
|
<h2 class="panel__title">Past seasons</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ archived_seasons|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% for season in archived_seasons %}
|
||||||
|
<div class="session-row session-row--closed" style="opacity:.7;">
|
||||||
|
<div class="session-row__info">
|
||||||
|
<div class="session-row__text">
|
||||||
|
<div class="session-row__title">{{ season.name }}</div>
|
||||||
|
<div class="session-row__meta">
|
||||||
|
{% if season.start_date %}
|
||||||
|
<span>{{ season.start_date.strftime('%b %-d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if season.start_date and season.end_date %}
|
||||||
|
<span>—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if season.end_date %}
|
||||||
|
<span>{{ season.end_date.strftime('%b %-d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if not season.start_date and not season.end_date %}
|
||||||
|
<span class="muted-text">No dates set</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="session-row__actions">
|
||||||
|
<form method="post" action="{{ url_for('leagues.unarchive_season', league_ref=league.url_ref, season_id=season.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Restore</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-modal="modal-delete-season"
|
||||||
|
data-season-id="{{ season.id }}"
|
||||||
|
data-season-name="{{ season.name }}">Delete</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<!-- Edit season modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-edit-season" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-season-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Season</span>
|
||||||
|
<h2 class="panel__title" id="modal-edit-season-title">Edit season</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" id="form-edit-season" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" name="name" id="edit-season-name" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Start date <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="date" name="start_date" id="edit-season-start">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>End date <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="date" name="end_date" id="edit-season-end">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete season confirmation modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-delete-season" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-delete-season-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Season</span>
|
||||||
|
<h2 class="panel__title" id="modal-delete-season-title">Delete season</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">Permanently delete <strong id="delete-season-name"></strong>? Sessions assigned to this season will not be deleted — they will simply become unassigned. This cannot be undone.</p>
|
||||||
|
<form method="post" id="form-delete-season" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--destructive btn--sm" type="submit">Delete season</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const leagueRef = {{ league.url_ref | tojson }};
|
||||||
|
|
||||||
|
function openModal(id) {
|
||||||
|
document.getElementById(id).hidden = false;
|
||||||
|
}
|
||||||
|
function closeModal(el) {
|
||||||
|
el.closest('.modal-backdrop').hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.matches('[data-close-modal]') || e.target.closest('[data-close-modal]')) {
|
||||||
|
closeModal(e.target.closest('[data-close-modal]') || e.target);
|
||||||
|
}
|
||||||
|
if (e.target.matches('.modal-backdrop')) {
|
||||||
|
e.target.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal="modal-edit-season"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const id = btn.dataset.seasonId;
|
||||||
|
document.getElementById('edit-season-name').value = btn.dataset.seasonName;
|
||||||
|
document.getElementById('edit-season-start').value = btn.dataset.seasonStart || '';
|
||||||
|
document.getElementById('edit-season-end').value = btn.dataset.seasonEnd || '';
|
||||||
|
document.getElementById('form-edit-season').action = '/l/' + leagueRef + '/seasons/' + id + '/update';
|
||||||
|
openModal('modal-edit-season');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal="modal-delete-season"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const id = btn.dataset.seasonId;
|
||||||
|
document.getElementById('delete-season-name').textContent = btn.dataset.seasonName;
|
||||||
|
document.getElementById('form-delete-season').action = '/l/' + leagueRef + '/seasons/' + id + '/delete';
|
||||||
|
openModal('modal-delete-season');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}{# can_manage #}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-top:0;padding-bottom:12px;display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<h1 class="hero__title">{{ session_model.display_label }}</h1>
|
||||||
|
<p class="hero__sub">{{ session_model.session_date.isoformat() | pretty_date }} · <span class="status-badge status-{{ session_model.status }}">{{ session_model.status }}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session_model.id) }}">Public view</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-edit-session">Edit session</button>
|
||||||
|
{% if session_model.status == 'open' %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Close session</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-reopen-session">Reopen session</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if is_owner %}
|
||||||
|
<button class="btn btn--danger btn--sm" type="button" data-modal="modal-delete-session">Delete session</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cash-strip">
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--accent);">
|
||||||
|
<span class="kicker">Invested</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ session.total_invested_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--rank-2);">
|
||||||
|
<span class="kicker">Cashout</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ session.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--pos);">
|
||||||
|
<span class="kicker">Paid out</span>
|
||||||
|
<div class="stat__val" style="color:var(--pos)">{{ session.total_paid_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat stat--rail" style="--rail:var(--warn);">
|
||||||
|
<span class="kicker">Open items</span>
|
||||||
|
<div class="stat__val" style="color:var(--num)">{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="league-manage-grid" style="margin-top:20px;align-items:stretch;">
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post" id="appendForm" style="align-self:stretch;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Append event</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Ledger entry</h2>
|
||||||
|
</div>
|
||||||
|
{% if session_model.status != 'open' %}
|
||||||
|
<div class="session-locked-banner">
|
||||||
|
<strong>Session closed</strong>
|
||||||
|
<span>Books assumed settled. Reopen to add events.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<fieldset {% if session_model.status != 'open' %}disabled{% endif %} style="border:none;padding:0;margin:0;min-width:0;">
|
||||||
|
<label>
|
||||||
|
<span>Player</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="playerSearch"
|
||||||
|
list="playerList"
|
||||||
|
placeholder="Start typing a name…"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{ players | selectattr('id', 'equalto', append_form.player_id) | map(attribute='display_name') | first | default('') }}"
|
||||||
|
>
|
||||||
|
<datalist id="playerList">
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.display_name }}"></option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<input type="hidden" name="player_id" id="playerIdInput" value="{{ append_form.player_id or '' }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Event type</span>
|
||||||
|
<select name="event_type" id="eventTypeSelect" required>
|
||||||
|
{% for value, label in event_types %}
|
||||||
|
<option value="{{ value }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Amount</span>
|
||||||
|
<input type="number" step="0.01" min="0" name="amount" value="{{ append_form.amount }}" placeholder="0.00">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Note <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<textarea name="note" placeholder="Optional context">{{ append_form.note or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit" style="margin-top:8px;">Append event</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel empty-panel">
|
||||||
|
<h2 class="panel__title">Read only</h2>
|
||||||
|
<p class="muted-text">Your role can inspect this session but cannot append ledger events.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Results</p>
|
||||||
|
<h2 class="panel__title">Player totals</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ session.entries|length }} players</span>
|
||||||
|
</div>
|
||||||
|
{% if session.entries %}
|
||||||
|
<div class="player-ledger-list">
|
||||||
|
{% for entry in session.entries %}
|
||||||
|
{% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %}
|
||||||
|
<div class="player-ledger-row {{ row_cls }}">
|
||||||
|
<div class="player-ledger-row__header">
|
||||||
|
<span class="player-ledger-row__name">{{ entry.player_name }}</span>
|
||||||
|
{% if entry.current_due_to_house_cents > 0 %}
|
||||||
|
<span class="badge badge--owes">Owes {{ entry.current_due_to_house_cents | money }}</span>
|
||||||
|
{% elif entry.current_due_to_player_cents > 0 %}
|
||||||
|
<span class="badge badge--unpaid">Due {{ entry.current_due_to_player_cents | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge--settled">Settled</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__fields">
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Buy in</span>
|
||||||
|
<span class="player-ledger-row__val">{{ entry.invested_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Cashout claim</span>
|
||||||
|
<span class="player-ledger-row__val">{{ entry.cash_out_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Paid out</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--pos)">{{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field player-ledger-row__field--net">
|
||||||
|
<span class="player-ledger-row__lbl">Net</span>
|
||||||
|
<span class="player-ledger-row__val {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %}
|
||||||
|
<div class="player-ledger-row__fields player-ledger-row__fields--secondary">
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Rolled in</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Carry in</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-ledger-row__field">
|
||||||
|
<span class="player-ledger-row__lbl">Rolled out</span>
|
||||||
|
<span class="player-ledger-row__val" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:28px;">No player ledger events yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Audit trail</p>
|
||||||
|
<h2 class="panel__title">Raw ledger events</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge--closed">append-only</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-feed feed feed--inline" style="max-height:none;">
|
||||||
|
{% for event in raw_events | reverse %}
|
||||||
|
<div class="event-card event-card--inline {% if event.voided_at %}event-card--voided{% endif %}">
|
||||||
|
<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.voided_at %}<span class="void-badge">voided</span>{% endif %}
|
||||||
|
{% if event.note and not event.voided_at %}<span class="event-note">{{ event.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||||
|
{% if event.voided_at %}
|
||||||
|
<p class="tiny-text void-reason">{{ event.void_reason }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="tiny-text">{{ event.created_at }}</p>
|
||||||
|
{% if is_owner %}
|
||||||
|
<button class="btn-void-toggle" type="button"
|
||||||
|
data-void-id="{{ event.id }}"
|
||||||
|
data-void-player="{{ event.player_name or 'Session' }}"
|
||||||
|
data-void-type="{{ event.event_type | replace('_', ' ') }}"
|
||||||
|
data-void-amount="${{ '%.2f' % (event.amount_cents / 100) }}"
|
||||||
|
data-void-action="{{ url_for('leagues.void_event', league_ref=league.url_ref, event_id=event.id) }}">
|
||||||
|
void
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted-text" style="padding:20px;">No events recorded.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_owner %}
|
||||||
|
<div class="modal-backdrop" id="modal-void-event" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-void-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;color:var(--warn);">Correct the record</span>
|
||||||
|
<h2 class="panel__title" id="modal-void-title">Void event</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text" id="modal-void-desc">This event will remain visible in the audit trail but will no longer count toward any calculations.</p>
|
||||||
|
<form id="modal-void-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<label style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px;">
|
||||||
|
<span style="font:600 12px/1 var(--font-ui);color:var(--text-2);text-transform:uppercase;letter-spacing:.04em;">Reason</span>
|
||||||
|
<input type="text" name="void_reason" placeholder="What was wrong with this event?" required maxlength="200"
|
||||||
|
style="background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);padding:9px 12px;font:400 14px/1.4 var(--font-ui);color:var(--text-1);">
|
||||||
|
</label>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Void this event</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-delete-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-delete-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;color:var(--neg);">Destructive action</span>
|
||||||
|
<h2 class="panel__title" id="modal-delete-title">Delete {{ session_model.display_label }}?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This will permanently delete the session and <strong>all {{ session_model.event_count if session_model.event_count is defined else 'its' }} ledger events</strong> attached to it. There is no undo.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.delete_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Yes, delete permanently</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="modal-backdrop" id="modal-edit-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-edit-session-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Session</span>
|
||||||
|
<h2 class="panel__title" id="modal-edit-session-title">Edit session</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('leagues.edit_session', league_ref=league.url_ref, session_id=session_model.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>Date</span>
|
||||||
|
<input type="date" name="session_date" value="{{ session_model.session_date.isoformat() }}" required>
|
||||||
|
</label>
|
||||||
|
{% if seasons %}
|
||||||
|
<label>
|
||||||
|
<span>Season <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<select name="season_id">
|
||||||
|
<option value="">No season</option>
|
||||||
|
{% for s in seasons %}
|
||||||
|
<option value="{{ s.id }}" {{ 'selected' if session_model.season_id == s.id else '' }}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<label>
|
||||||
|
<span>Label <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="label" value="{{ session_model.label or '' }}" placeholder="e.g. Main table">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<input type="text" name="notes" value="{{ session_model.notes or '' }}" placeholder="Optional context">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-reopen-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-reopen-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Settled session</span>
|
||||||
|
<h2 class="panel__title" id="modal-reopen-title">Reopen this session?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This session has been closed and books are assumed to be settled. Reopening will allow new ledger events to be appended and may affect settlement totals.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form method="post" action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session_model.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Reopen session</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const playerMap = {
|
||||||
|
{% for player in players %}"{{ player.display_name | replace('"', '\\"') }}": "{{ player.id }}"{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
const searchInput = document.getElementById('playerSearch');
|
||||||
|
const idInput = document.getElementById('playerIdInput');
|
||||||
|
const eventTypeSelect = document.getElementById('eventTypeSelect');
|
||||||
|
|
||||||
|
if (searchInput && idInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
idInput.value = playerMap[this.value.trim()] || '';
|
||||||
|
});
|
||||||
|
searchInput.addEventListener('change', function () {
|
||||||
|
idInput.value = playerMap[this.value.trim()] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventTypeSelect && searchInput && idInput) {
|
||||||
|
eventTypeSelect.addEventListener('change', function () {
|
||||||
|
if (this.value === 'note') {
|
||||||
|
searchInput.value = '';
|
||||||
|
idInput.value = '';
|
||||||
|
searchInput.placeholder = 'No player (session note)';
|
||||||
|
} else {
|
||||||
|
searchInput.placeholder = 'Start typing a name…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (eventTypeSelect.value === 'note') {
|
||||||
|
searchInput.placeholder = 'No player (session note)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var target = document.getElementById(this.dataset.modal);
|
||||||
|
if (target) target.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(function(backdrop) {
|
||||||
|
backdrop.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-void-id]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var player = this.dataset.voidPlayer;
|
||||||
|
var type = this.dataset.voidType;
|
||||||
|
var amount = this.dataset.voidAmount;
|
||||||
|
document.getElementById('modal-void-title').textContent = 'Void ' + type + ' — ' + player;
|
||||||
|
document.getElementById('modal-void-desc').textContent =
|
||||||
|
'The ' + amount + ' ' + type + ' event for ' + player +
|
||||||
|
' will be marked voided and excluded from all calculations. It stays visible in the audit trail.';
|
||||||
|
var form = document.getElementById('modal-void-form');
|
||||||
|
form.action = this.dataset.voidAction;
|
||||||
|
form.querySelector('[name=void_reason]').value = '';
|
||||||
|
document.getElementById('modal-void-event').removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<span class="breadcrumb__sep">/</span>
|
||||||
|
<span>{{ session_model.display_label }}</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
{% if prev_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=prev_session_id) }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_session_id %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=next_session_id) }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn--outline btn--sm" id="copyLinkBtn" type="button" onclick="copySessionLink(this)">Share</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero" style="padding-top:0;">
|
||||||
|
<p class="hero__kicker session-number">Session #{{ "%03d"|format(session_number) }}</p>
|
||||||
|
<h1 class="hero__title">{{ session_model.display_label }}</h1>
|
||||||
|
<p class="hero__sub">{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · <span class="status-badge status-{{ session_model.status }}">{{ session_model.status }}</span>{% if season %} · <a class="season-tag" href="{{ url_for('leagues.sessions', league_ref=league.url_ref, season=session_model.season_id) }}">{{ season.name }}</a>{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recon cards -->
|
||||||
|
<div class="recon-grid" style="margin-bottom:20px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="recon-card-head">
|
||||||
|
<span class="kicker kicker--dot kicker--dot-accent">Banker cash flow</span>
|
||||||
|
</div>
|
||||||
|
<div class="recon-metric-grid">
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Actual cash in</span>
|
||||||
|
<div class="recon-val recon-val--num">{{ session.total_real_cash_in_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Gross player claim</span>
|
||||||
|
<div class="recon-val recon-val--num">{{ session.total_gross_payout_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Cash paid out</span>
|
||||||
|
<div class="recon-val recon-val--pos">{{ session.total_real_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Rolled out</span>
|
||||||
|
<div class="recon-val" style="color:var(--rolled)">{{ session.total_rollover_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="recon-card-head">
|
||||||
|
<span class="kicker kicker--dot kicker--dot-warn">Open items</span>
|
||||||
|
</div>
|
||||||
|
<div class="recon-metric-grid">
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">House owes players</span>
|
||||||
|
<div class="recon-val {{ 'recon-val--neg' if session.total_current_due_to_player_cents > 0 else 'recon-val--pos' }}">
|
||||||
|
{{ session.total_current_due_to_player_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Players owe house</span>
|
||||||
|
<div class="recon-val {{ 'recon-val--warn' if session.total_current_due_to_house_cents > 0 else '' }}">
|
||||||
|
{{ session.total_current_due_to_house_cents | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Cash debt repaid</span>
|
||||||
|
<div class="recon-val" style="color:var(--pos)">{{ session.total_debt_repayment_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recon-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Written off</span>
|
||||||
|
<div class="recon-val" style="color:var(--neg)">{{ session.total_writeoff_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player totals table -->
|
||||||
|
<div class="panel" style="margin-bottom:20px;">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Results</span>
|
||||||
|
<h2 class="panel__title">Player totals</h2>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" id="viewToggle">
|
||||||
|
<button class="seg is-on" data-view="compact">Compact</button>
|
||||||
|
<button class="seg" data-view="full">Full ledger</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-scroll">
|
||||||
|
<table class="tbl tbl--session" id="sessionTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-player">Player</th>
|
||||||
|
<th>Invested</th>
|
||||||
|
<th>Gross cashout</th>
|
||||||
|
<th>Net</th>
|
||||||
|
<th>Paid out</th>
|
||||||
|
<th>Open settlement</th>
|
||||||
|
<th>Settlement status</th>
|
||||||
|
<th class="col-acct">Front</th>
|
||||||
|
<th class="col-acct">Rolled in</th>
|
||||||
|
<th class="col-acct">Payout carry-in</th>
|
||||||
|
<th class="col-acct">Gross player claim</th>
|
||||||
|
<th class="col-acct">Rolled out</th>
|
||||||
|
<th class="col-acct">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in session.entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="col-player">{{ entry.player_name }}</td>
|
||||||
|
<td>{{ entry.invested_cents | money }}</td>
|
||||||
|
<td>{{ entry.cash_out_cents | money }}</td>
|
||||||
|
<td class="num-net {{ 'num-pos' if entry.net_cents > 0 else 'num-neg' if entry.net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--pos)">{{ entry.paid_out_cents | money }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.current_due_to_house_cents > 0 %}
|
||||||
|
<span style="color:var(--owes)">owes {{ entry.current_due_to_house_cents | money }}</span>
|
||||||
|
{% elif entry.current_due_to_player_cents > 0 %}
|
||||||
|
<span style="color:var(--neg)">due {{ entry.current_due_to_player_cents | money }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="num-muted">{{ 0 | money }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge--{{ entry.payout_status }}">{{ entry.payout_status }}</span></td>
|
||||||
|
<td class="col-acct" style="color:var(--warn)">{{ entry.front_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_in_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.payout_carry_in_cents | money }}</td>
|
||||||
|
<td class="col-acct">
|
||||||
|
{% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-acct" style="color:var(--rolled)">{{ entry.rollover_out_cents | money }}</td>
|
||||||
|
<td class="col-acct" style="text-align:left;font-family:var(--font-ui);">
|
||||||
|
{% if entry.notes %}
|
||||||
|
<ul class="note-list">{% for n in entry.notes %}<li>{{ n }}</li>{% endfor %}</ul>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faintest-2)">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="13" style="text-align:left;padding:28px;color:var(--muted);">No player events yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Net bars + snapshot -->
|
||||||
|
<div class="split">
|
||||||
|
<div class="panel col-main">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Breakdown</span>
|
||||||
|
<h2 class="panel__title">Net by player</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="session-net-bars" id="sessionBreakdownChart" aria-label="Net by player"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel col-side">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="kicker" style="margin:0;">Quick read</span>
|
||||||
|
<h2 class="panel__title">Session snapshot</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %}
|
||||||
|
{% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %}
|
||||||
|
<div class="snapshot-grid">
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Biggest winner</span>
|
||||||
|
<div class="snapshot-val" style="color:var(--pos)">
|
||||||
|
{% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if winners %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Biggest loser</span>
|
||||||
|
<div class="snapshot-val" style="color:var(--neg)">
|
||||||
|
{% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if losers %}<div style="font-size:12px;color:var(--muted);margin-top:4px;">{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Gross cashout result</span>
|
||||||
|
<div class="snapshot-val">{{ session.total_cash_out_cents | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-cell">
|
||||||
|
<span class="kicker" style="font-size:10px;">Open items</span>
|
||||||
|
<div class="snapshot-val {{ 'recon-val--neg' if (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) > 0 else '' }}">
|
||||||
|
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sessionChartData = {{ chart_data | tojson }};
|
||||||
|
const breakdownCtx = document.getElementById('sessionBreakdownChart');
|
||||||
|
if (breakdownCtx) {
|
||||||
|
const values = sessionChartData.net_values || [];
|
||||||
|
const labels = sessionChartData.labels || [];
|
||||||
|
const colors = sessionChartData.net_colors || [];
|
||||||
|
const maxAbs = Math.max(...values.map(v => Math.abs(v)), 1);
|
||||||
|
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
const esc = v => String(v).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
breakdownCtx.innerHTML = labels.map((label, i) => {
|
||||||
|
const val = Number(values[i] || 0);
|
||||||
|
const w = Math.min(Math.abs(val) / maxAbs * 50, 50);
|
||||||
|
const cls = val < 0 ? 'session-net-bars__bar--neg' : 'session-net-bars__bar--pos';
|
||||||
|
const amt = `${val > 0 ? '+' : ''}${fmt.format(val)}`;
|
||||||
|
const color = colors[i] || (val < 0 ? '#e0758a' : val > 0 ? '#6fc093' : '#84828e');
|
||||||
|
return `<div class="session-net-bars__row">
|
||||||
|
<div class="session-net-bars__name">${esc(label)}</div>
|
||||||
|
<div class="session-net-bars__track"><span class="session-net-bars__bar ${cls}" style="width:${w}%;background:${color};"></span></div>
|
||||||
|
<div class="session-net-bars__amount">${amt}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#viewToggle .seg').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#viewToggle .seg').forEach(b => b.classList.remove('is-on'));
|
||||||
|
btn.classList.add('is-on');
|
||||||
|
document.getElementById('sessionTable').classList.toggle('is-full', btn.dataset.view === 'full');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function copySessionLink(btn) {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(function() {
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||||
|
}).catch(function() {
|
||||||
|
// Fallback for non-HTTPS or denied clipboard
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = window.location.href;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sessions · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header__title">Sessions</h1>
|
||||||
|
<p class="page-header__sub">All game sessions for {{ league.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Add session</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">New session</h2>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="status" value="open">
|
||||||
|
<div class="session-create-row">
|
||||||
|
<label>
|
||||||
|
<span>Date <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The night the game was played. Same-day sessions automatically become S1, S2, etc.</span></span></span>
|
||||||
|
<input type="date" name="session_date" value="{{ form.session_date }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Season <span class="form-opt-hint">(optional)</span></span>
|
||||||
|
<select name="season_id">
|
||||||
|
<option value="">No season</option>
|
||||||
|
{% for s in seasons %}
|
||||||
|
<option value="{{ s.id }}" {{ 'selected' if form.season_id == s.id else '' }}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A short name for this session — 'Main table', 'Side game', etc. Shown in the session list alongside the date.</span></span></span>
|
||||||
|
<input type="text" name="label" value="{{ form.label or '' }}" placeholder="e.g. Main table">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional context visible in the session list — venue, occasion, anything worth noting.</span></span></span>
|
||||||
|
<input type="text" name="notes" value="{{ form.notes or '' }}" placeholder="Optional context">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Create session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">History</p>
|
||||||
|
<h2 class="panel__title">All sessions</h2>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
{% if seasons %}
|
||||||
|
<select class="session-filter-select" onchange="if(this.value) window.location=this.value;">
|
||||||
|
<option value="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}" {{ 'selected' if not selected_season else '' }}>All seasons</option>
|
||||||
|
{% for s in seasons %}
|
||||||
|
<option value="{{ url_for('leagues.sessions', league_ref=league.url_ref, season=s.id) }}" {{ 'selected' if selected_season_id == s.id else '' }}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if selected_season %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="panel__tag" id="sess-count-label">{{ sessions|length }}{% if selected_season %} / {{ all_session_count }}{% endif %} total</span>
|
||||||
|
{% if can_manage and empty_count > 0 and not selected_season %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.prune_empty_sessions', league_ref=league.url_ref) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Prune empty ({{ empty_count }})</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if sessions %}
|
||||||
|
<div class="sessions-search-bar">
|
||||||
|
<svg class="sessions-search-bar__icon" width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input class="sessions-search-bar__input" id="sess-search" type="search" placeholder="Search by name, date, or notes…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="sess-empty-search" class="empty-panel" style="display:none;">
|
||||||
|
<p class="muted-text">No sessions match your search.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% for session in sessions %}
|
||||||
|
{% set sm = db_id_to_summary.get(session.id) %}
|
||||||
|
{% set season_name = season_map[session.season_id].name if session.season_id and season_map.get(session.season_id) else '' %}
|
||||||
|
<div class="session-row session-row--{{ session.status }}"
|
||||||
|
data-search="{{ (session.display_label ~ ' ' ~ session.session_date.isoformat() ~ ' ' ~ session.session_date.strftime('%B %Y') ~ ' ' ~ (session.notes or '') ~ ' ' ~ season_name) | lower }}">
|
||||||
|
<div class="session-row__info">
|
||||||
|
<div class="session-date-block">
|
||||||
|
<span class="session-date-block__mon">{{ session.session_date.strftime('%b') }}</span>
|
||||||
|
<span class="session-date-block__day">{{ session.session_date.strftime('%-d') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__text">
|
||||||
|
<div class="session-row__title">
|
||||||
|
<a href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">{{ session.display_label }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__meta">
|
||||||
|
<span>S{{ session.sequence_on_date }}</span>
|
||||||
|
{% if session.season_id and season_map.get(session.season_id) %}
|
||||||
|
<span class="season-tag">{{ season_map[session.season_id].name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if sm and sm.entries %}
|
||||||
|
<span>· {{ sm.entries|length }} player{{ 's' if sm.entries|length != 1 else '' }}</span>
|
||||||
|
<span>· {{ sm.total_invested_cents | money }} pot</span>
|
||||||
|
{% if sm.total_current_due_to_house_cents > 0 or sm.total_current_due_to_player_cents > 0 %}
|
||||||
|
<span style="color:var(--warn);">· {{ (sm.total_current_due_to_house_cents + sm.total_current_due_to_player_cents) | money }} open</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if session.notes %}<span>· {{ session.notes }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-row__actions">
|
||||||
|
<span class="status-pill status-pill--{{ session.status }}">{{ session.status }}</span>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_public_view', league_ref=league.url_ref, session_id=session.id) }}">View</a>
|
||||||
|
{% if can_manage %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.session_detail', league_ref=league.url_ref, session_id=session.id) }}">Manage</a>
|
||||||
|
{% if session.status == 'open' %}
|
||||||
|
<form method="post" action="{{ url_for('leagues.close_league_session', league_ref=league.url_ref, session_id=session.id) }}" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Close</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn--outline btn--sm" type="button"
|
||||||
|
data-modal="modal-reopen-session"
|
||||||
|
data-reopen-action="{{ url_for('leagues.open_league_session', league_ref=league.url_ref, session_id=session.id) }}">Reopen</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-panel">
|
||||||
|
<h3 class="panel__title">No sessions yet</h3>
|
||||||
|
<p class="muted-text">Create the first session above. Same-day sessions will automatically become S1, S2, and so on.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reopen confirmation modal -->
|
||||||
|
<div class="modal-backdrop" id="modal-reopen-session" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-reopen-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Settled session</span>
|
||||||
|
<h2 class="panel__title" id="modal-reopen-title">Reopen this session?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">This session has been closed and books are assumed to be settled. Reopening will allow new ledger events to be appended and may affect settlement totals.</p>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<form id="reopenForm" method="post" action="" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Reopen session</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var reopenModal = document.getElementById('modal-reopen-session');
|
||||||
|
var reopenForm = document.getElementById('reopenForm');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal="modal-reopen-session"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (reopenForm && this.dataset.reopenAction) {
|
||||||
|
reopenForm.action = this.dataset.reopenAction;
|
||||||
|
}
|
||||||
|
if (reopenModal) reopenModal.removeAttribute('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
this.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
reopenModal && reopenModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('sess-search');
|
||||||
|
var countLabel = document.getElementById('sess-count-label');
|
||||||
|
var emptyMsg = document.getElementById('sess-empty-search');
|
||||||
|
if (!input) return;
|
||||||
|
var total = {{ sessions|length }};
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = this.value.toLowerCase().trim();
|
||||||
|
var rows = document.querySelectorAll('.session-row[data-search]');
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var match = !q || row.dataset.search.includes(q);
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
if (match) visible++;
|
||||||
|
});
|
||||||
|
if (countLabel) countLabel.textContent = q ? visible + ' of ' + total : total + ' total';
|
||||||
|
if (emptyMsg) emptyMsg.style.display = (q && visible === 0) ? '' : 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings · {{ league.name }} · myboker.org{% endblock %}
|
||||||
|
{% block sidebar %}{% include "_league_sidebar.html" %}{% endblock %}
|
||||||
|
{% block page_class %}page--app{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">General</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">League info</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Name <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">The display name for this league. Shown on your dashboard and the public leaderboard.</span></span></span>
|
||||||
|
<input type="text" name="name" value="{{ form.name }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description <span class="form-opt-hint">(optional)</span> <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Optional. A short note about the league — game night, buy-in rules, anything useful to members.</span></span></span>
|
||||||
|
<textarea name="description">{{ form.description }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Visibility <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Private keeps the league off the Explore page. Public makes the leaderboard and session history visible to anyone with the link — no account needed.</span></span></span>
|
||||||
|
<select name="visibility">
|
||||||
|
<option value="private" {{ 'selected' if form.visibility == 'private' else '' }}>Private — only members can see this league</option>
|
||||||
|
<option value="public" {{ 'selected' if form.visibility == 'public' else '' }}>Public — leaderboard is visible to anyone</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style="margin-top:8px;padding-top:20px;border-top:1px solid var(--border);">
|
||||||
|
<p class="eyebrow">Leaderboard</p>
|
||||||
|
<p class="muted-text" style="margin-top:4px;margin-bottom:0;font-size:.82rem;">Controls how players qualify and how sessions are classified.</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-create-row" style="grid-template-columns:1fr 1fr;">
|
||||||
|
<label>
|
||||||
|
<span>Sessions to rank <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">Minimum number of sessions a player must have played to appear on the ranked leaderboard. Players below this threshold are shown as provisional.</span></span></span>
|
||||||
|
<input type="number" name="eligible_min_sessions" value="{{ form.eligible_min_sessions }}" min="1" max="100" step="1" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Break-even threshold ($) <span class="info-tip" tabindex="0"><svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="6.5" cy="4" r=".7" fill="currentColor"/></svg><span class="info-tip__bubble">A session within ±this amount of $0 is counted as a break-even rather than a win or loss. Default is $1.00.</span></span></span>
|
||||||
|
<input type="number" name="break_even_dollars" value="{{ form.break_even_dollars }}" min="0" max="100" step="0.50" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel access-panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Access</p>
|
||||||
|
<h2 class="panel__title">Members</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel__tag">{{ members|length }} member{{ 's' if members|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="access-panel__grid">
|
||||||
|
<div class="access-panel__members" aria-label="League members">
|
||||||
|
{% for membership, member_user in members %}
|
||||||
|
<div class="member-row">
|
||||||
|
<div class="member-row__identity">
|
||||||
|
<strong>{{ member_user.email }}</strong>
|
||||||
|
<span class="member-role member-role--{{ membership.role }}">{{ membership.role }}</span>
|
||||||
|
</div>
|
||||||
|
{% if membership.role != 'owner' and member_user.id != current_user_id %}
|
||||||
|
<div class="member-row__actions">
|
||||||
|
{% if membership.role == 'viewer' %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-member-action="promote"
|
||||||
|
data-member-email="{{ member_user.email }}"
|
||||||
|
data-member-role="manager"
|
||||||
|
data-member-url="{{ url_for('leagues.update_member_role', league_ref=league.url_ref, user_id=member_user.id) }}">Promote</button>
|
||||||
|
{% elif membership.role == 'manager' %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-member-action="demote"
|
||||||
|
data-member-email="{{ member_user.email }}"
|
||||||
|
data-member-role="viewer"
|
||||||
|
data-member-url="{{ url_for('leagues.update_member_role', league_ref=league.url_ref, user_id=member_user.id) }}">Demote</button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button"
|
||||||
|
data-member-action="remove"
|
||||||
|
data-member-email="{{ member_user.email }}"
|
||||||
|
data-member-url="{{ url_for('leagues.remove_member', league_ref=league.url_ref, user_id=member_user.id) }}">Remove</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<form class="access-panel__invite form-card" id="invite-member-form" method="post" action="{{ url_for('leagues.invite_member', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0 0 4px;">Invite by email</p>
|
||||||
|
<h3 class="access-panel__invite-title">Add a member</h3>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" placeholder="friend@example.com" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Role</span>
|
||||||
|
<select name="role">
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Send invite</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;color:var(--neg);">Danger zone</p>
|
||||||
|
<h2 class="panel__title">Destructive actions</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Transfer ownership</strong>
|
||||||
|
<p class="muted-text">Choose an existing member to become the league owner. Your role will change to manager.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--warning btn--sm danger-row__btn" type="button" data-modal="modal-transfer-owner" {% if members|length <= 1 %}disabled{% endif %}>Transfer ownership</button>
|
||||||
|
</div>
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Archive league</strong>
|
||||||
|
<p class="muted-text">Removes this league from your dashboard. All players, sessions, and ledger data are preserved.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--warning btn--sm danger-row__btn" type="button" data-modal="modal-archive">Archive league</button>
|
||||||
|
</div>
|
||||||
|
<div class="danger-row">
|
||||||
|
<div>
|
||||||
|
<strong class="danger-row__title">Delete league</strong>
|
||||||
|
<p class="muted-text">Permanently deletes this league and all players, sessions, and ledger data. This cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--warning btn--sm danger-row__btn" type="button" data-modal="modal-delete-league">Delete league</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-transfer-owner" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-transfer-owner-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Ownership</span>
|
||||||
|
<h2 class="panel__title" id="modal-transfer-owner-title">Transfer ownership</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">The selected member will become the owner of <strong>{{ league.name }}</strong>. Your role will change to manager.</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.transfer_ownership', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 16px;gap:12px;">
|
||||||
|
<label>
|
||||||
|
<span>New owner</span>
|
||||||
|
<select name="new_owner_user_id" id="transfer-owner-select" required>
|
||||||
|
<option value="">Select a member</option>
|
||||||
|
{% for membership, member_user in members %}
|
||||||
|
{% if membership.role != 'owner' %}
|
||||||
|
<option value="{{ member_user.id }}">{{ member_user.email }} · {{ membership.role }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Transfer ownership</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-member-role" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-member-role-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Access</span>
|
||||||
|
<h2 class="panel__title" id="modal-member-role-title">Change member role?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text" id="modal-member-role-text"></p>
|
||||||
|
<form id="member-role-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="role" id="member-role-input" value="">
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--primary btn--sm" id="member-role-confirm" type="submit">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-member-remove" hidden>
|
||||||
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-member-remove-title">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Access</span>
|
||||||
|
<h2 class="panel__title" id="modal-member-remove-title">Remove member?</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text" id="modal-member-remove-text"></p>
|
||||||
|
<form id="member-remove-form" method="post" action="">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Remove member</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="modal-archive" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Danger zone</span>
|
||||||
|
<h2 class="panel__title">Archive league</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
<strong>{{ league.name }}</strong> will be hidden from your dashboard. All data is preserved and can be recovered by contacting support. This does not delete any sessions or ledger events.<br><br>
|
||||||
|
Type the league name to confirm:
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.archive_league', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;">
|
||||||
|
<input
|
||||||
|
class="modal-confirm-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm_name"
|
||||||
|
placeholder="{{ league.name }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="{{ league.name }}"
|
||||||
|
data-target="archive-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="archive-confirm-btn" type="submit" disabled>Archive league</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" id="modal-delete-league" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<span class="kicker" style="margin:0;">Danger zone</span>
|
||||||
|
<h2 class="panel__title">Delete league</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
<strong>{{ league.name }}</strong> and all its players, sessions, and ledger data will be <strong>permanently deleted</strong>. This cannot be undone.<br><br>
|
||||||
|
Type the league name to confirm:
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.delete_league_route', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-card" style="padding:0 20px 4px;">
|
||||||
|
<input
|
||||||
|
class="modal-confirm-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm_name"
|
||||||
|
placeholder="{{ league.name }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="{{ league.name }}"
|
||||||
|
data-target="delete-league-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="delete-league-confirm-btn" type="submit" disabled>Permanently delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.removeAttribute('hidden');
|
||||||
|
const inp = el.querySelector('.modal-confirm-input, input[type="password"]');
|
||||||
|
if (inp) { inp.value = ''; inp.focus(); checkConfirmInput(inp); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeModal(el) {
|
||||||
|
el.closest('.modal-backdrop').setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
function checkConfirmInput(inp) {
|
||||||
|
const targetId = inp.dataset.target;
|
||||||
|
const btn = document.getElementById(targetId);
|
||||||
|
if (!btn) return;
|
||||||
|
if (inp.dataset.match) {
|
||||||
|
btn.disabled = inp.value !== inp.dataset.match;
|
||||||
|
} else if (inp.dataset.minlength) {
|
||||||
|
btn.disabled = inp.value.length < parseInt(inp.dataset.minlength, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-modal]').forEach(trigger => {
|
||||||
|
trigger.addEventListener('click', () => openModal(trigger.dataset.modal));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||||
|
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.setAttribute('hidden', ''); });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-confirm-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => checkConfirmInput(inp));
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleModal = document.getElementById('modal-member-role');
|
||||||
|
const roleForm = document.getElementById('member-role-form');
|
||||||
|
const roleInput = document.getElementById('member-role-input');
|
||||||
|
const roleTitle = document.getElementById('modal-member-role-title');
|
||||||
|
const roleText = document.getElementById('modal-member-role-text');
|
||||||
|
const roleConfirm = document.getElementById('member-role-confirm');
|
||||||
|
const removeModal = document.getElementById('modal-member-remove');
|
||||||
|
const removeForm = document.getElementById('member-remove-form');
|
||||||
|
const removeTitle = document.getElementById('modal-member-remove-title');
|
||||||
|
const removeText = document.getElementById('modal-member-remove-text');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-member-action]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const action = btn.dataset.memberAction;
|
||||||
|
const email = btn.dataset.memberEmail;
|
||||||
|
|
||||||
|
if (action === 'remove') {
|
||||||
|
removeForm.action = btn.dataset.memberUrl;
|
||||||
|
removeTitle.textContent = 'Remove ' + email + '?';
|
||||||
|
removeText.textContent = 'This member will lose access to ' + {{ league.name|tojson }} + '. Their league data and ledger history will stay intact.';
|
||||||
|
removeModal.removeAttribute('hidden');
|
||||||
|
removeModal.querySelector('[data-close-modal]')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRole = btn.dataset.memberRole;
|
||||||
|
roleForm.action = btn.dataset.memberUrl;
|
||||||
|
roleInput.value = nextRole;
|
||||||
|
roleTitle.textContent = (action === 'promote' ? 'Promote ' : 'Demote ') + email + '?';
|
||||||
|
roleText.textContent = email + ' will become a ' + nextRole + ' for ' + {{ league.name|tojson }} + '.';
|
||||||
|
roleConfirm.textContent = action === 'promote' ? 'Promote member' : 'Demote member';
|
||||||
|
roleModal.removeAttribute('hidden');
|
||||||
|
roleModal.querySelector('[data-close-modal]')?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteForm = document.getElementById('invite-member-form');
|
||||||
|
if (inviteForm) {
|
||||||
|
inviteForm.addEventListener('submit', () => {
|
||||||
|
const btn = inviteForm.querySelector('button[type="submit"]');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sending...';
|
||||||
|
inviteForm.setAttribute('aria-busy', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Leagues · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header__title">Your leagues</h1>
|
||||||
|
{% if leagues %}
|
||||||
|
<p class="page-header__sub">
|
||||||
|
{{ page_summary.league_count }} league{{ 's' if page_summary.league_count != 1 else '' }}
|
||||||
|
{% if page_summary.live_count > 0 %} · <span style="color:var(--pos);">{{ page_summary.live_count }} live</span>{% endif %}
|
||||||
|
· {{ page_summary.sessions_count }} session{{ 's' if page_summary.sessions_count != 1 else '' }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="page-header__actions">
|
||||||
|
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.new') }}">New league</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leagues %}
|
||||||
|
|
||||||
|
<div class="li-board">
|
||||||
|
{% for item in leagues %}
|
||||||
|
{% set league = item.league %}
|
||||||
|
{% set membership = item.membership %}
|
||||||
|
<article class="li-card{% if item.live_session %} li-card--live{% endif %}">
|
||||||
|
|
||||||
|
<div class="li-card__aside">
|
||||||
|
<span class="li-card__initial">{{ league.name[0]|upper }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="li-card__body">
|
||||||
|
<div class="li-card__head">
|
||||||
|
<div class="li-card__info">
|
||||||
|
<div class="li-card__name-row">
|
||||||
|
<a class="li-card__name" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">{{ league.name }}</a>
|
||||||
|
{% if item.live_session %}
|
||||||
|
<span class="live-badge">{{ session_label(item.live_session) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-pill status-pill--closed">idle</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="li-card__chips">
|
||||||
|
<span class="db-chip db-chip--accent">{{ membership.role }}</span>
|
||||||
|
<span class="db-chip {% if league.visibility == 'public' %}db-chip--public{% endif %}">
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M10 2c-2.5 3-2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M10 2c2.5 3 2.5 13 0 16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M2.5 10h15" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
public
|
||||||
|
{% else %}
|
||||||
|
<svg width="9" height="11" viewBox="0 0 10 12" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="5" width="8" height="6.5" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M3 5V3.5A2 2 0 0 1 7 3.5V5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
private
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if league.description %}
|
||||||
|
<p class="li-card__desc">{{ league.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="li-card__stats">
|
||||||
|
<div class="li-stat">
|
||||||
|
<span>Players</span>
|
||||||
|
<strong>{{ item.players_count }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="li-stat">
|
||||||
|
<span>Sessions</span>
|
||||||
|
<strong>{{ item.sessions_count }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="li-stat">
|
||||||
|
<span>Paid out</span>
|
||||||
|
<strong>{{ item.cash_paid_out_cents | money }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="li-stat">
|
||||||
|
<span>Open</span>
|
||||||
|
<strong class="{{ 'num-neg' if item.open_items_cents > 0 else 'num-muted' }}">{{ item.open_items_cents | money }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.top_players %}
|
||||||
|
<div class="li-card__leaders">
|
||||||
|
<span class="li-card__leaders-label">Leaders</span>
|
||||||
|
<div class="li-podium">
|
||||||
|
{% for player in item.top_players %}
|
||||||
|
<div class="li-podium-item li-podium-item--{{ loop.index }}">
|
||||||
|
<span class="li-podium-rank">#{{ loop.index }}</span>
|
||||||
|
<span class="li-podium-name">{{ player.player_name }}</span>
|
||||||
|
<strong class="li-podium-net {{ 'num-pos' if player.total_net_cents > 0 else 'num-neg' if player.total_net_cents < 0 else 'num-muted' }}">
|
||||||
|
{{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="li-card__footer">
|
||||||
|
<span class="li-card__meta">
|
||||||
|
{% if item.latest_session %}Latest {{ session_label(item.latest_session) }}{% elif not item.top_players %}No sessions yet{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="li-card__actions">
|
||||||
|
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}">Open league</a>
|
||||||
|
<a class="li-action-link" href="{{ url_for('leagues.sessions', league_ref=league.url_ref) }}">Sessions</a>
|
||||||
|
<a class="li-action-link" href="{{ url_for('leagues.leaderboard', league_ref=league.url_ref) }}">Leaderboard</a>
|
||||||
|
{% if membership.role in ('owner', 'manager') %}
|
||||||
|
<a class="li-action-link" href="{{ url_for('leagues.ledger', league_ref=league.url_ref) }}">Ledger</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="panel empty-panel">
|
||||||
|
<h2 class="panel__title">No leagues yet</h2>
|
||||||
|
<p class="muted-text">Create your first league to start adding players, sessions, and ledger events.</p>
|
||||||
|
<a class="btn btn--primary" href="{{ url_for('leagues.new') }}">Create league</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Privacy Policy - myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--public{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="legal-doc">
|
||||||
|
<header class="legal-doc__header">
|
||||||
|
<p class="lp-kicker">Legal</p>
|
||||||
|
<h1 class="legal-doc__title">Privacy Policy</h1>
|
||||||
|
<p class="legal-doc__meta">Effective June 28, 2026. Owned and operated by <strong>Cedarline Digital</strong>.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legal-doc__body">
|
||||||
|
|
||||||
|
<h2>1. Who we are</h2>
|
||||||
|
<p>myboker.org is owned and operated by <strong>Cedarline Digital</strong>. "We," "us," and "our" refer to Cedarline Digital. By using myboker.org, you agree to the collection and use of information as described in this policy.</p>
|
||||||
|
<p>This policy is governed by the <em>Personal Information Protection and Electronic Documents Act</em> (PIPEDA) and the BC <em>Personal Information Protection Act</em> (PIPA).</p>
|
||||||
|
|
||||||
|
<h2>2. Information we collect</h2>
|
||||||
|
|
||||||
|
<h3>Account information</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Your <strong>email address</strong>, required to create and verify your account.</li>
|
||||||
|
<li>Your <strong>password</strong>, stored exclusively as a one-way bcrypt hash. We never store or have access to your plaintext password.</li>
|
||||||
|
<li>Timestamps for account creation, email verification, and most recent login.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>League and gameplay data</h3>
|
||||||
|
<ul>
|
||||||
|
<li>League names, descriptions, visibility settings (private or public), and configuration you create.</li>
|
||||||
|
<li>Player display names and optional notes within your leagues.</li>
|
||||||
|
<li>Session records: dates, labels, open/close timestamps, and session notes.</li>
|
||||||
|
<li>Ledger events: buy-ins, cash-outs, fronts, payouts, rollovers, debt repayments, and write-offs. All amounts are recorded in cents with a full append-only audit trail.</li>
|
||||||
|
<li>The account that created or voided each ledger event.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Membership data</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Your role within each league you belong to (owner, manager, or viewer).</li>
|
||||||
|
<li>Which account invited you and when.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Support and operational data</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Account status information such as whether an account is verified, disabled, or marked as a site administrator.</li>
|
||||||
|
<li>Aggregate operational metrics derived from existing records, such as user counts, active users, league counts, session counts, ledger event counts, voided event counts, and recorded ledger volume.</li>
|
||||||
|
<li>Support actions performed through internal tools, such as account email updates, password reset or temporary password emails, account disable/restore actions, league archive/restore actions, league deletion, and ownership transfers.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. What we do not collect</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Real names.</strong> Player display names are arbitrary labels chosen by league managers and are not required to correspond to real legal identities.</li>
|
||||||
|
<li><strong>Payment information.</strong> We do not process, hold, or transfer money of any kind.</li>
|
||||||
|
<li>Location or GPS data.</li>
|
||||||
|
<li>Browser fingerprints or device identifiers.</li>
|
||||||
|
<li>Advertising IDs or third-party tracking identifiers.</li>
|
||||||
|
<li>Third-party analytics data. There are no third-party analytics scripts (no Google Analytics, Mixpanel, or similar) on myboker.org.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Cookies and transient data</h2>
|
||||||
|
<p>We set <strong>one cookie</strong>: <code>myboker_org_session</code>. This is a functional authentication cookie that keeps you signed in. It contains only an encrypted reference to your session. We do not use advertising cookies, tracking cookies, or any third-party cookies.</p>
|
||||||
|
<p>Our rate-limiting system temporarily processes your IP address in memory to prevent abuse. IP addresses are not written to disk or retained once the request completes.</p>
|
||||||
|
<p>Password-reset and league-invitation links are cryptographically signed tokens that expire automatically (1 hour for resets, 7 days for invitations). They are stateless and not stored in our database.</p>
|
||||||
|
|
||||||
|
<h2>5. How we use your information</h2>
|
||||||
|
<ul>
|
||||||
|
<li>To authenticate you and maintain a secure session.</li>
|
||||||
|
<li>To store and display your league, session, and player data to you and the members of your leagues.</li>
|
||||||
|
<li>To send transactional emails: account verification, password reset, and league invitations.</li>
|
||||||
|
<li>To enforce rate limits and protect the service from abuse.</li>
|
||||||
|
<li>To provide account and league support, including correcting account emails, helping with password recovery, disabling abusive or compromised accounts, transferring league ownership, and handling deletion requests.</li>
|
||||||
|
<li>To understand basic service health and usage through first-party aggregate metrics calculated from records already stored by the Service.</li>
|
||||||
|
</ul>
|
||||||
|
<p>We do not sell, rent, share, or otherwise disclose your personal information to third parties for advertising or marketing purposes.</p>
|
||||||
|
|
||||||
|
<h2>6. Internal administration</h2>
|
||||||
|
<p>Authorized site administrators may use private internal tools to view account, league, session, membership, and ledger information when needed to operate the Service, respond to support requests, investigate abuse or security issues, correct account or league problems, and maintain service reliability.</p>
|
||||||
|
<p>Internal tools may display aggregate service metrics, including recorded ledger volume. Recorded ledger volume is a usage metric only. myboker.org does not process payments, hold money, transfer funds, or treat recorded ledger amounts as platform revenue.</p>
|
||||||
|
|
||||||
|
<h2>7. Third-party services</h2>
|
||||||
|
<p>We use a third-party SMTP provider to deliver transactional emails. This provider receives only the recipient email address and the message content required to deliver your email. No other personal data is shared with this provider.</p>
|
||||||
|
<p>We have no other third-party integrations, analytics platforms, or data processors.</p>
|
||||||
|
<p>Servers hosting myboker.org are located in <strong>North America</strong> (primarily Canada; potentially the United States). Data is not intentionally transferred to the European Union, though no geographic restriction is technically enforced at the network level.</p>
|
||||||
|
|
||||||
|
<h2>8. Player display names</h2>
|
||||||
|
<p>Player profiles in a league use display names set by the league manager. These names do not need to be real legal names; they are arbitrary labels for tracking purposes. All leagues are <strong>private by default</strong>, meaning player data is only visible to members of that specific league.</p>
|
||||||
|
<p>If you are a league manager adding players, you are responsible for ensuring that players whose results you track have agreed to participate in your league and have their results recorded.</p>
|
||||||
|
|
||||||
|
<h2>9. Data retention and deletion</h2>
|
||||||
|
<p>We retain your data for as long as your account is active. You have two options for removing your data:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account deletion:</strong> available in account settings. This is immediate and permanent. Your account, all leagues you own, and all associated session history are irreversibly removed.</li>
|
||||||
|
<li><strong>League deletion:</strong> league owners can permanently delete a league, which removes all its sessions, players, and ledger events. Archiving a league preserves data while hiding the league from active views.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Site administrators may disable accounts, restore accounts, archive leagues, restore leagues, transfer league ownership, or permanently delete accounts or leagues when needed for support, security, abuse prevention, or to complete a verified user request.</p>
|
||||||
|
<p>We do not have automated data expiry periods. Data remains until you delete it. As the user base grows, we may introduce formal retention policies and will update this policy accordingly.</p>
|
||||||
|
|
||||||
|
<h2>10. Your rights</h2>
|
||||||
|
<p>Under PIPEDA and BC PIPA, you have the right to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Know what personal information we hold about you.</li>
|
||||||
|
<li>Request corrections to inaccurate information.</li>
|
||||||
|
<li>Request deletion of your personal information (fulfilled via in-app hard delete or by emailing us).</li>
|
||||||
|
<li>Withdraw consent, subject to legal or contractual limitations.</li>
|
||||||
|
</ul>
|
||||||
|
<p>To exercise any of these rights, contact us at <a href="mailto:support@cedarline.digital">support@cedarline.digital</a>. We will respond within 30 days.</p>
|
||||||
|
<p>If you are a resident of the European Union, additional rights may apply under the GDPR. myboker.org is not specifically directed at EU residents, but we will honour GDPR deletion and access requests made in writing.</p>
|
||||||
|
|
||||||
|
<h2>11. Age requirement</h2>
|
||||||
|
<p>You must be <strong>at least 18 years old</strong> to create an account on myboker.org. By registering, you confirm you meet this requirement. If we become aware that an account was created by someone under 18, we will permanently delete that account.</p>
|
||||||
|
|
||||||
|
<h2>12. Security</h2>
|
||||||
|
<p>We apply standard security measures including encrypted connections (TLS in transit), bcrypt password hashing, CSRF protection, and rate limiting. No method of data transmission or storage is 100% secure. We cannot guarantee absolute security but take reasonable precautions to protect your information.</p>
|
||||||
|
|
||||||
|
<h2>13. Changes to this policy</h2>
|
||||||
|
<p>We may update this Privacy Policy from time to time. When we do, we will update the effective date at the top of this page. Continued use of myboker.org after changes are posted constitutes your acceptance of the revised policy.</p>
|
||||||
|
|
||||||
|
<h2>14. Contact</h2>
|
||||||
|
<p>Questions or requests regarding this policy:</p>
|
||||||
|
<p>
|
||||||
|
<strong>Cedarline Digital</strong><br>
|
||||||
|
British Columbia, Canada<br>
|
||||||
|
<a href="mailto:support@cedarline.digital">support@cedarline.digital</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Reset Password · myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="form-shell narrow">
|
||||||
|
<form class="panel form-card" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account recovery</p>
|
||||||
|
<h1 style="font-size:28px;margin-bottom:8px;">Reset password</h1>
|
||||||
|
<p class="muted-text" style="margin-bottom:8px;">Enter a new password for your account.</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>New password</span>
|
||||||
|
<input type="password" name="new_password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confirm new password</span>
|
||||||
|
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">Reset password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Help - myboker.org{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero" style="padding-bottom:12px;">
|
||||||
|
<p class="hero__kicker">Documentation</p>
|
||||||
|
<h1 class="hero__title">How myboker works</h1>
|
||||||
|
<p class="hero__sub">Everything you need to run a home poker league: sessions, ledgers, leaderboards, and settlement.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-grid">
|
||||||
|
|
||||||
|
<nav class="docs-nav">
|
||||||
|
<p class="kicker">Contents</p>
|
||||||
|
<a href="#leagues">Leagues</a>
|
||||||
|
<a href="#members">Members & roles</a>
|
||||||
|
<a href="#players">Players</a>
|
||||||
|
<a href="#seasons">Seasons</a>
|
||||||
|
<a href="#sessions">Sessions</a>
|
||||||
|
<a href="#events">Ledger events</a>
|
||||||
|
<a href="#settlement">Settlement</a>
|
||||||
|
<a href="#leaderboard">Leaderboard & stats</a>
|
||||||
|
<a href="#visibility">Public vs private</a>
|
||||||
|
<a href="#account">Account & login</a>
|
||||||
|
<a href="#privacy">Privacy & data</a>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="docs-body">
|
||||||
|
|
||||||
|
<section class="docs-section" id="leagues">
|
||||||
|
<h2 class="docs-h2">Leagues</h2>
|
||||||
|
<p>A <strong>league</strong> is the top-level container for your home game. It holds all your members, players, sessions, and ledger events in one place. One account can manage multiple leagues, which is useful if you run separate games (a Thursday game and a weekend game, for example).</p>
|
||||||
|
<p>When you create a league you give it a name and optional description. A unique public key is generated automatically so you can share a link if you make the league public. All leagues start as <strong>private</strong> by default.</p>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Tip:</strong> Leagues are long-lived. You don't need to create a new league every season. Use <em>sessions</em> for individual nights and <em>seasons</em> to group them into periods.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="members">
|
||||||
|
<h2 class="docs-h2">Members & roles</h2>
|
||||||
|
<p><strong>Members</strong> are myboker.org account holders who have been granted access to a league. They are distinct from players: a member manages or observes the league, while a player is just a tracked name in the ledger.</p>
|
||||||
|
<p>Invite members from league settings. They'll receive an email with a unique link that expires after 7 days. Each member is assigned one of three roles:</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Owner</strong>
|
||||||
|
<span>Full control. Can manage settings, invite and remove members, create and close sessions, add players, and permanently delete the league.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Manager</strong>
|
||||||
|
<span>Can run sessions, record ledger events, manage players, and view all data. Cannot change league settings or manage member roles.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Viewer</strong>
|
||||||
|
<span>Read-only. Can view sessions, the leaderboard, and player history. Cannot record events or change anything.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Removing a member:</strong> Disabling a member removes their access but preserves all their historical contributions. Past session data recorded by them remains intact.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="players">
|
||||||
|
<h2 class="docs-h2">Players</h2>
|
||||||
|
<p>Players are the people tracked in your league's ledger. They <strong>do not need a myboker.org account</strong>. A player is just a display name added by a manager or owner. Their results, stats, and history are tied to that name.</p>
|
||||||
|
<p>Display names don't have to be legal names. They can be nicknames or aliases. Player data is only visible to league members.</p>
|
||||||
|
<p>You can optionally <strong>link a player profile to a member account</strong>. This lets a real account "claim" their stats, but it is never required.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Active</strong>
|
||||||
|
<span>Shows on the roster and is available to record events against.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Archived</strong>
|
||||||
|
<span>Hidden from the active roster. History and stats are fully preserved. Can be reactivated at any time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="seasons">
|
||||||
|
<h2 class="docs-h2">Seasons</h2>
|
||||||
|
<p>Seasons let you group sessions into defined time periods, for example <em>Season 1</em> or <em>Spring 2026</em>. They are entirely optional. Sessions can exist without belonging to any season.</p>
|
||||||
|
<p>When viewing the leaderboard, you can filter by season to see rankings for just that period. Seasons have optional start and end dates for reference but these do not restrict which sessions can be assigned to them.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Active</strong>
|
||||||
|
<span>Visible in the league and available to assign sessions to.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Archived</strong>
|
||||||
|
<span>Hidden from the active list. Sessions assigned to it are preserved and remain filterable.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="sessions">
|
||||||
|
<h2 class="docs-h2">Sessions</h2>
|
||||||
|
<p>A <strong>session</strong> is a single night of poker. Sessions are date-stamped and sequenced automatically. If you play twice on the same day, they become S1 and S2 for that date.</p>
|
||||||
|
<p>Sessions have two states: <strong>open</strong> and <strong>closed</strong>. While open, you record buy-ins, cashouts, and all other ledger events. When everyone has settled, close the session. Closing doesn't delete anything and the session can always be reopened.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Date</strong>
|
||||||
|
<span>The calendar date the game was played. Used to order sessions and group same-day games.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Label</strong>
|
||||||
|
<span>Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Season</strong>
|
||||||
|
<span>Optional. Assigns the session to a season so it appears in that season's leaderboard filter.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Notes</strong>
|
||||||
|
<span>Optional freeform context (e.g. "Holiday game", "Marcus's place").</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Open</strong>
|
||||||
|
<span>Session is live. Events can be added and settlement is still in progress.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Closed</strong>
|
||||||
|
<span>Session is settled. Books are considered final. Can be reopened if needed.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="events">
|
||||||
|
<h2 class="docs-h2">Ledger events</h2>
|
||||||
|
<p>All activity in a session is recorded as <strong>ledger events</strong>. The ledger is <strong>append-only</strong>. Events are never deleted. This keeps the record auditable even when corrections are needed. To fix a mistake, void the incorrect event and add the correct one.</p>
|
||||||
|
<div class="docs-events">
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-buyin">buy-in</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player buys chips with cash.</strong>
|
||||||
|
<p>Cash comes into the pot. Records how much the player has invested. Record one every time someone puts money on the table.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-front">front</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house covers a player's buy-in.</strong>
|
||||||
|
<p>When a player doesn't have cash, the house can front them chips. This creates a debt. The player owes that amount back to the house. Track fronts carefully so you know who owes what at the end of the night.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-cashout">cashout</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player exchanges their chips for their cash value.</strong>
|
||||||
|
<p>Records the chip count a player walks away with. This is the "owed X" event. It doesn't mean cash has left the house yet. Use <em>paid out</em> to record the actual handover.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-paid_out">paid out</span>
|
||||||
|
<div>
|
||||||
|
<strong>Cash physically leaves the house to the player.</strong>
|
||||||
|
<p>Closes the loop on a cashout. Once paid out, the player's balance goes to $0. Record this separately from cashout because payments may happen at different times.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_in">rollover in</span>
|
||||||
|
<div>
|
||||||
|
<strong>Chips or a balance carried in from a previous session.</strong>
|
||||||
|
<p>Record a rollover in when a player carries an unpaid winning forward from a prior session. Always paired with a rollover out on the source session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-rollover_out">rollover out</span>
|
||||||
|
<div>
|
||||||
|
<strong>An unpaid winning deferred to a future session.</strong>
|
||||||
|
<p>Recorded when a player's payout isn't settled immediately and will be carried to the next game. Paired with a rollover in on the receiving session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-payout_carry_in">payout carry-in</span>
|
||||||
|
<div>
|
||||||
|
<strong>An outstanding winning from a prior session honoured here.</strong>
|
||||||
|
<p>Similar to rollover in but used when the balance isn't tied to a specific session's rollover out. Adds to the player's total payout due in this session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-debt_repayment">debt repayment</span>
|
||||||
|
<div>
|
||||||
|
<strong>A player repays an outstanding front.</strong>
|
||||||
|
<p>When a player who was fronted chips pays the house back, record a debt repayment. This reduces their outstanding balance with the house.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-event">
|
||||||
|
<span class="pill pill-writeoff">write-off</span>
|
||||||
|
<div>
|
||||||
|
<strong>The house forgives a debt.</strong>
|
||||||
|
<p>If a front won't be collected (player left, debt forgiven), write it off. The debt is cleared from the open balance without cash changing hands. Add a note explaining why.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="settlement">
|
||||||
|
<h2 class="docs-h2">Settlement</h2>
|
||||||
|
<p>The ledger tracks two types of open balance at all times:</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to players</strong>
|
||||||
|
<span>Cash the house owes to players who have cashed out but haven't been paid yet. Record a paid out event to clear each balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Due to house</strong>
|
||||||
|
<span>Cash players owe from unpaid fronts. Collect debt repayments or write off to clear the balance.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash in</strong>
|
||||||
|
<span>Total real cash that has entered the pot: buy-ins and repaid fronts only. Fronts themselves don't count because no cash entered.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Cash paid out</strong>
|
||||||
|
<span>Total real cash that has left the house to players. Should approach cash in over time once all balances are settled.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Fronts vs buy-ins:</strong> A front doesn't add to "cash in" because no cash actually entered the pot. The house covered it. Cash in only counts money physically on the table.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="leaderboard">
|
||||||
|
<h2 class="docs-h2">Leaderboard & stats</h2>
|
||||||
|
<p>The leaderboard ranks players by their all-time performance within a league. Stats are computed directly from ledger events and are never manually entered.</p>
|
||||||
|
<p>Players must meet the <strong>minimum sessions threshold</strong> set in league settings (default: 3) before appearing in the ranked leaderboard. Players below the threshold are shown in a provisional section.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Net</strong>
|
||||||
|
<span>Total cash out minus total invested across all sessions. Positive means up, negative means down.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>ROI</strong>
|
||||||
|
<span>Net divided by total invested, as a percentage. Compares performance independent of how much someone plays.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Win rate</strong>
|
||||||
|
<span>Percentage of sessions where the player finished with a positive net. Breaking exactly even counts as a loss.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Sessions played</strong>
|
||||||
|
<span>Number of sessions with at least one ledger event for this player.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest win</strong>
|
||||||
|
<span>The single session where the player netted the most profit.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Biggest loss</strong>
|
||||||
|
<span>The single session where the player lost the most money.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="docs-section" id="visibility">
|
||||||
|
<h2 class="docs-h2">Public vs private</h2>
|
||||||
|
<p>Each league has a <strong>visibility</strong> setting you can change in league settings at any time.</p>
|
||||||
|
<div class="docs-term-grid">
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Private</strong>
|
||||||
|
<span>Only invited members can see this league. It will not appear on the Explore page. This is the default for all new leagues.</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-term">
|
||||||
|
<strong>Public</strong>
|
||||||
|
<span>Anyone on the Explore page can view the leaderboard and session results. No account required to view. Members still need an account to manage it.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Note:</strong> Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or member email addresses.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Account & Login (FAQ dropdowns) ─────────────────────── -->
|
||||||
|
<section class="docs-section" id="account">
|
||||||
|
<h2 class="docs-h2">Account & login</h2>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">I didn't receive a verification email.</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>Check your spam or junk folder first. Verification emails are sent from <strong>noreply@myboker.org</strong>. If you still can't find it, sign in and use the <em>Resend verification email</em> option on your account page.</p>
|
||||||
|
<p>If you continue to have trouble, contact us at <a href="mailto:support@cedarline.digital">support@cedarline.digital</a>.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">How do I reset my password?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>On the sign-in page, click <strong>Forgot password?</strong> and enter your email address. You will receive a reset link that expires after <strong>1 hour</strong>. Follow the link and choose a new password.</p>
|
||||||
|
<p>If you don't receive the email, check your spam folder or contact support.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">How do I accept a league invitation?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>When a league owner invites you, you will receive an email with a unique invitation link. Clicking it will either sign you in directly (if you already have an account with that email) or guide you through creating an account first.</p>
|
||||||
|
<p>Invitation links expire after <strong>7 days</strong>. If yours has expired, ask the league owner to send a new one.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">How do I delete my account?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>Account deletion is available in your account settings. <strong>This is permanent and cannot be undone.</strong> Deleting your account removes your login credentials, all leagues you own and their complete session history, and your membership records in any leagues you belong to.</p>
|
||||||
|
<p>If you own leagues with other active members, consider notifying them before deleting your account as they will lose access to that league's data.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Privacy & Data (FAQ dropdowns) ──────────────────────── -->
|
||||||
|
<section class="docs-section" id="privacy">
|
||||||
|
<h2 class="docs-h2">Privacy & your data</h2>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">Who can see my league data?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>For private leagues (the default): only invited members with active access. No one outside your league can see sessions, players, or financials.</p>
|
||||||
|
<p>For public leagues: the leaderboard is visible to any signed-in user on the Explore page. Full session detail and financials are still restricted to invited members.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">Does myboker.org track me or use analytics?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>There are no third-party analytics scripts on myboker.org. We set one functional session cookie to keep you signed in. We may calculate first-party aggregate service metrics from existing account, league, session, and ledger records to understand service health and usage. We do not sell or share your data for advertising. See the full <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> for details.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary class="faq-item__q">How do I request a copy of my data or have it deleted?</summary>
|
||||||
|
<div class="faq-item__a">
|
||||||
|
<p>You can permanently delete your account and all associated data directly from account settings at any time. To request an export or to have data deleted on your behalf, email <a href="mailto:support@cedarline.digital">support@cedarline.digital</a> from the address associated with your account. We respond within 30 days.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Contact ──────────────────────────────────────────────── -->
|
||||||
|
<section class="docs-section" id="contact">
|
||||||
|
<h2 class="docs-h2">Contact</h2>
|
||||||
|
<p>myboker.org is owned and operated by <strong>Cedarline Digital</strong>.</p>
|
||||||
|
<p>For support, data requests, or anything else: <a href="mailto:support@cedarline.digital">support@cedarline.digital</a>. Please allow a few days for a response.</p>
|
||||||
|
<div class="docs-callout">
|
||||||
|
<strong>Legal:</strong> See the <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> and <a href="{{ url_for('public.terms') }}">Terms of Service</a> for details on how your data is handled and the rules governing use of the service.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Terms of Service - myboker.org{% endblock %}
|
||||||
|
{% block page_class %}page--public{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="legal-doc">
|
||||||
|
<header class="legal-doc__header">
|
||||||
|
<p class="lp-kicker">Legal</p>
|
||||||
|
<h1 class="legal-doc__title">Terms of Service</h1>
|
||||||
|
<p class="legal-doc__meta">Effective June 28, 2026. Owned and operated by <strong>Cedarline Digital</strong>.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legal-doc__body">
|
||||||
|
|
||||||
|
<h2>1. Acceptance of terms</h2>
|
||||||
|
<p>By accessing or using myboker.org ("the Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, do not use the Service.</p>
|
||||||
|
|
||||||
|
<h2>2. Who operates this Service</h2>
|
||||||
|
<p>myboker.org is owned and operated by <strong>Cedarline Digital</strong> ("we," "us," "our"). The Service is a personal side project offered free of charge.</p>
|
||||||
|
|
||||||
|
<h2>3. Age requirement</h2>
|
||||||
|
<p>You must be <strong>at least 18 years old</strong> to use myboker.org. By creating an account, you confirm you are 18 or older and legally permitted to engage in or track recreational poker play in your jurisdiction. If we learn that an account was created by someone under 18, we reserve the right to permanently delete that account without notice.</p>
|
||||||
|
|
||||||
|
<h2>4. What myboker.org is and is not</h2>
|
||||||
|
<p>myboker.org is a <strong>statistical record-keeping tool</strong> for private home poker leagues. It allows league organizers to log buy-ins, cash-outs, and settlements for informational and tracking purposes.</p>
|
||||||
|
<p><strong>We are not:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>An online casino or gambling platform</li>
|
||||||
|
<li>A payment processor. We do not hold, transfer, or facilitate the movement of money</li>
|
||||||
|
<li>A financial institution or licensed financial services provider</li>
|
||||||
|
<li>A legal or financial advisor</li>
|
||||||
|
</ul>
|
||||||
|
<p>All real-money transactions occur directly between players, entirely outside of this platform. We bear no responsibility for any debts, payments, disputes, or financial losses that arise between players in your league.</p>
|
||||||
|
|
||||||
|
<h2>5. Your account</h2>
|
||||||
|
<p>You are responsible for:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Keeping your login credentials secure and confidential</li>
|
||||||
|
<li>All activity that occurs under your account</li>
|
||||||
|
<li>Providing a valid, working email address</li>
|
||||||
|
<li>Ensuring your use of the Service complies with these Terms and all applicable laws</li>
|
||||||
|
</ul>
|
||||||
|
<p>You may not create multiple accounts to circumvent a suspension, share your account credentials with others, or use the Service for automated data scraping.</p>
|
||||||
|
|
||||||
|
<h2>6. League owners and data responsibility</h2>
|
||||||
|
<p>If you create a league, you are the league owner and are solely responsible for:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Ensuring that players whose data you enter are participants in your poker league and have agreed to have their results tracked</li>
|
||||||
|
<li>The accuracy of the data you enter into the Service</li>
|
||||||
|
<li>Managing member roles and access appropriately</li>
|
||||||
|
<li>Keeping your league set to <strong>Private</strong> (the default) unless you intend for it to be publicly discoverable</li>
|
||||||
|
</ul>
|
||||||
|
<p>Player display names are arbitrary labels and are not required to be real legal names. However, you must not enter data in a way intended to harm, harass, defame, or identify individuals who have not consented to being tracked.</p>
|
||||||
|
|
||||||
|
<h2>7. Acceptable use</h2>
|
||||||
|
<p>You agree not to use myboker.org to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Violate any applicable local, provincial, federal, or international law</li>
|
||||||
|
<li>Attempt to gain unauthorized access to another user's account or league data</li>
|
||||||
|
<li>Interfere with, disrupt, or degrade the performance of the Service or its infrastructure</li>
|
||||||
|
<li>Reverse engineer, decompile, or attempt to extract proprietary source code from the Service</li>
|
||||||
|
<li>Track individuals without their knowledge or consent</li>
|
||||||
|
<li>Upload or transmit malicious code, viruses, or other harmful content</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>8. Financial data disclaimer</h2>
|
||||||
|
<p>Amounts recorded in myboker.org are for <strong>reference and tracking purposes only</strong>. We make no representations about the accuracy, completeness, or legal weight of any financial data entered by users.</p>
|
||||||
|
<p>We may use aggregate recorded ledger totals internally to understand service usage. These totals are not platform revenue, payment volume, money handled by us, or proof that any real-world payment occurred.</p>
|
||||||
|
<p>We are not responsible for any disputes, non-payment, or financial losses arising from information tracked in the Service. Home poker games may be subject to local laws. It is your responsibility to ensure your activities comply with the laws of your jurisdiction.</p>
|
||||||
|
|
||||||
|
<h2>9. Internal administration and support</h2>
|
||||||
|
<p>Authorized site administrators may use private internal tools to operate and support the Service. These tools may allow administrators to search for users or leagues, view account and league details, update account emails, send password reset or temporary password emails, disable or restore accounts, archive or restore leagues, delete accounts or leagues, and transfer league ownership.</p>
|
||||||
|
<p>We will use these tools only for service operation, support requests, security, abuse prevention, troubleshooting, legal compliance, or other legitimate administrative purposes. You may contact <a href="mailto:support@cedarline.digital">support@cedarline.digital</a> if you believe an administrative action was incorrect.</p>
|
||||||
|
|
||||||
|
<h2>10. Account and data deletion</h2>
|
||||||
|
<p>You may permanently delete your account at any time through account settings. <strong>Deletion is immediate and irreversible.</strong> All personal data associated with your account, all leagues you own, and all associated session and player history will be removed. Members of leagues you own will permanently lose access to that league's data.</p>
|
||||||
|
<p>League owners may also permanently delete individual leagues without deleting their account. This removes all sessions, players, and ledger events for that league. We strongly recommend reviewing your data before deletion, as this action cannot be undone.</p>
|
||||||
|
<p>Site administrators may also disable, restore, or delete accounts and leagues when needed for support, security, abuse prevention, or to complete a verified user request. In some cases, we may require league ownership to be transferred or league data to be deleted before an account can be permanently removed.</p>
|
||||||
|
|
||||||
|
<h2>11. Service availability</h2>
|
||||||
|
<p>myboker.org is provided <strong>as-is</strong> and <strong>free of charge</strong>. We do not guarantee continuous availability, uptime, error-free operation, or data preservation. We may perform maintenance, push updates, or experience outages at any time. We will make reasonable efforts to notify users of planned downtime when possible.</p>
|
||||||
|
|
||||||
|
<h2>12. Intellectual property</h2>
|
||||||
|
<p>The myboker.org software, design, and branding are owned by Cedarline Digital. You retain ownership of all data you enter into the Service (league names, player names, session data). You grant us a limited licence to store and display that data for the purpose of providing the Service to you.</p>
|
||||||
|
|
||||||
|
<h2>13. Limitation of liability</h2>
|
||||||
|
<p>To the fullest extent permitted by applicable law, Cedarline Digital shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising out of or related to your use of, or inability to use, the Service, including but not limited to loss of data, revenue, or goodwill.</p>
|
||||||
|
<p>Our total aggregate liability to you for any claim arising from these Terms shall not exceed the total amount you have paid us in the twelve months preceding the claim. As the Service is currently free of charge, this limit is <strong>$0 CAD</strong>.</p>
|
||||||
|
|
||||||
|
<h2>14. Suspension and termination</h2>
|
||||||
|
<p>We reserve the right to suspend or permanently terminate any account, at our discretion, for violations of these Terms, abusive behaviour, or any activity that we reasonably believe poses a risk to the Service or other users. We will make reasonable efforts to notify affected users unless doing so would compromise security or is prohibited by law.</p>
|
||||||
|
|
||||||
|
<h2>15. Changes to these Terms</h2>
|
||||||
|
<p>We may update these Terms at any time. When we do, we will update the effective date at the top of this page. Continued use of myboker.org after changes are posted constitutes your acceptance of the revised Terms. If changes are material, we will make reasonable efforts to inform users.</p>
|
||||||
|
|
||||||
|
<h2>16. Governing law</h2>
|
||||||
|
<p>These Terms are governed by and construed in accordance with the laws of the Province of British Columbia and the federal laws of Canada applicable therein, without regard to conflict of law principles. You agree that any dispute arising under these Terms shall be subject to the exclusive jurisdiction of the courts of British Columbia.</p>
|
||||||
|
|
||||||
|
<h2>17. Contact</h2>
|
||||||
|
<p>Questions about these Terms:</p>
|
||||||
|
<p>
|
||||||
|
<strong>Cedarline Digital</strong><br>
|
||||||
|
British Columbia, Canada<br>
|
||||||
|
<a href="mailto:support@cedarline.digital">support@cedarline.digital</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
+19
-50
@@ -1,16 +1,13 @@
|
|||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import app as app_module
|
from boker.charts import player_session_series
|
||||||
from charts import player_session_series
|
from boker.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 boker.utils import session_sort_key
|
||||||
from utils import session_sort_key
|
|
||||||
|
|
||||||
|
|
||||||
def cents(amount: float) -> int:
|
def cents(amount: float) -> int:
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfigTests(unittest.TestCase):
|
||||||
|
def test_production_refuses_default_sqlite_database(self):
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"APP_ENV": "production"},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "DATABASE_URL"):
|
||||||
|
create_app({"SECRET_KEY": "test-production-secret"})
|
||||||
|
|
||||||
|
def test_production_accepts_postgresql_database_url(self):
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"APP_ENV": "production"},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"SECRET_KEY": "test-production-secret",
|
||||||
|
"SQLALCHEMY_DATABASE_URI": "postgresql+psycopg://user:password@example.com/dbname",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(app.config["SESSION_COOKIE_SECURE"])
|
||||||
|
self.assertEqual(app.config["SESSION_COOKIE_SAMESITE"], "Strict")
|
||||||
|
self.assertTrue(app.config["SQLALCHEMY_DATABASE_URI"].startswith("postgresql+psycopg://"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from boker.db import database_extensions_available
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
database_extensions_available(),
|
||||||
|
"Database dependencies are not installed.",
|
||||||
|
)
|
||||||
|
class DatabaseFoundationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from app import create_app
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
self.db = db
|
||||||
|
self.app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.ctx = self.app.app_context()
|
||||||
|
self.ctx.push()
|
||||||
|
self.db.create_all()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.session.remove()
|
||||||
|
self.db.drop_all()
|
||||||
|
self.db.engine.dispose()
|
||||||
|
self.ctx.pop()
|
||||||
|
|
||||||
|
def test_owner_league_players_sessions_season_and_events(self):
|
||||||
|
from boker.db_models import LedgerEvent, LeagueMembership, PokerSession
|
||||||
|
from boker.ledger_repositories import append_ledger_event
|
||||||
|
from boker.league_repositories import (
|
||||||
|
create_league,
|
||||||
|
create_player,
|
||||||
|
create_poker_session,
|
||||||
|
create_season,
|
||||||
|
create_user,
|
||||||
|
user_has_league_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = create_user("Owner@Example.com", "correct horse battery staple")
|
||||||
|
self.db.session.flush()
|
||||||
|
league = create_league(owner, "Friday Poker", "friday-poker")
|
||||||
|
player = create_player(league.id, "Alex", "alex")
|
||||||
|
season = create_season(
|
||||||
|
league.id,
|
||||||
|
"Fall 2026",
|
||||||
|
start_date=date(2026, 9, 1),
|
||||||
|
end_date=date(2026, 12, 31),
|
||||||
|
)
|
||||||
|
self.db.session.flush()
|
||||||
|
|
||||||
|
first_session = create_poker_session(
|
||||||
|
league.id,
|
||||||
|
date(2026, 10, 2),
|
||||||
|
season_id=season.id,
|
||||||
|
status="open",
|
||||||
|
)
|
||||||
|
second_session = create_poker_session(
|
||||||
|
league.id,
|
||||||
|
date(2026, 10, 2),
|
||||||
|
season_id=season.id,
|
||||||
|
)
|
||||||
|
self.db.session.flush()
|
||||||
|
|
||||||
|
append_ledger_event(
|
||||||
|
league.id,
|
||||||
|
first_session.id,
|
||||||
|
"buyin",
|
||||||
|
2500,
|
||||||
|
owner.id,
|
||||||
|
player_id=player.id,
|
||||||
|
)
|
||||||
|
append_ledger_event(
|
||||||
|
league.id,
|
||||||
|
first_session.id,
|
||||||
|
"paid",
|
||||||
|
1000,
|
||||||
|
owner.id,
|
||||||
|
player_id=player.id,
|
||||||
|
)
|
||||||
|
append_ledger_event(
|
||||||
|
league.id,
|
||||||
|
first_session.id,
|
||||||
|
"session_close",
|
||||||
|
9999,
|
||||||
|
owner.id,
|
||||||
|
note="Closed after payout review.",
|
||||||
|
)
|
||||||
|
self.db.session.commit()
|
||||||
|
|
||||||
|
membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=owner.id,
|
||||||
|
).one()
|
||||||
|
sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all()
|
||||||
|
events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all()
|
||||||
|
|
||||||
|
self.assertEqual(owner.email, "owner@example.com")
|
||||||
|
self.assertEqual(membership.role, "owner")
|
||||||
|
self.assertTrue(user_has_league_role(owner.id, league.id, {"owner"}))
|
||||||
|
self.assertEqual([s.sequence_on_date for s in sessions], [1, 2])
|
||||||
|
self.assertEqual(sessions[0].season_id, season.id)
|
||||||
|
self.assertEqual(events[1].event_type, "paid_out")
|
||||||
|
self.assertEqual(events[2].amount_cents, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorPageTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
db_path = Path(self.tmpdir.name) / "test.sqlite3"
|
||||||
|
self.app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"PROPAGATE_EXCEPTIONS": False,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.app.get("/explode")
|
||||||
|
def explode():
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
db.engine.dispose()
|
||||||
|
self.tmpdir.cleanup()
|
||||||
|
|
||||||
|
def test_unexpected_errors_render_crash_page_with_incident_id(self):
|
||||||
|
response = self.client.get("/explode")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.assertIn(b"Something went wrong.", response.data)
|
||||||
|
self.assertIn(b"Incident", response.data)
|
||||||
|
self.assertNotIn(b"RuntimeError", response.data)
|
||||||
|
self.assertRegex(response.get_data(as_text=True), re.compile(r"Incident [a-f0-9]{12}"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from boker.auth import hash_password
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
|
||||||
|
class InternalAdminAccessTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
db_path = Path(self.tmpdir.name) / "test.sqlite3"
|
||||||
|
self.app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League, LeagueMembership, User
|
||||||
|
|
||||||
|
db.create_all()
|
||||||
|
self.user = User(
|
||||||
|
email="viewer@example.com",
|
||||||
|
password_hash=hash_password("password123"),
|
||||||
|
)
|
||||||
|
self.manager = User(
|
||||||
|
email="manager@example.com",
|
||||||
|
password_hash=hash_password("password123"),
|
||||||
|
)
|
||||||
|
self.admin = User(
|
||||||
|
email="admin@example.com",
|
||||||
|
password_hash=hash_password("password123"),
|
||||||
|
is_site_admin=True,
|
||||||
|
)
|
||||||
|
db.session.add_all([self.user, self.manager, self.admin])
|
||||||
|
db.session.flush()
|
||||||
|
self.league = League(
|
||||||
|
name="Friday Poker",
|
||||||
|
slug="friday-poker",
|
||||||
|
public_key="abc123",
|
||||||
|
created_by_user_id=self.admin.id,
|
||||||
|
visibility="public",
|
||||||
|
)
|
||||||
|
db.session.add(self.league)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add_all(
|
||||||
|
[
|
||||||
|
LeagueMembership(league_id=self.league.id, user_id=self.admin.id, role="owner"),
|
||||||
|
LeagueMembership(league_id=self.league.id, user_id=self.manager.id, role="manager"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
self.user_id = self.user.id
|
||||||
|
self.manager_id = self.manager.id
|
||||||
|
self.admin_id = self.admin.id
|
||||||
|
self.league_id = self.league.id
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
db.engine.dispose()
|
||||||
|
self.tmpdir.cleanup()
|
||||||
|
|
||||||
|
def login_as(self, user_id: str, **extra_session):
|
||||||
|
with self.client.session_transaction() as session:
|
||||||
|
session["user_id"] = user_id
|
||||||
|
for key, value in extra_session.items():
|
||||||
|
session[key] = value
|
||||||
|
|
||||||
|
def test_internal_dashboard_requires_login(self):
|
||||||
|
response = self.client.get("/internal/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/account/login", response.headers["Location"])
|
||||||
|
|
||||||
|
def test_internal_dashboard_rejects_regular_users(self):
|
||||||
|
self.login_as(self.user_id)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_internal_dashboard_ignores_client_side_admin_spoof(self):
|
||||||
|
self.login_as(self.user_id, is_site_admin=True)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_internal_dashboard_allows_site_admins(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Overview", response.data)
|
||||||
|
self.assertIn(b"admin@example.com", response.data)
|
||||||
|
self.assertIn(b"Active users", response.data)
|
||||||
|
self.assertIn(b"Activity breakdown", response.data)
|
||||||
|
self.assertIn(b"Ledger events", response.data)
|
||||||
|
self.assertIn(b"Totals at a glance", response.data)
|
||||||
|
|
||||||
|
def test_admin_can_search_users(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/users?q=viewer")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"viewer@example.com", response.data)
|
||||||
|
self.assertNotIn(b"manager@example.com", response.data)
|
||||||
|
|
||||||
|
def test_admin_users_page_is_paginated(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
users = [
|
||||||
|
User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123"))
|
||||||
|
for i in range(55)
|
||||||
|
]
|
||||||
|
db.session.add_all(users)
|
||||||
|
db.session.commit()
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/users?page=2")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Page 2 of 2", response.data)
|
||||||
|
self.assertIn(b"Previous", response.data)
|
||||||
|
|
||||||
|
def test_admin_leagues_page_is_paginated(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
leagues = [
|
||||||
|
League(
|
||||||
|
name=f"Bulk League {i:02d}",
|
||||||
|
slug=f"bulk-league-{i:02d}",
|
||||||
|
public_key=f"bk{i:010d}"[:12],
|
||||||
|
created_by_user_id=self.admin_id,
|
||||||
|
visibility="private",
|
||||||
|
)
|
||||||
|
for i in range(55)
|
||||||
|
]
|
||||||
|
db.session.add_all(leagues)
|
||||||
|
db.session.commit()
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.get("/internal/leagues?page=2")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Page 2 of 2", response.data)
|
||||||
|
self.assertIn(b"Previous", response.data)
|
||||||
|
|
||||||
|
def test_session_growth_uses_session_date_not_created_at(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import PokerSession
|
||||||
|
from boker.routes.internal import _growth_data
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
old_session = PokerSession(
|
||||||
|
league_id=self.league_id,
|
||||||
|
session_date=(now - timedelta(weeks=5)).date(),
|
||||||
|
sequence_on_date=1,
|
||||||
|
status="closed",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
current_session = PokerSession(
|
||||||
|
league_id=self.league_id,
|
||||||
|
session_date=(now - timedelta(days=2)).date(),
|
||||||
|
sequence_on_date=1,
|
||||||
|
status="closed",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.session.add_all([old_session, current_session])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
growth = _growth_data()
|
||||||
|
|
||||||
|
self.assertEqual(sum(growth["session_counts"]), 2)
|
||||||
|
self.assertLess(growth["session_counts"][-1], 2)
|
||||||
|
|
||||||
|
def test_regular_user_cannot_update_user_email(self):
|
||||||
|
self.login_as(self.user_id)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/users/{self.user_id}/email",
|
||||||
|
data={"email": "changed@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, self.user_id)
|
||||||
|
self.assertEqual(user.email, "viewer@example.com")
|
||||||
|
|
||||||
|
def test_admin_can_update_user_email(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/users/{self.user_id}/email",
|
||||||
|
data={"email": "changed@example.com"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, self.user_id)
|
||||||
|
self.assertEqual(user.email, "changed@example.com")
|
||||||
|
|
||||||
|
def test_admin_delete_user_blocks_owned_leagues(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League, LeagueMembership
|
||||||
|
|
||||||
|
owned_league = League(
|
||||||
|
name="Manager Owned Poker",
|
||||||
|
slug="manager-owned-poker",
|
||||||
|
public_key="mgr123",
|
||||||
|
created_by_user_id=self.manager_id,
|
||||||
|
visibility="private",
|
||||||
|
)
|
||||||
|
db.session.add(owned_league)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(LeagueMembership(league_id=owned_league.id, user_id=self.manager_id, role="owner"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/users/{self.manager_id}/delete",
|
||||||
|
data={"confirm": "DELETE"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
self.assertIsNotNone(db.session.get(User, self.manager_id))
|
||||||
|
|
||||||
|
def test_admin_can_archive_and_restore_league(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
archive_response = self.client.post(f"/internal/leagues/{self.league_id}/archive")
|
||||||
|
self.assertEqual(archive_response.status_code, 302)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
league = db.session.get(League, self.league_id)
|
||||||
|
self.assertIsNotNone(league.archived_at)
|
||||||
|
|
||||||
|
restore_response = self.client.post(f"/internal/leagues/{self.league_id}/restore")
|
||||||
|
self.assertEqual(restore_response.status_code, 302)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
league = db.session.get(League, self.league_id)
|
||||||
|
self.assertIsNone(league.archived_at)
|
||||||
|
|
||||||
|
def test_admin_can_update_league_settings(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/leagues/{self.league_id}/settings",
|
||||||
|
data={
|
||||||
|
"name": "Saturday Poker",
|
||||||
|
"description": "Updated by support",
|
||||||
|
"visibility": "private",
|
||||||
|
"eligible_min_sessions": "5",
|
||||||
|
"break_even_dollars": "2.50",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
league = db.session.get(League, self.league_id)
|
||||||
|
self.assertEqual(league.name, "Saturday Poker")
|
||||||
|
self.assertEqual(league.slug, "saturday-poker")
|
||||||
|
self.assertEqual(league.description, "Updated by support")
|
||||||
|
self.assertEqual(league.visibility, "private")
|
||||||
|
self.assertEqual(league.eligible_min_sessions, 5)
|
||||||
|
self.assertEqual(league.break_even_cents, 250)
|
||||||
|
|
||||||
|
def test_admin_can_transfer_league_ownership(self):
|
||||||
|
self.login_as(self.admin_id)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/internal/leagues/{self.league_id}/transfer",
|
||||||
|
data={"new_owner_email": "manager@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
with self.app.app_context():
|
||||||
|
from boker.db_models import League, LeagueMembership
|
||||||
|
|
||||||
|
league = db.session.get(League, self.league_id)
|
||||||
|
self.assertEqual(league.created_by_user_id, self.manager_id)
|
||||||
|
old_owner_membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=self.league_id,
|
||||||
|
user_id=self.admin_id,
|
||||||
|
).one()
|
||||||
|
new_owner_membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=self.league_id,
|
||||||
|
user_id=self.manager_id,
|
||||||
|
).one()
|
||||||
|
self.assertEqual(old_owner_membership.role, "manager")
|
||||||
|
self.assertEqual(new_owner_membership.role, "owner")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from boker.db import database_extensions_available
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
database_extensions_available(),
|
||||||
|
"Database dependencies are not installed.",
|
||||||
|
)
|
||||||
|
class Phase2AuthLeagueRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from app import create_app
|
||||||
|
from boker.db import db
|
||||||
|
|
||||||
|
self.db = db
|
||||||
|
self.app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test-secret",
|
||||||
|
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.ctx = self.app.app_context()
|
||||||
|
self.ctx.push()
|
||||||
|
self.db.create_all()
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.session.remove()
|
||||||
|
self.db.drop_all()
|
||||||
|
self.db.engine.dispose()
|
||||||
|
self.ctx.pop()
|
||||||
|
|
||||||
|
def league_path(self, league=None) -> str:
|
||||||
|
if league is None:
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
league = League.query.one()
|
||||||
|
return f"/l/{league.url_ref}"
|
||||||
|
|
||||||
|
def create_verified_user(self, email="owner@example.com"):
|
||||||
|
from boker.db_models import utc_now
|
||||||
|
from boker.league_repositories import create_user
|
||||||
|
|
||||||
|
user = create_user(email, "password123")
|
||||||
|
self.db.session.flush()
|
||||||
|
user.email_verified_at = utc_now()
|
||||||
|
self.db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def login_as(self, user):
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = user.id
|
||||||
|
|
||||||
|
def create_logged_in_league(self, email="owner@example.com", name="Friday Poker"):
|
||||||
|
from boker.league_repositories import create_league
|
||||||
|
|
||||||
|
owner = self.create_verified_user(email)
|
||||||
|
league = create_league(owner, name, name.lower().replace(" ", "-"))
|
||||||
|
self.db.session.commit()
|
||||||
|
self.login_as(owner)
|
||||||
|
return owner, league, self.league_path(league)
|
||||||
|
|
||||||
|
def test_registration_starts_email_verification_before_login(self):
|
||||||
|
from boker.db_models import User
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/account/register",
|
||||||
|
data={
|
||||||
|
"email": "Owner@Example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"confirm_password": "password123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], "/account/verify-email")
|
||||||
|
|
||||||
|
user = User.query.one()
|
||||||
|
self.assertEqual(user.email, "owner@example.com")
|
||||||
|
self.assertIsNone(user.email_verified_at)
|
||||||
|
self.assertIsNotNone(user.email_verification_code_hash)
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
self.assertNotIn("user_id", flask_session)
|
||||||
|
self.assertEqual(flask_session["pending_verification_user_id"], user.id)
|
||||||
|
|
||||||
|
def test_verified_user_can_create_league_and_owner_membership(self):
|
||||||
|
from boker.db_models import League, LeagueMembership, User
|
||||||
|
|
||||||
|
user = self.create_verified_user("Owner@Example.com")
|
||||||
|
self.login_as(user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/leagues/new",
|
||||||
|
data={
|
||||||
|
"name": "Friday Poker",
|
||||||
|
"slug": "",
|
||||||
|
"description": "Private home league",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
self.assertEqual(User.query.count(), 1)
|
||||||
|
league = League.query.one()
|
||||||
|
self.assertEqual(response.headers["Location"], self.league_path(league))
|
||||||
|
membership = LeagueMembership.query.one()
|
||||||
|
self.assertEqual(league.slug, "friday-poker")
|
||||||
|
self.assertEqual(membership.user_id, user.id)
|
||||||
|
self.assertEqual(membership.role, "owner")
|
||||||
|
|
||||||
|
dashboard = self.client.get(self.league_path(league))
|
||||||
|
self.assertEqual(dashboard.status_code, 200)
|
||||||
|
self.assertIn(b"Friday Poker", dashboard.data)
|
||||||
|
|
||||||
|
def test_legacy_admin_routes_are_not_registered_by_default(self):
|
||||||
|
response = self.client.get("/admin/login")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_duplicate_league_slug_is_allowed_because_route_uses_public_key(self):
|
||||||
|
owner = self.create_verified_user()
|
||||||
|
self.login_as(owner)
|
||||||
|
self.client.post("/leagues/new", data={"name": "Friday Poker"})
|
||||||
|
self.client.post("/leagues/new", data={"name": "Friday Poker"})
|
||||||
|
|
||||||
|
from boker.db_models import League
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[league.slug for league in League.query.order_by(League.slug.asc()).all()],
|
||||||
|
["friday-poker", "friday-poker"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_uuid_league_route_redirects_to_public_key_route(self):
|
||||||
|
from boker.db import db
|
||||||
|
from boker.league_repositories import create_league, create_user
|
||||||
|
|
||||||
|
owner = create_user("owner@example.com", "password123")
|
||||||
|
db.session.flush()
|
||||||
|
league = create_league(owner, "Private League", "private-league")
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = owner.id
|
||||||
|
|
||||||
|
response = self.client.get(f"/l/{league.id}/{league.slug}")
|
||||||
|
self.assertEqual(response.status_code, 301)
|
||||||
|
self.assertEqual(response.headers["Location"], self.league_path(league))
|
||||||
|
|
||||||
|
def test_league_dashboard_requires_membership(self):
|
||||||
|
from boker.db import db
|
||||||
|
from boker.league_repositories import create_league, create_user
|
||||||
|
|
||||||
|
owner = create_user("owner@example.com", "password123")
|
||||||
|
other = create_user("other@example.com", "password123")
|
||||||
|
db.session.flush()
|
||||||
|
league = create_league(owner, "Private League", "private-league")
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = other.id
|
||||||
|
|
||||||
|
response = self.client.get(self.league_path(league))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_owner_can_add_archive_and_reactivate_player(self):
|
||||||
|
from boker.db_models import Player
|
||||||
|
|
||||||
|
_, _, league_base = self.create_logged_in_league()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{league_base}/players",
|
||||||
|
data={
|
||||||
|
"display_name": "Alex",
|
||||||
|
"slug": "",
|
||||||
|
"notes": "Usually hosts.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
player = Player.query.one()
|
||||||
|
self.assertEqual(player.display_name, "Alex")
|
||||||
|
self.assertEqual(player.slug, "alex")
|
||||||
|
self.assertEqual(player.status, "active")
|
||||||
|
self.assertEqual(player.notes, "Usually hosts.")
|
||||||
|
|
||||||
|
duplicate = self.client.post(
|
||||||
|
f"{league_base}/players",
|
||||||
|
data={"display_name": " alex "},
|
||||||
|
)
|
||||||
|
self.assertEqual(duplicate.status_code, 200)
|
||||||
|
self.assertIn(b"already exists", duplicate.data)
|
||||||
|
self.assertEqual(Player.query.count(), 1)
|
||||||
|
|
||||||
|
archive = self.client.post(f"{league_base}/players/{player.id}/archive")
|
||||||
|
self.assertEqual(archive.status_code, 302)
|
||||||
|
self.db.session.refresh(player)
|
||||||
|
self.assertEqual(player.status, "archived")
|
||||||
|
|
||||||
|
reactivate = self.client.post(f"{league_base}/players/{player.id}/reactivate")
|
||||||
|
self.assertEqual(reactivate.status_code, 302)
|
||||||
|
self.db.session.refresh(player)
|
||||||
|
self.assertEqual(player.status, "active")
|
||||||
|
|
||||||
|
def test_viewer_can_view_players_but_cannot_add_or_archive(self):
|
||||||
|
from boker.db import db
|
||||||
|
from boker.db_models import LeagueMembership, Player
|
||||||
|
from boker.league_repositories import create_league, create_player, create_user
|
||||||
|
|
||||||
|
owner = create_user("owner@example.com", "password123")
|
||||||
|
viewer = create_user("viewer@example.com", "password123")
|
||||||
|
db.session.flush()
|
||||||
|
league = create_league(owner, "Private League", "private-league")
|
||||||
|
player = create_player(league.id, "Alex", "alex")
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
LeagueMembership(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=viewer.id,
|
||||||
|
role="viewer",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = viewer.id
|
||||||
|
|
||||||
|
league_base = self.league_path(league)
|
||||||
|
response = self.client.get(f"{league_base}/players")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Alex", response.data)
|
||||||
|
self.assertNotIn(b"Add player", response.data)
|
||||||
|
|
||||||
|
add = self.client.post(
|
||||||
|
f"{league_base}/players",
|
||||||
|
data={"display_name": "Blair"},
|
||||||
|
)
|
||||||
|
self.assertEqual(add.status_code, 403)
|
||||||
|
self.assertEqual(Player.query.count(), 1)
|
||||||
|
|
||||||
|
archive = self.client.post(f"{league_base}/players/{player.id}/archive")
|
||||||
|
self.assertEqual(archive.status_code, 403)
|
||||||
|
self.db.session.refresh(player)
|
||||||
|
self.assertEqual(player.status, "active")
|
||||||
|
|
||||||
|
def test_owner_can_create_same_day_sessions_and_close_them(self):
|
||||||
|
from boker.db_models import LedgerEvent, PokerSession
|
||||||
|
|
||||||
|
_, _, league_base = self.create_logged_in_league()
|
||||||
|
|
||||||
|
first = self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={
|
||||||
|
"session_date": "2026-10-02",
|
||||||
|
"label": "",
|
||||||
|
"status": "open",
|
||||||
|
"notes": "Main game",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
second = self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={
|
||||||
|
"session_date": "2026-10-02",
|
||||||
|
"label": "Late table",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first.status_code, 302)
|
||||||
|
self.assertEqual(second.status_code, 302)
|
||||||
|
|
||||||
|
sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all()
|
||||||
|
self.assertEqual([session.sequence_on_date for session in sessions], [1, 2])
|
||||||
|
self.assertEqual(sessions[0].status, "open")
|
||||||
|
self.assertEqual(sessions[0].notes, "Main game")
|
||||||
|
self.assertEqual(sessions[1].display_label, "Late table")
|
||||||
|
|
||||||
|
events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all()
|
||||||
|
self.assertEqual([event.event_type for event in events], ["session_open", "session_open"])
|
||||||
|
|
||||||
|
close = self.client.post(f"{league_base}/sessions/{sessions[0].id}/close")
|
||||||
|
self.assertEqual(close.status_code, 302)
|
||||||
|
self.db.session.refresh(sessions[0])
|
||||||
|
self.assertEqual(sessions[0].status, "closed")
|
||||||
|
self.assertIsNotNone(sessions[0].closed_at)
|
||||||
|
self.assertEqual(LedgerEvent.query.count(), 3)
|
||||||
|
self.assertEqual(
|
||||||
|
LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type,
|
||||||
|
"session_close",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_closed_session_creation_does_not_append_marker_event_until_opened(self):
|
||||||
|
from boker.db_models import LedgerEvent, PokerSession
|
||||||
|
|
||||||
|
_, _, league_base = self.create_logged_in_league()
|
||||||
|
self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={"session_date": "2026-10-02", "status": "closed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
session = PokerSession.query.one()
|
||||||
|
self.assertEqual(session.status, "closed")
|
||||||
|
self.assertEqual(LedgerEvent.query.count(), 0)
|
||||||
|
|
||||||
|
open_response = self.client.post(f"{league_base}/sessions/{session.id}/open")
|
||||||
|
self.assertEqual(open_response.status_code, 302)
|
||||||
|
self.db.session.refresh(session)
|
||||||
|
self.assertEqual(session.status, "open")
|
||||||
|
self.assertIsNotNone(session.opened_at)
|
||||||
|
self.assertEqual(LedgerEvent.query.one().event_type, "session_open")
|
||||||
|
|
||||||
|
def test_viewer_can_view_sessions_but_cannot_create_or_close(self):
|
||||||
|
from boker.db import db
|
||||||
|
from boker.db_models import LeagueMembership, PokerSession
|
||||||
|
from boker.league_repositories import create_league, create_poker_session, create_user
|
||||||
|
|
||||||
|
owner = create_user("owner@example.com", "password123")
|
||||||
|
viewer = create_user("viewer@example.com", "password123")
|
||||||
|
db.session.flush()
|
||||||
|
league = create_league(owner, "Private League", "private-league")
|
||||||
|
session = create_poker_session(league.id, date(2026, 10, 2), status="open")
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
LeagueMembership(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=viewer.id,
|
||||||
|
role="viewer",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = viewer.id
|
||||||
|
|
||||||
|
league_base = self.league_path(league)
|
||||||
|
response = self.client.get(f"{league_base}/sessions")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"2026-10-02", response.data)
|
||||||
|
self.assertNotIn(b"Create session", response.data)
|
||||||
|
|
||||||
|
create = self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={"session_date": "2026-10-03"},
|
||||||
|
)
|
||||||
|
self.assertEqual(create.status_code, 403)
|
||||||
|
self.assertEqual(PokerSession.query.count(), 1)
|
||||||
|
|
||||||
|
close = self.client.post(f"{league_base}/sessions/{session.id}/close")
|
||||||
|
self.assertEqual(close.status_code, 403)
|
||||||
|
self.db.session.refresh(session)
|
||||||
|
self.assertEqual(session.status, "open")
|
||||||
|
|
||||||
|
def test_owner_can_append_events_and_view_db_backed_stats(self):
|
||||||
|
from boker.db_models import LedgerEvent, Player, PokerSession
|
||||||
|
|
||||||
|
_, _, league_base = self.create_logged_in_league()
|
||||||
|
self.client.post(f"{league_base}/players", data={"display_name": "Alex"})
|
||||||
|
self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={"session_date": "2026-10-02", "status": "open"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player.query.one()
|
||||||
|
session = PokerSession.query.one()
|
||||||
|
|
||||||
|
buyin = self.client.post(
|
||||||
|
f"{league_base}/sessions/{session.id}",
|
||||||
|
data={
|
||||||
|
"player_id": player.id,
|
||||||
|
"event_type": "buyin",
|
||||||
|
"amount": "20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cashout = self.client.post(
|
||||||
|
f"{league_base}/sessions/{session.id}",
|
||||||
|
data={
|
||||||
|
"player_id": player.id,
|
||||||
|
"event_type": "cashout",
|
||||||
|
"amount": "35",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
paid = self.client.post(
|
||||||
|
f"{league_base}/sessions/{session.id}",
|
||||||
|
data={
|
||||||
|
"player_id": player.id,
|
||||||
|
"event_type": "paid_out",
|
||||||
|
"amount": "15",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(buyin.status_code, 302)
|
||||||
|
self.assertEqual(cashout.status_code, 302)
|
||||||
|
self.assertEqual(paid.status_code, 302)
|
||||||
|
self.assertEqual(LedgerEvent.query.filter_by(player_id=player.id).count(), 3)
|
||||||
|
|
||||||
|
leaderboard = self.client.get(f"{league_base}/leaderboard")
|
||||||
|
self.assertEqual(leaderboard.status_code, 200)
|
||||||
|
self.assertIn(b"Alex", leaderboard.data)
|
||||||
|
self.assertIn(b"15.00", leaderboard.data)
|
||||||
|
|
||||||
|
detail = self.client.get(f"{league_base}/sessions/{session.id}")
|
||||||
|
self.assertEqual(detail.status_code, 200)
|
||||||
|
self.assertIn(b"cashout", detail.data)
|
||||||
|
self.assertIn(b"paid out", detail.data)
|
||||||
|
|
||||||
|
player_detail = self.client.get(f"{league_base}/players/{player.id}")
|
||||||
|
self.assertEqual(player_detail.status_code, 200)
|
||||||
|
self.assertIn(b"Total net", player_detail.data)
|
||||||
|
self.assertIn(b"20.00", player_detail.data)
|
||||||
|
|
||||||
|
def test_league_ledger_shows_and_resolves_player_debts(self):
|
||||||
|
from boker.db_models import LedgerEvent, Player, PokerSession
|
||||||
|
|
||||||
|
_, _, league_base = self.create_logged_in_league()
|
||||||
|
self.client.post(f"{league_base}/players", data={"display_name": "Alex"})
|
||||||
|
self.client.post(
|
||||||
|
f"{league_base}/sessions",
|
||||||
|
data={"session_date": "2026-10-02", "status": "open"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player.query.one()
|
||||||
|
session = PokerSession.query.one()
|
||||||
|
self.client.post(
|
||||||
|
f"{league_base}/sessions/{session.id}",
|
||||||
|
data={"player_id": player.id, "event_type": "front", "amount": "50"},
|
||||||
|
)
|
||||||
|
self.client.post(
|
||||||
|
f"{league_base}/sessions/{session.id}",
|
||||||
|
data={"player_id": player.id, "event_type": "cashout", "amount": "20"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger = self.client.get(f"{league_base}/ledger")
|
||||||
|
self.assertEqual(ledger.status_code, 200)
|
||||||
|
self.assertIn(b"Debt resolution", ledger.data)
|
||||||
|
self.assertIn(b"Front receivables", ledger.data)
|
||||||
|
self.assertIn(b"30.00", ledger.data)
|
||||||
|
|
||||||
|
repayment = self.client.post(
|
||||||
|
f"{league_base}/ledger",
|
||||||
|
data={
|
||||||
|
"session_id": session.id,
|
||||||
|
"player_id": player.id,
|
||||||
|
"event_type": "debt_repayment",
|
||||||
|
"amount": "10",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(repayment.status_code, 302)
|
||||||
|
self.assertEqual(
|
||||||
|
LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type,
|
||||||
|
"debt_repayment",
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger = self.client.get(f"{league_base}/ledger")
|
||||||
|
self.assertEqual(ledger.status_code, 200)
|
||||||
|
self.assertIn(b"20.00", ledger.data)
|
||||||
|
|
||||||
|
def test_viewer_cannot_append_ledger_events(self):
|
||||||
|
from boker.db import db
|
||||||
|
from boker.db_models import LeagueMembership, LedgerEvent
|
||||||
|
from boker.league_repositories import (
|
||||||
|
create_league,
|
||||||
|
create_player,
|
||||||
|
create_poker_session,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = create_user("owner@example.com", "password123")
|
||||||
|
viewer = create_user("viewer@example.com", "password123")
|
||||||
|
db.session.flush()
|
||||||
|
league = create_league(owner, "Private League", "private-league")
|
||||||
|
player = create_player(league.id, "Alex", "alex")
|
||||||
|
session = create_poker_session(league.id, date(2026, 10, 2), status="open")
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(
|
||||||
|
LeagueMembership(
|
||||||
|
league_id=league.id,
|
||||||
|
user_id=viewer.id,
|
||||||
|
role="viewer",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with self.client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = viewer.id
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.league_path(league)}/sessions/{session.id}",
|
||||||
|
data={
|
||||||
|
"player_id": player.id,
|
||||||
|
"event_type": "buyin",
|
||||||
|
"amount": "20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(LedgerEvent.query.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+11
-9
@@ -1,10 +1,11 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from stats import SessionEntry, SessionSummary, build_leaderboard
|
from boker.models import SessionEntry, SessionSummary
|
||||||
|
from boker.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