From ac278065d2f0d7d79a9eac7662854d6fcd57e030 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:08:33 -0700 Subject: [PATCH 1/5] security --- .env.example | 1 + app.py | 9 ++++++--- routes/account.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index b73c996..5fd77fe 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +FLASK_ENV=production SECRET_KEY=replace-this-with-a-long-random-string DATABASE_URL=postgresql+psycopg://user:password@host/dbname APP_BASE_URL=https://myboker.org diff --git a/app.py b/app.py index b0164e7..c2c707b 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 from __future__ import annotations +import os + import click from flask import Flask from auth import current_user_id, is_logged_in -from config import Config +from config import Config, ProductionConfig from db import database_extensions_available, db, init_database from extensions import csrf, limiter, mail from routes.account import account_bp @@ -17,7 +19,8 @@ from utils import cents_to_dollars, safe_date_label def create_app(config_overrides: dict | None = None) -> Flask: app = Flask(__name__) - app.config.from_object(Config) + cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config + app.config.from_object(cfg) if config_overrides: app.config.update(config_overrides) @@ -60,4 +63,4 @@ def create_app(config_overrides: dict | None = None) -> Flask: app = create_app() if __name__ == "__main__": - app.run(debug=True) + app.run(debug=os.getenv("FLASK_DEBUG", "0") == "1") diff --git a/routes/account.py b/routes/account.py index 1bd77c0..ba0f5ec 100644 --- a/routes/account.py +++ b/routes/account.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 from __future__ import annotations +from urllib.parse import urlparse + from flask import Blueprint, flash, redirect, render_template, request, url_for from auth import ( @@ -97,7 +99,9 @@ def login(): flash("That account is disabled.", "error") else: log_user_in(user.id) - next_url = request.args.get("next") or url_for("leagues.index") + raw_next = request.args.get("next", "") + parsed = urlparse(raw_next) + next_url = raw_next if (raw_next and not parsed.netloc and not parsed.scheme) else url_for("leagues.index") flash("Logged in.", "success") return redirect(next_url) From 6da4c3e37972ded76e3c50b07e07172164f4ebac Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:53:19 -0700 Subject: [PATCH 2/5] major file restrucutre --- app.py | 62 +--------- boker/__init__.py | 60 +++++++++ auth.py => boker/auth.py | 0 charts.py => boker/charts.py | 4 +- config.py => boker/config.py | 4 +- db.py => boker/db.py | 2 +- db_models.py => boker/db_models.py | 2 +- emails.py => boker/emails.py | 2 +- extensions.py => boker/extensions.py | 0 models.py => boker/models.py | 0 {routes => boker/repositories}/__init__.py | 0 .../repositories/leagues.py | 12 +- .../repositories/ledger.py | 6 +- boker/routes/__init__.py | 1 + {routes => boker/routes}/account.py | 31 ++--- {routes => boker/routes}/admin.py | 8 +- {routes => boker/routes}/leagues.py | 117 +++++++++--------- {routes => boker/routes}/public.py | 16 +-- services.py => boker/services.py | 6 +- {static => boker/static}/css/style.css | 0 {static => boker/static}/css/tokens.css | 0 {static => boker/static}/favicon.svg | 0 storage.py => boker/storage.py | 0 .../templates}/_league_subnav.html | 0 .../templates}/account_login.html | 0 .../templates}/account_register.html | 0 .../templates}/account_settings.html | 0 .../templates}/admin_dashboard.html | 0 .../templates}/admin_login.html | 0 {templates => boker/templates}/base.html | 0 {templates => boker/templates}/docs.html | 0 {templates => boker/templates}/explore.html | 0 .../templates}/forgot_password.html | 0 {templates => boker/templates}/landing.html | 0 .../templates}/leaderboard.html | 0 .../templates}/league_dashboard.html | 0 .../templates}/league_leaderboard.html | 0 .../templates}/league_ledger.html | 0 .../templates}/league_new.html | 0 .../templates}/league_player_detail.html | 0 .../templates}/league_players.html | 0 .../templates}/league_session_detail.html | 0 .../templates}/league_session_view.html | 0 .../templates}/league_sessions.html | 0 .../templates}/league_settings.html | 0 .../templates}/leagues_index.html | 0 .../templates}/player_detail.html | 0 .../templates}/reset_password.html | 0 .../templates}/session_detail.html | 0 {templates => boker/templates}/sessions.html | 0 utils.py => boker/utils.py | 2 +- tests/test_accounting.py | 28 +++-- tests/test_stats.py | 5 +- 53 files changed, 188 insertions(+), 180 deletions(-) create mode 100644 boker/__init__.py rename auth.py => boker/auth.py (100%) rename charts.py => boker/charts.py (97%) rename config.py => boker/config.py (96%) rename db.py => boker/db.py (90%) rename db_models.py => boker/db_models.py (99%) rename emails.py => boker/emails.py (97%) rename extensions.py => boker/extensions.py (100%) rename models.py => boker/models.py (100%) rename {routes => boker/repositories}/__init__.py (100%) rename league_repositories.py => boker/repositories/leagues.py (97%) rename ledger_repositories.py => boker/repositories/ledger.py (98%) create mode 100644 boker/routes/__init__.py rename {routes => boker/routes}/account.py (93%) rename {routes => boker/routes}/admin.py (99%) rename {routes => boker/routes}/leagues.py (92%) rename {routes => boker/routes}/public.py (93%) rename services.py => boker/services.py (98%) rename {static => boker/static}/css/style.css (100%) rename {static => boker/static}/css/tokens.css (100%) rename {static => boker/static}/favicon.svg (100%) rename storage.py => boker/storage.py (100%) rename {templates => boker/templates}/_league_subnav.html (100%) rename {templates => boker/templates}/account_login.html (100%) rename {templates => boker/templates}/account_register.html (100%) rename {templates => boker/templates}/account_settings.html (100%) rename {templates => boker/templates}/admin_dashboard.html (100%) rename {templates => boker/templates}/admin_login.html (100%) rename {templates => boker/templates}/base.html (100%) rename {templates => boker/templates}/docs.html (100%) rename {templates => boker/templates}/explore.html (100%) rename {templates => boker/templates}/forgot_password.html (100%) rename {templates => boker/templates}/landing.html (100%) rename {templates => boker/templates}/leaderboard.html (100%) rename {templates => boker/templates}/league_dashboard.html (100%) rename {templates => boker/templates}/league_leaderboard.html (100%) rename {templates => boker/templates}/league_ledger.html (100%) rename {templates => boker/templates}/league_new.html (100%) rename {templates => boker/templates}/league_player_detail.html (100%) rename {templates => boker/templates}/league_players.html (100%) rename {templates => boker/templates}/league_session_detail.html (100%) rename {templates => boker/templates}/league_session_view.html (100%) rename {templates => boker/templates}/league_sessions.html (100%) rename {templates => boker/templates}/league_settings.html (100%) rename {templates => boker/templates}/leagues_index.html (100%) rename {templates => boker/templates}/player_detail.html (100%) rename {templates => boker/templates}/reset_password.html (100%) rename {templates => boker/templates}/session_detail.html (100%) rename {templates => boker/templates}/sessions.html (100%) rename utils.py => boker/utils.py (98%) diff --git a/app.py b/app.py index c2c707b..d71a532 100644 --- a/app.py +++ b/app.py @@ -1,66 +1,8 @@ #!/usr/bin/env python3 -from __future__ import annotations - -import os - -import click -from flask import Flask - -from auth import current_user_id, is_logged_in -from config import Config, ProductionConfig -from db import database_extensions_available, db, init_database -from extensions import csrf, limiter, mail -from routes.account import account_bp -from routes.leagues import leagues_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(config_overrides: dict | None = None) -> Flask: - app = Flask(__name__) - cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config - app.config.from_object(cfg) - if config_overrides: - app.config.update(config_overrides) - - 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(), - } - - app.register_blueprint(public_bp) - app.register_blueprint(account_bp) - app.register_blueprint(leagues_bp) - - @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.") - - return app - +from boker import create_app app = create_app() if __name__ == "__main__": + import os app.run(debug=os.getenv("FLASK_DEBUG", "0") == "1") diff --git a/boker/__init__.py b/boker/__init__.py new file mode 100644 index 0000000..a1cc031 --- /dev/null +++ b/boker/__init__.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os + +import click +from flask import Flask + +from .auth import current_user_id, is_logged_in +from .config import Config, ProductionConfig +from .db import database_extensions_available, db, init_database +from .extensions import csrf, limiter, mail +from .routes.account import account_bp +from .routes.leagues import leagues_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(config_overrides: dict | None = None) -> Flask: + app = Flask(__name__) + cfg = ProductionConfig if os.getenv("FLASK_ENV") == "production" else Config + app.config.from_object(cfg) + if config_overrides: + app.config.update(config_overrides) + + 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(), + } + + app.register_blueprint(public_bp) + app.register_blueprint(account_bp) + app.register_blueprint(leagues_bp) + + @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.") + + return app diff --git a/auth.py b/boker/auth.py similarity index 100% rename from auth.py rename to boker/auth.py diff --git a/charts.py b/boker/charts.py similarity index 97% rename from charts.py rename to boker/charts.py index 0a8d074..de70f3b 100644 --- a/charts.py +++ b/boker/charts.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any -from models import SessionEntry, SessionSummary -from utils import net_result_bucket, session_chart_label, session_sort_key +from .models import SessionEntry, SessionSummary +from .utils import net_result_bucket, session_chart_label, session_sort_key PLAYER_PALETTE = [ "#9b8cf0", # --line-1 violet diff --git a/config.py b/boker/config.py similarity index 96% rename from config.py rename to boker/config.py index c5a83af..0730954 100644 --- a/config.py +++ b/boker/config.py @@ -4,12 +4,12 @@ from __future__ import annotations import os from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +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'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.3.0" +APP_VERSION = "2.3.1" def load_local_env(env_path: Path) -> None: diff --git a/db.py b/boker/db.py similarity index 90% rename from db.py rename to boker/db.py index 050416c..430a768 100644 --- a/db.py +++ b/boker/db.py @@ -24,7 +24,7 @@ def init_database(app: Any) -> bool: app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False return False - import db_models # noqa: F401 - registers SQLAlchemy models with metadata. + from . import db_models # noqa: F401 - registers SQLAlchemy models with metadata. db.init_app(app) migrate.init_app(app, db) diff --git a/db_models.py b/boker/db_models.py similarity index 99% rename from db_models.py rename to boker/db_models.py index 23b2915..229ebe9 100644 --- a/db_models.py +++ b/boker/db_models.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import date, datetime, timezone -from db import db +from .db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") diff --git a/emails.py b/boker/emails.py similarity index 97% rename from emails.py rename to boker/emails.py index 9f15ce6..7b02ddf 100644 --- a/emails.py +++ b/boker/emails.py @@ -4,7 +4,7 @@ from __future__ import annotations from flask import current_app from flask_mail import Message -from extensions import mail +from .extensions import mail def send_password_reset(to_email: str, reset_url: str) -> None: diff --git a/extensions.py b/boker/extensions.py similarity index 100% rename from extensions.py rename to boker/extensions.py diff --git a/models.py b/boker/models.py similarity index 100% rename from models.py rename to boker/models.py diff --git a/routes/__init__.py b/boker/repositories/__init__.py similarity index 100% rename from routes/__init__.py rename to boker/repositories/__init__.py diff --git a/league_repositories.py b/boker/repositories/leagues.py similarity index 97% rename from league_repositories.py rename to boker/repositories/leagues.py index 3d4417a..b23dbc3 100644 --- a/league_repositories.py +++ b/boker/repositories/leagues.py @@ -5,13 +5,13 @@ import secrets import string from datetime import date -from auth import hash_password, normalize_email -from db import db +from ..auth import hash_password, normalize_email +from ..db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from ..db_models import ( # noqa: E402 LedgerEvent, League, LeagueMembership, @@ -24,7 +24,7 @@ from db_models import ( # noqa: E402 make_user, normalize_lookup, ) -from utils import slugify +from ..utils import slugify PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits @@ -223,7 +223,7 @@ def set_session_status(session: PokerSession, status: str) -> PokerSession: if status not in {"open", "closed"}: raise ValueError(f"Unsupported session status: {status}") - from db_models import utc_now + from ..db_models import utc_now session.status = status if status == "open": @@ -313,7 +313,7 @@ def add_league_member( def remove_league_member(league_id: str, user_id: str) -> None: - from db_models import utc_now + from ..db_models import utc_now membership = LeagueMembership.query.filter_by( league_id=league_id, diff --git a/ledger_repositories.py b/boker/repositories/ledger.py similarity index 98% rename from ledger_repositories.py rename to boker/repositories/ledger.py index 93cb1e2..748caaa 100644 --- a/ledger_repositories.py +++ b/boker/repositories/ledger.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 from __future__ import annotations -from db import db +from ..db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from ..db_models import ( # noqa: E402 CANONICAL_EVENT_TYPES, LedgerEvent, Player, @@ -14,7 +14,7 @@ from db_models import ( # noqa: E402 User, canonical_event_type, ) -from storage import EventRow # noqa: E402 +from ..storage import EventRow # noqa: E402 def session_event_ref(session: PokerSession) -> str: diff --git a/boker/routes/__init__.py b/boker/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/boker/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/routes/account.py b/boker/routes/account.py similarity index 93% rename from routes/account.py rename to boker/routes/account.py index ba0f5ec..4eee3b3 100644 --- a/routes/account.py +++ b/boker/routes/account.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from flask import Blueprint, flash, redirect, render_template, request, url_for -from auth import ( +from ..auth import ( current_user_id, generate_invite_token, generate_reset_token, @@ -18,8 +18,8 @@ from auth import ( verify_password, verify_reset_token, ) -from db import database_extensions_available, db -from extensions import limiter +from ..db import database_extensions_available, db +from ..extensions import limiter account_bp = Blueprint("account", __name__, url_prefix="/account") @@ -49,7 +49,7 @@ def register(): } if request.method == "POST": - from league_repositories import create_user, find_user_by_email + from ..repositories.leagues import create_user, find_user_by_email email = normalize_email(form["email"]) password = request.form.get("password", "") @@ -88,7 +88,7 @@ def login(): } if request.method == "POST": - from league_repositories import find_user_by_email + from ..repositories.leagues import find_user_by_email user = find_user_by_email(form["email"]) password = request.form.get("password", "") @@ -122,7 +122,7 @@ def settings(): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -139,7 +139,7 @@ def update_email(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -154,7 +154,7 @@ def update_email(): elif not verify_password(user.password_hash, current_password): flash("Current password is incorrect.", "error") else: - from league_repositories import find_user_by_email + from ..repositories.leagues import find_user_by_email existing = find_user_by_email(new_email) if existing and existing.id != user.id: @@ -174,7 +174,7 @@ def update_password(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from ..db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -206,7 +206,7 @@ def delete_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, User, utc_now + from ..db_models import League, User, utc_now user = db.session.get(User, current_user_id()) if user is None: @@ -246,9 +246,10 @@ def forgot_password(): flash("Account database is not available.", "error") return redirect(url_for("account.forgot_password")) - from emails import send_password_reset from flask import current_app - from league_repositories import find_user_by_email + + from ..emails import send_password_reset + from ..repositories.leagues import find_user_by_email email = normalize_email(request.form.get("email", "")) user = find_user_by_email(email) @@ -282,7 +283,7 @@ def reset_password(token): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from ..db_models import User user = db.session.get(User, user_id) if user is None or user.disabled_at is not None: @@ -320,8 +321,8 @@ def accept_invite(token): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User - from league_repositories import add_league_member, find_league_by_id, find_membership + from ..db_models import User + from ..repositories.leagues import add_league_member, find_league_by_id, find_membership user = db.session.get(User, current_user_id()) if user is None or user.email != data.get("email"): diff --git a/routes/admin.py b/boker/routes/admin.py similarity index 99% rename from routes/admin.py rename to boker/routes/admin.py index f959d89..625e236 100644 --- a/routes/admin.py +++ b/boker/routes/admin.py @@ -16,16 +16,16 @@ from flask import ( url_for, ) -from auth import is_admin -from services import ( +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 +from ..storage import CSV_HEADERS, append_event, load_events, write_events +from ..utils import cents_to_dollars, session_label admin_bp = Blueprint("admin", __name__) diff --git a/routes/leagues.py b/boker/routes/leagues.py similarity index 92% rename from routes/leagues.py rename to boker/routes/leagues.py index 44bcb13..4d46c83 100644 --- a/routes/leagues.py +++ b/boker/routes/leagues.py @@ -9,12 +9,12 @@ from datetime import datetime, timezone from flask import Blueprint, abort, flash, make_response, redirect, render_template, request, url_for -from auth import current_user_id, login_required, normalize_email -from charts import cumulative_profit_series, player_session_series -from db import database_extensions_available, db -from models import SessionSummary -from services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events -from utils import cents_to_dollars, session_label, session_sort_key +from ..auth import current_user_id, login_required, normalize_email +from ..charts import cumulative_profit_series, player_session_series +from ..db import database_extensions_available, db +from ..models import SessionSummary +from ..services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events +from ..utils import cents_to_dollars, session_label, session_sort_key leagues_bp = Blueprint("leagues", __name__) @@ -48,7 +48,7 @@ def split_league_ref(league_ref: str) -> tuple[str, str]: def require_league(league_ref: str, allowed_roles: set[str]): - from league_repositories import find_league_by_public_key, user_has_league_role + from ..repositories.leagues import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -64,7 +64,7 @@ def require_league(league_ref: str, allowed_roles: set[str]): def get_league_with_visibility_gate(league_ref: str): """Load league; if private, enforce login + membership. Returns (league, None) or (None, redirect).""" - from league_repositories import find_league_by_public_key, user_has_league_role + from ..repositories.leagues import find_league_by_public_key, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -99,8 +99,8 @@ def empty_session_summary(session) -> SessionSummary: def session_ref_map(league_id: str) -> dict[str, str]: - from ledger_repositories import session_event_ref - from league_repositories import list_sessions_for_league + from ..repositories.ledger import session_event_ref + from ..repositories.leagues import list_sessions_for_league return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} @@ -112,8 +112,8 @@ def index(): flash("League database is not available.", "error") return render_template("leagues_index.html", leagues=[]) - from league_repositories import list_leagues_for_user - from ledger_repositories import list_event_rows_for_league + from ..repositories.leagues import list_leagues_for_user + from ..repositories.ledger import list_event_rows_for_league memberships = list_leagues_for_user(current_user_id() or "") league_summaries = [] @@ -190,8 +190,8 @@ def new(): } if request.method == "POST": - from db_models import User - from league_repositories import create_league, unique_league_slug + from ..db_models import User + from ..repositories.leagues import create_league, unique_league_slug owner = db.session.get(User, current_user_id()) if owner is None: @@ -221,7 +221,7 @@ def dashboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import league_counts, user_has_league_role + from ..repositories.leagues import league_counts, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -242,7 +242,7 @@ def legacy_dashboard_redirect(league_id: str, league_slug: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_league_by_id, user_has_league_role + from ..repositories.leagues import find_league_by_id, user_has_league_role league = find_league_by_id(league_id) if league is None: @@ -259,9 +259,9 @@ def leaderboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from config import ELIGIBLE_MIN_SESSIONS - from ledger_repositories import list_event_rows_for_league - from league_repositories import list_players_for_league, user_has_league_role + from ..config import ELIGIBLE_MIN_SESSIONS + from ..repositories.ledger import list_event_rows_for_league + from ..repositories.leagues import list_players_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -366,12 +366,12 @@ def ledger(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import ( + from ..repositories.ledger import ( append_ledger_event, list_all_event_rows_for_league, list_event_rows_for_league, ) - from league_repositories import ( + from ..repositories.leagues import ( find_player_for_league, find_session_for_league, list_players_for_league, @@ -506,7 +506,7 @@ def void_event(league_ref: str, event_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import void_ledger_event + from ..repositories.ledger import void_ledger_event league = require_league(league_ref, {"owner"}) reason = request.form.get("void_reason", "").strip() @@ -529,8 +529,8 @@ def player_detail(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import find_player_for_league, user_has_league_role + from ..repositories.ledger import list_event_rows_for_league + from ..repositories.leagues import find_player_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -588,7 +588,7 @@ def players(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from ..repositories.leagues import ( create_player, list_players_for_league, player_name_exists, @@ -627,7 +627,7 @@ def players(league_ref: str): flash("Player added.", "success") return redirect(url_for("leagues.players", **league_url_values(league))) - from ledger_repositories import list_event_rows_for_league + from ..repositories.ledger import list_event_rows_for_league all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) board = build_leaderboard(all_sessions) @@ -661,7 +661,7 @@ def update_player_status(league_ref: str, player_id: str, status: str, message: flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, set_player_status + from ..repositories.leagues import find_player_for_league, set_player_status league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -681,7 +681,7 @@ def edit_player(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, player_name_taken, update_player + from ..repositories.leagues import find_player_for_league, player_name_taken, update_player league = require_league(league_ref, {"owner", "manager"}) player = find_player_for_league(league.id, player_id) @@ -709,8 +709,8 @@ def sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import ( + from ..repositories.ledger import append_ledger_event + from ..repositories.leagues import ( create_poker_session, list_sessions_for_league, user_has_league_role, @@ -747,7 +747,7 @@ def sessions(league_ref: str): session.label = form["label"] or None session.notes = form["notes"] or None if status == "open": - from league_repositories import set_session_status + from ..repositories.leagues import set_session_status set_session_status(session, "open") @@ -765,8 +765,8 @@ def sessions(league_ref: str): flash(f"Created {session.display_label}.", "success") return redirect(url_for("leagues.sessions", **league_url_values(league))) - from db_models import LedgerEvent - from ledger_repositories import list_event_rows_for_league + from ..db_models import LedgerEvent + from ..repositories.ledger import list_event_rows_for_league all_sessions = list_sessions_for_league(league.id) summaries = build_session_summaries(list_event_rows_for_league(league.id)) @@ -803,8 +803,8 @@ def prune_empty_sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import LedgerEvent, PokerSession - from league_repositories import list_sessions_for_league + from ..db_models import LedgerEvent, PokerSession + from ..repositories.leagues import list_sessions_for_league league = require_league(league_ref, {"owner", "manager"}) @@ -839,7 +839,7 @@ def delete_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from ..repositories.leagues import find_session_for_league league = require_league(league_ref, {"owner"}) session = find_session_for_league(league.id, session_id) @@ -859,7 +859,7 @@ def edit_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from ..repositories.leagues import find_session_for_league league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -877,7 +877,7 @@ def edit_session(league_ref: str, session_id: str): return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) if new_date != session.session_date: - from db_models import PokerSession as _PS + from ..db_models import PokerSession as _PS max_seq = db.session.query(db.func.max(_PS.sequence_on_date)).filter( _PS.league_id == league.id, _PS.session_date == new_date, @@ -900,8 +900,8 @@ def session_detail(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session - from league_repositories import ( + from ..repositories.ledger import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session + from ..repositories.leagues import ( find_session_for_league, list_players_for_league, user_has_league_role, @@ -989,9 +989,9 @@ def session_public_view(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from charts import session_breakdown_series - from ledger_repositories import list_event_rows_for_league, list_event_rows_for_session - from league_repositories import find_league_by_public_key, find_session_for_league, list_sessions_for_league, user_has_league_role + from ..charts import session_breakdown_series + from ..repositories.ledger import list_event_rows_for_league, list_event_rows_for_session + from ..repositories.leagues import find_league_by_public_key, find_session_for_league, list_sessions_for_league, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -1062,8 +1062,8 @@ def update_session_status(league_ref: str, session_id: str, status: str, message flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import find_session_for_league, set_session_status + from ..repositories.ledger import append_ledger_event + from ..repositories.leagues import find_session_for_league, set_session_status league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -1114,7 +1114,7 @@ def league_settings(league_ref: str): elif form["visibility"] not in ("private", "public"): flash("Invalid visibility value.", "error") else: - from utils import slugify + from ..utils import slugify league.name = form["name"] league.slug = slugify(form["name"]) @@ -1124,7 +1124,7 @@ def league_settings(league_ref: str): flash("League settings saved.", "success") return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) - from league_repositories import list_members_for_league + from ..repositories.leagues import list_members_for_league members = list_members_for_league(league.id) return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) @@ -1137,10 +1137,11 @@ def invite_member(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from auth import generate_invite_token - from emails import send_league_invite from flask import current_app - from league_repositories import find_membership, find_user_by_email + + from ..auth import generate_invite_token + from ..emails import send_league_invite + from ..repositories.leagues import find_membership, find_user_by_email league = require_league(league_ref, {"owner"}) email = normalize_email(request.form.get("email", "")) @@ -1181,7 +1182,7 @@ def remove_member(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import remove_league_member + from ..repositories.leagues import remove_league_member league = require_league(league_ref, {"owner"}) @@ -1202,7 +1203,7 @@ def archive_league(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import utc_now + from ..db_models import utc_now league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1224,8 +1225,8 @@ def export_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from storage import CSV_HEADERS + from ..repositories.ledger import list_event_rows_for_league + from ..storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager", "viewer"}) rows = list_event_rows_for_league(league.id) @@ -1250,14 +1251,14 @@ def import_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import CANONICAL_EVENT_TYPES, canonical_event_type - from ledger_repositories import ( + from ..db_models import CANONICAL_EVENT_TYPES, canonical_event_type + from ..repositories.ledger import ( append_ledger_event, list_ledger_events_for_league, session_event_ref, ) - from league_repositories import list_players_for_league, list_sessions_for_league - from storage import CSV_HEADERS + from ..repositories.leagues import list_players_for_league, list_sessions_for_league + from ..storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) diff --git a/routes/public.py b/boker/routes/public.py similarity index 93% rename from routes/public.py rename to boker/routes/public.py index c2371be..f88828f 100644 --- a/routes/public.py +++ b/boker/routes/public.py @@ -4,16 +4,16 @@ from __future__ import annotations from flask import Blueprint, flash, redirect, render_template, request, url_for from flask import current_app -from charts import cumulative_profit_series, player_session_series, session_breakdown_series -from config import ELIGIBLE_MIN_SESSIONS -from services import ( +from ..charts import cumulative_profit_series, player_session_series, session_breakdown_series +from ..config import ELIGIBLE_MIN_SESSIONS +from ..services import ( apply_rank_changes, build_leaderboard, build_session_summaries, session_events, ) -from storage import load_events -from utils import session_label, session_sort_key +from ..storage import load_events +from ..utils import session_label, session_sort_key public_bp = Blueprint("public", __name__) @@ -30,9 +30,9 @@ def help(): @public_bp.get("/explore") def explore(): - from auth import is_logged_in - from db import database_extensions_available - from league_repositories import league_counts, list_public_leagues + from ..auth import is_logged_in + from ..db import database_extensions_available + from ..repositories.leagues import league_counts, list_public_leagues q = request.args.get("q", "").strip() leagues = list_public_leagues(q) if database_extensions_available() else [] diff --git a/services.py b/boker/services.py similarity index 98% rename from services.py rename to boker/services.py index c364759..6d6123a 100644 --- a/services.py +++ b/boker/services.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections import defaultdict -from models import PlayerStats, SessionEntry, SessionSummary -from storage import EventRow -from utils import entry_sort_key, net_result_bucket, session_sort_key +from .models import PlayerStats, SessionEntry, SessionSummary +from .storage import EventRow +from .utils import entry_sort_key, net_result_bucket, session_sort_key SESSION_MARKER_TYPES = {"session_open", "session_close"} diff --git a/static/css/style.css b/boker/static/css/style.css similarity index 100% rename from static/css/style.css rename to boker/static/css/style.css diff --git a/static/css/tokens.css b/boker/static/css/tokens.css similarity index 100% rename from static/css/tokens.css rename to boker/static/css/tokens.css diff --git a/static/favicon.svg b/boker/static/favicon.svg similarity index 100% rename from static/favicon.svg rename to boker/static/favicon.svg diff --git a/storage.py b/boker/storage.py similarity index 100% rename from storage.py rename to boker/storage.py diff --git a/templates/_league_subnav.html b/boker/templates/_league_subnav.html similarity index 100% rename from templates/_league_subnav.html rename to boker/templates/_league_subnav.html diff --git a/templates/account_login.html b/boker/templates/account_login.html similarity index 100% rename from templates/account_login.html rename to boker/templates/account_login.html diff --git a/templates/account_register.html b/boker/templates/account_register.html similarity index 100% rename from templates/account_register.html rename to boker/templates/account_register.html diff --git a/templates/account_settings.html b/boker/templates/account_settings.html similarity index 100% rename from templates/account_settings.html rename to boker/templates/account_settings.html diff --git a/templates/admin_dashboard.html b/boker/templates/admin_dashboard.html similarity index 100% rename from templates/admin_dashboard.html rename to boker/templates/admin_dashboard.html diff --git a/templates/admin_login.html b/boker/templates/admin_login.html similarity index 100% rename from templates/admin_login.html rename to boker/templates/admin_login.html diff --git a/templates/base.html b/boker/templates/base.html similarity index 100% rename from templates/base.html rename to boker/templates/base.html diff --git a/templates/docs.html b/boker/templates/docs.html similarity index 100% rename from templates/docs.html rename to boker/templates/docs.html diff --git a/templates/explore.html b/boker/templates/explore.html similarity index 100% rename from templates/explore.html rename to boker/templates/explore.html diff --git a/templates/forgot_password.html b/boker/templates/forgot_password.html similarity index 100% rename from templates/forgot_password.html rename to boker/templates/forgot_password.html diff --git a/templates/landing.html b/boker/templates/landing.html similarity index 100% rename from templates/landing.html rename to boker/templates/landing.html diff --git a/templates/leaderboard.html b/boker/templates/leaderboard.html similarity index 100% rename from templates/leaderboard.html rename to boker/templates/leaderboard.html diff --git a/templates/league_dashboard.html b/boker/templates/league_dashboard.html similarity index 100% rename from templates/league_dashboard.html rename to boker/templates/league_dashboard.html diff --git a/templates/league_leaderboard.html b/boker/templates/league_leaderboard.html similarity index 100% rename from templates/league_leaderboard.html rename to boker/templates/league_leaderboard.html diff --git a/templates/league_ledger.html b/boker/templates/league_ledger.html similarity index 100% rename from templates/league_ledger.html rename to boker/templates/league_ledger.html diff --git a/templates/league_new.html b/boker/templates/league_new.html similarity index 100% rename from templates/league_new.html rename to boker/templates/league_new.html diff --git a/templates/league_player_detail.html b/boker/templates/league_player_detail.html similarity index 100% rename from templates/league_player_detail.html rename to boker/templates/league_player_detail.html diff --git a/templates/league_players.html b/boker/templates/league_players.html similarity index 100% rename from templates/league_players.html rename to boker/templates/league_players.html diff --git a/templates/league_session_detail.html b/boker/templates/league_session_detail.html similarity index 100% rename from templates/league_session_detail.html rename to boker/templates/league_session_detail.html diff --git a/templates/league_session_view.html b/boker/templates/league_session_view.html similarity index 100% rename from templates/league_session_view.html rename to boker/templates/league_session_view.html diff --git a/templates/league_sessions.html b/boker/templates/league_sessions.html similarity index 100% rename from templates/league_sessions.html rename to boker/templates/league_sessions.html diff --git a/templates/league_settings.html b/boker/templates/league_settings.html similarity index 100% rename from templates/league_settings.html rename to boker/templates/league_settings.html diff --git a/templates/leagues_index.html b/boker/templates/leagues_index.html similarity index 100% rename from templates/leagues_index.html rename to boker/templates/leagues_index.html diff --git a/templates/player_detail.html b/boker/templates/player_detail.html similarity index 100% rename from templates/player_detail.html rename to boker/templates/player_detail.html diff --git a/templates/reset_password.html b/boker/templates/reset_password.html similarity index 100% rename from templates/reset_password.html rename to boker/templates/reset_password.html diff --git a/templates/session_detail.html b/boker/templates/session_detail.html similarity index 100% rename from templates/session_detail.html rename to boker/templates/session_detail.html diff --git a/templates/sessions.html b/boker/templates/sessions.html similarity index 100% rename from templates/sessions.html rename to boker/templates/sessions.html diff --git a/utils.py b/boker/utils.py similarity index 98% rename from utils.py rename to boker/utils.py index 2fa8e79..95d83dd 100644 --- a/utils.py +++ b/boker/utils.py @@ -7,7 +7,7 @@ import re from markupsafe import Markup -from models import SessionEntry, SessionSummary +from .models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 diff --git a/tests/test_accounting.py b/tests/test_accounting.py index f4e2c8c..799e3e8 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -2,15 +2,17 @@ from pathlib import Path import tempfile import unittest -import app as app_module -from charts import player_session_series -from services import ( +from boker import create_app +from boker.charts import player_session_series +from boker.services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, ) -from storage import load_events, write_events -from utils import session_sort_key +from boker.storage import load_events, write_events +from boker.utils import session_sort_key + +_app = create_app() def cents(amount: float) -> int: @@ -203,8 +205,8 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: data_path = Path(tmpdir) / "entries.csv" write_events(data_path, rows) - original_path = app_module.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + original_path = _app.config["DATA_PATH"] + _app.config["DATA_PATH"] = data_path try: sessions = build_session_summaries(load_events(data_path)) @@ -213,7 +215,7 @@ class AccountingTests(unittest.TestCase): [{"player_name": "A", "amount_cents": cents(5)}], ) - with app_module.app.test_client() as client: + with _app.test_client() as client: with client.session_transaction() as flask_session: flask_session["is_admin"] = True @@ -222,7 +224,7 @@ class AccountingTests(unittest.TestCase): data={"session_id": "s2", "player_name": "A"}, ) finally: - app_module.app.config["DATA_PATH"] = original_path + _app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) updated_sessions = build_session_summaries(load_events(data_path)) @@ -261,17 +263,17 @@ class AccountingTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: data_path = Path(tmpdir) / "entries.csv" write_events(data_path, rows) - original_path = app_module.app.config["DATA_PATH"] - app_module.app.config["DATA_PATH"] = data_path + original_path = _app.config["DATA_PATH"] + _app.config["DATA_PATH"] = data_path try: - with app_module.app.test_client() as client: + with _app.test_client() as client: with client.session_transaction() as flask_session: flask_session["is_admin"] = True response = client.post("/admin/prune-empty-sessions") finally: - app_module.app.config["DATA_PATH"] = original_path + _app.config["DATA_PATH"] = original_path self.assertEqual(response.status_code, 302) remaining = load_events(data_path) diff --git a/tests/test_stats.py b/tests/test_stats.py index 36ceaf3..5f3b1cd 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,6 +1,7 @@ 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): @@ -78,7 +79,7 @@ class CashInAccountingTest(unittest.TestCase): self.assertEqual(player.total_buy_in_cents, 2500) self.assertEqual(player.total_front_collected_cents, 0) - self.assertEqual(player.total_cash_in_cents, 3000) + self.assertEqual(player.total_real_cash_in_cents, 2500) if __name__ == "__main__": From f07803478974b214d968eecaef4c0bfa5c807d10 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 19:59:12 -0700 Subject: [PATCH 3/5] update readme --- README.md | 270 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 163 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 08b2b71..862152d 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,188 @@ -# Poker Portal +# Boker -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. +A Flask web app for tracking low-stakes poker nights. Multiple leagues, per-player stats, session history, leaderboards, and a full double-entry ledger — without keeping a spreadsheet open. -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. +## Features -## What it does - -- all-time leaderboard -- per-session pages -- per-player stat pages -- admin login for recording events -- open / closed session tracking -- actual cash payout tracking with `paid_out` events -- debt repayments and write-offs with `debt_repayment` / `writeoff` events -- session and player charts -- CSV import / export from the admin page -- append-only `entries.csv` ledger instead of overwriting old rows +- User accounts with registration and login +- Create and manage multiple leagues +- Invite league members by email with role-based access (owner / manager / viewer) +- Public or private league visibility +- Per-session event ledger (buy-ins, cashouts, fronts, rollovers, payouts) +- All-time leaderboard with rank tracking and eligibility thresholds +- Per-player stat pages with session history and charts +- Open / closed session tracking +- Debt tracking: fronts, repayments, and write-offs +- CSV export of any league ledger +- CSV import for migrating historical data +- Rate limiting and CSRF protection ## Stack -Built with a pretty lightweight setup: - -- Python -- Flask -- Jinja templates +- Python 3 / Flask +- SQLAlchemy + Flask-Migrate (SQLite for local dev, PostgreSQL in production) +- Jinja2 templates - Chart.js -- plain CSS -- CSV event log for storage +- Plain CSS + +## Setup + +### 1. Clone and create a virtual environment + +```bash +git clone https://github.com/SowinskiBraeden/boker +cd boker +python -m venv .venv +source .venv/bin/activate +``` + +### 2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 3. Configure environment + +```bash +cp .env.example .env +``` + +Open `.env` and set at minimum: + +```env +SECRET_KEY=replace-this-with-a-long-random-string +``` + +The other values can be left as defaults for local development. See the [Environment variables](#environment-variables) section for the full list. + +### 4. Initialize the database + +```bash +flask --app app db upgrade +``` + +This creates `data/boker-dev.sqlite3` and applies all migrations. Run this again whenever you pull new migrations. + +### 5. Run the development server + +```bash +flask --app app run +``` + +Then open `http://127.0.0.1:5000` and register an account. + +## Environment variables + +All variables are read from `.env` at startup. Copy `.env.example` as a starting point. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SECRET_KEY` | Yes | `change-this-before-deploying` | Flask session signing key. Use a long random string in production. | +| `DATABASE_URL` | No | `sqlite:///data/boker-dev.sqlite3` | SQLAlchemy connection URL. For production use a `postgresql+psycopg://` URL. | +| `FLASK_ENV` | No | _(unset)_ | Set to `production` to enable secure cookie flags. | +| `APP_BASE_URL` | No | `http://localhost:5000` | Base URL used when generating links in email (invite, password reset). | +| `MAIL_SERVER` | No | _(empty)_ | SMTP server hostname. Email features are disabled if left blank. | +| `MAIL_PORT` | No | `587` | SMTP port. | +| `MAIL_USE_TLS` | No | `true` | Set to `false` to disable STARTTLS. | +| `MAIL_USERNAME` | No | _(empty)_ | SMTP username / API key. | +| `MAIL_PASSWORD` | No | _(empty)_ | SMTP password / API key secret. | +| `MAIL_DEFAULT_SENDER` | No | `noreply@myboker.org` | From address on outgoing mail. | +| `FLASK_DEBUG` | No | `0` | Set to `1` to enable the Flask reloader and debugger. | + +## Running tests + +```bash +python -m pytest tests/ +``` + +Or with the standard library runner: + +```bash +python -m unittest discover tests/ +``` + +## Production deployment + +### Database + +Set `DATABASE_URL` to a PostgreSQL connection string: + +```env +DATABASE_URL=postgresql+psycopg://user:password@host/dbname +``` + +`psycopg` (v3) is already in `requirements.txt`. + +After deploying, run migrations: + +```bash +flask --app app db upgrade +``` + +### Environment + +Set `FLASK_ENV=production` to enable: +- `Secure` flag on the session cookie +- `SameSite=Strict` cookie policy +- HTTPS preferred URL scheme + +Generate a strong secret key: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +### WSGI + +Run with a production WSGI server (gunicorn, uWSGI, etc.) rather than the Flask dev server: + +```bash +gunicorn "app:app" +``` ## Ledger model -The app treats `data/entries.csv` as the source of truth. +Each event in the ledger is an append-only row. Nothing is edited or deleted — corrections are new rows. This keeps the full history readable. -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. +### Event types -Current event types: +| Event | Description | +|---|---| +| `buyin` | Player buys chips. Counts as poker investment and real cash in. | +| `front` | House fronts chips to a player. Counts as poker investment; cash is owed back. | +| `cashout` | Gross chip result at end of session. Not cash movement — sets the payout claim. | +| `paid_out` | Cash physically paid out to a player. Settles the payout claim. | +| `rollover_out` | Player carries winnings into the next session instead of being paid. Settles source session without cash movement. | +| `rollover_in` | Carried funds enter play in the destination session. Counts as investment without new cash in. | +| `payout_carry_in` | Prior-session credit applied to a later payout. Increases the payout due without counting as investment. | +| `debt_repayment` | Player repays a front outside of poker. Counts as real cash in; reduces the receivable. | +| `writeoff` | Front is forgiven. Resolves the receivable without cash. | +| `note` | Free-text bookkeeping note. No financial effect. | +| `session_open` | Marks a session as live. | +| `session_close` | Marks a session as closed. | -- `buyin` -- `front` -- `debt_repayment` -- `writeoff` -- `cashout` -- `paid_out` -- `rollover_in` -- `payout_carry_in` -- `rollover_out` -- `note` -- `session_open` -- `session_close` +Legacy ledgers may contain `paid`, `front_collected`, or `front_writeoff` — the app reads these as aliases for `paid_out`, `debt_repayment`, and `writeoff`. -A few examples: +### Accounting summary -- 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 net is `cashout - poker investment` -- real cash in is `buyin + debt_repayment` -- real cash out is `paid_out` -- `rollover_out` settles the source session without counting as cash out -- `rollover_in` enters play in the destination session without counting as cash in -- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in -- `writeoff` resolves a receivable without counting as cash in - -Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`. -The app reads those historical names as aliases for `paid_out`, -`debt_repayment`, and `writeoff`. - -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. +- **Poker investment** = `buyin + front + rollover_in` +- **Poker net** = `cashout − investment` +- **Real cash in** = `buyin + debt_repayment` +- **Real cash out** = `paid_out` +- **Rollover-out** settles a session without cash leaving the book +- **Writeoff** resolves a receivable without cash coming in ## CSV format -Main file: - -`data/entries.csv` +Each league's ledger can be exported and re-imported as 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. +Amounts are stored in cents (integer) to avoid floating-point rounding. -## Running it locally - -```bash -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env -python app.py -``` - -Then open: - -`http://127.0.0.1:8000` - -## Environment values - -The app reads these from `.env`: - -- `SECRET_KEY` -- `ADMIN_USERNAME` -- `ADMIN_PASSWORD` - -Example: - -```env -SECRET_KEY=change-this -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me -``` - -## Notes - -A few choices here were deliberate: - -- no database for now -- no user accounts, just one admin login -- public-facing stats pages, admin-only controls -- CSV backup before importing a replacement ledger - -If I ever decide to take it further, the first real upgrade would probably be moving the storage layer to SQLite while keeping the rest of the app roughly the same. +Import validates headers, event types, session references, and player names before writing anything. Rows with a matching `id` are skipped so re-importing a previous export is safe. From 5c7a498af65fc13fd44fd9985a575b65573c9879 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 24 Jun 2026 23:15:13 -0700 Subject: [PATCH 4/5] admin improvements --- boker/__init__.py | 2 + boker/auth.py | 25 + boker/config.py | 2 +- boker/db_models.py | 1 + boker/repositories/leagues.py | 20 + boker/routes/leagues.py | 34 +- boker/routes/site_admin.py | 614 +++++++++++++++++++++++ boker/static/css/admin.css | 562 +++++++++++++++++++++ boker/static/css/style.css | 7 + boker/static/css/tokens.css | 6 + boker/templates/admin/base.html | 95 ++++ boker/templates/admin/dashboard.html | 269 ++++++++++ boker/templates/admin/league_detail.html | 209 ++++++++ boker/templates/admin/leagues.html | 90 ++++ boker/templates/admin/user_detail.html | 168 +++++++ boker/templates/admin/users.html | 68 +++ boker/templates/league_settings.html | 61 ++- migrations/versions/0003_site_admin.py | 32 ++ 18 files changed, 2261 insertions(+), 4 deletions(-) create mode 100644 boker/routes/site_admin.py create mode 100644 boker/static/css/admin.css create mode 100644 boker/templates/admin/base.html create mode 100644 boker/templates/admin/dashboard.html create mode 100644 boker/templates/admin/league_detail.html create mode 100644 boker/templates/admin/leagues.html create mode 100644 boker/templates/admin/user_detail.html create mode 100644 boker/templates/admin/users.html create mode 100644 migrations/versions/0003_site_admin.py diff --git a/boker/__init__.py b/boker/__init__.py index a1cc031..b68942d 100644 --- a/boker/__init__.py +++ b/boker/__init__.py @@ -13,6 +13,7 @@ from .extensions import csrf, limiter, mail from .routes.account import account_bp from .routes.leagues import leagues_bp from .routes.public import public_bp +from .routes.site_admin import site_admin_bp from .storage import ensure_data_file from .utils import cents_to_dollars, safe_date_label @@ -44,6 +45,7 @@ def create_app(config_overrides: dict | None = None) -> Flask: app.register_blueprint(public_bp) app.register_blueprint(account_bp) app.register_blueprint(leagues_bp) + app.register_blueprint(site_admin_bp) @app.cli.command("init-db") def init_db_command() -> None: diff --git a/boker/auth.py b/boker/auth.py index c33f1f1..14c9794 100644 --- a/boker/auth.py +++ b/boker/auth.py @@ -77,3 +77,28 @@ def login_required(view): return view(*args, **kwargs) return wrapped_view + + +def current_user_is_site_admin() -> bool: + user_id = current_user_id() + if not user_id: + return False + from .db import db + from .db_models import User + if db is None: + return False + user = db.session.get(User, user_id) + return bool(user and user.is_site_admin and user.disabled_at is None) + + +def site_admin_required(view): + @wraps(view) + def wrapped_view(*args, **kwargs): + if not is_logged_in(): + return redirect(url_for("account.login", next=request.full_path)) + if not current_user_is_site_admin(): + from flask import abort + abort(403) + return view(*args, **kwargs) + + return wrapped_view diff --git a/boker/config.py b/boker/config.py index 0730954..a5f5c85 100644 --- a/boker/config.py +++ b/boker/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.3.1" +APP_VERSION = "2.4.3" def load_local_env(env_path: Path) -> None: diff --git a/boker/db_models.py b/boker/db_models.py index 229ebe9..98e4fc6 100644 --- a/boker/db_models.py +++ b/boker/db_models.py @@ -75,6 +75,7 @@ class User(TimestampMixin, db.Model): email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True) last_login_at = db.Column(db.DateTime(timezone=True), nullable=True) disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) + is_site_admin = db.Column(db.Boolean, nullable=False, default=False) class League(TimestampMixin, db.Model): diff --git a/boker/repositories/leagues.py b/boker/repositories/leagues.py index b23dbc3..176c9c4 100644 --- a/boker/repositories/leagues.py +++ b/boker/repositories/leagues.py @@ -322,3 +322,23 @@ def remove_league_member(league_id: str, user_id: str) -> None: ).one_or_none() if membership and membership.role != "owner": membership.disabled_at = utc_now() + + +def transfer_league_ownership(league_id: str, new_owner_user_id: str) -> None: + current_owner = LeagueMembership.query.filter_by( + league_id=league_id, + role="owner", + disabled_at=None, + ).one_or_none() + new_owner_membership = 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 new_owner_membership is None: + raise ValueError("Invalid transfer: owner or target not found.") + league = db.session.get(League, league_id) + if league: + league.created_by_user_id = new_owner_user_id + current_owner.role = "manager" + new_owner_membership.role = "owner" diff --git a/boker/routes/leagues.py b/boker/routes/leagues.py index 4d46c83..d868fb8 100644 --- a/boker/routes/leagues.py +++ b/boker/routes/leagues.py @@ -1127,7 +1127,8 @@ def league_settings(league_ref: str): from ..repositories.leagues import list_members_for_league members = list_members_for_league(league.id) - return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) + has_managers = any(m.role == "manager" for m, u in members) + return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members, has_managers=has_managers) @leagues_bp.post("/l//settings/invite") @@ -1196,6 +1197,37 @@ def remove_member(league_ref: str, user_id: str): return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) +@leagues_bp.post("/l//settings/transfer") +@login_required +def transfer_ownership(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from ..repositories.leagues import find_membership, transfer_league_ownership + + league = require_league(league_ref, {"owner"}) + new_owner_id = request.form.get("new_owner_user_id", "").strip() + + if not new_owner_id: + flash("Select a manager to transfer ownership to.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + membership = find_membership(league.id, new_owner_id) + if membership is None or membership.role != "manager": + flash("Target user must be a manager of this league.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + try: + transfer_league_ownership(league.id, new_owner_id) + db.session.commit() + flash("Ownership transferred. You are now a manager of this league.", "success") + except ValueError as exc: + flash(str(exc), "error") + + return redirect(url_for("leagues.dashboard", **league_url_values(league))) + + @leagues_bp.post("/l//archive") @login_required def archive_league(league_ref: str): diff --git a/boker/routes/site_admin.py b/boker/routes/site_admin.py new file mode 100644 index 0000000..1e9f4e7 --- /dev/null +++ b/boker/routes/site_admin.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from ..auth import ( + current_user_id, + generate_reset_token, + hash_password, + normalize_email, + site_admin_required, +) +from ..db import db + +site_admin_bp = Blueprint("site_admin", __name__, url_prefix="/admin") + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@site_admin_bp.get("/") +def index(): + return redirect(url_for("site_admin.dashboard")) + + +@site_admin_bp.get("/dashboard") +@site_admin_required +def dashboard(): + from ..db_models import League, LedgerEvent, LeagueMembership, PokerSession, User + + now = utc_now() + day_ago = now - timedelta(days=1) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + total_users = db.session.query(db.func.count(User.id)).scalar() or 0 + active_users = db.session.query(db.func.count(User.id)).filter(User.disabled_at.is_(None)).scalar() or 0 + disabled_users = total_users - active_users + new_users_7d = db.session.query(db.func.count(User.id)).filter(User.created_at >= week_ago).scalar() or 0 + new_users_30d = db.session.query(db.func.count(User.id)).filter(User.created_at >= month_ago).scalar() or 0 + admin_count = db.session.query(db.func.count(User.id)).filter(User.is_site_admin.is_(True)).scalar() or 0 + + dau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= day_ago).scalar() or 0 + wau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= week_ago).scalar() or 0 + mau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= month_ago).scalar() or 0 + + total_leagues = db.session.query(db.func.count(League.id)).scalar() or 0 + active_leagues = db.session.query(db.func.count(League.id)).filter(League.archived_at.is_(None)).scalar() or 0 + public_leagues = db.session.query(db.func.count(League.id)).filter( + League.visibility == "public", League.archived_at.is_(None) + ).scalar() or 0 + new_leagues_30d = db.session.query(db.func.count(League.id)).filter(League.created_at >= month_ago).scalar() or 0 + + total_sessions = db.session.query(db.func.count(PokerSession.id)).scalar() or 0 + open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(PokerSession.status == "open").scalar() or 0 + + total_events = db.session.query(db.func.count(LedgerEvent.id)).filter(LedgerEvent.voided_at.is_(None)).scalar() or 0 + + league_session_rows = db.session.query( + PokerSession.league_id, db.func.count(PokerSession.id) + ).group_by(PokerSession.league_id).all() + avg_sessions_per_league = round( + sum(r[1] for r in league_session_rows) / len(league_session_rows), 1 + ) if league_session_rows else 0 + + league_buyin_rows = db.session.query( + LedgerEvent.league_id, db.func.sum(LedgerEvent.amount_cents) + ).filter( + LedgerEvent.event_type == "buyin", + LedgerEvent.voided_at.is_(None), + ).group_by(LedgerEvent.league_id).all() + avg_buyin_cents = int( + sum(r[1] for r in league_buyin_rows) / len(league_buyin_rows) + ) if league_buyin_rows else 0 + + today = now.date() + chart_days = [today - timedelta(days=i) for i in range(29, -1, -1)] + chart_labels = [d.strftime("%b %d") for d in chart_days] + chart_iso = [d.isoformat() for d in chart_days] + + signup_raw = db.session.query( + db.func.date(User.created_at), + db.func.count(User.id), + ).filter(User.created_at >= month_ago).group_by(db.func.date(User.created_at)).all() + signup_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in signup_raw + } + chart_signups = [signup_by_day.get(d, 0) for d in chart_iso] + + login_raw = db.session.query( + db.func.date(User.last_login_at), + db.func.count(User.id), + ).filter(User.last_login_at >= month_ago).group_by(db.func.date(User.last_login_at)).all() + login_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in login_raw + } + chart_logins = [login_by_day.get(d, 0) for d in chart_iso] + + session_raw = db.session.query( + PokerSession.session_date, + db.func.count(PokerSession.id), + ).filter(PokerSession.session_date >= chart_days[0]).group_by(PokerSession.session_date).all() + session_by_day = { + (r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1] + for r in session_raw + } + chart_sessions = [session_by_day.get(d, 0) for d in chart_iso] + + chart_data = json.dumps({ + "labels": chart_labels, + "signups": chart_signups, + "logins": chart_logins, + "sessions": chart_sessions, + }) + + recent_users = ( + db.session.query(User) + .order_by(User.created_at.desc()) + .limit(10) + .all() + ) + recent_leagues = ( + db.session.query(League) + .order_by(League.created_at.desc()) + .limit(10) + .all() + ) + league_owner_ids = {league.created_by_user_id for league in recent_leagues} + owners_by_id = { + u.id: u + for u in db.session.query(User).filter(User.id.in_(league_owner_ids)).all() + } if league_owner_ids else {} + + stats = { + "total_users": total_users, + "active_users": active_users, + "disabled_users": disabled_users, + "new_users_7d": new_users_7d, + "new_users_30d": new_users_30d, + "admin_count": admin_count, + "dau": dau, + "wau": wau, + "mau": mau, + "total_leagues": total_leagues, + "active_leagues": active_leagues, + "public_leagues": public_leagues, + "new_leagues_30d": new_leagues_30d, + "total_sessions": total_sessions, + "open_sessions": open_sessions, + "total_events": total_events, + "avg_sessions_per_league": avg_sessions_per_league, + "avg_buyin_cents": avg_buyin_cents, + } + + return render_template( + "admin/dashboard.html", + stats=stats, + recent_users=recent_users, + recent_leagues=recent_leagues, + owners_by_id=owners_by_id, + chart_data=chart_data, + ) + + +@site_admin_bp.get("/users") +@site_admin_required +def users(): + from ..db_models import League, LeagueMembership, User + + search = request.args.get("q", "").strip() + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + + query = db.session.query(User) + if search: + query = query.filter(User.email.ilike(f"%{search}%")) + query = query.order_by(User.created_at.desc()) + + total = query.count() + user_list = query.offset((page - 1) * per_page).limit(per_page).all() + + user_ids = [u.id for u in user_list] + league_counts = {} + if user_ids: + rows = ( + db.session.query( + LeagueMembership.user_id, + db.func.count(LeagueMembership.id), + ) + .filter(LeagueMembership.user_id.in_(user_ids)) + .group_by(LeagueMembership.user_id) + .all() + ) + league_counts = {row[0]: row[1] for row in rows} + + total_pages = max(1, (total + per_page - 1) // per_page) + + return render_template( + "admin/users.html", + users=user_list, + league_counts=league_counts, + search=search, + page=page, + total=total, + total_pages=total_pages, + ) + + +@site_admin_bp.get("/users/") +@site_admin_required +def user_detail(user_id: str): + from flask import current_app + + from ..db_models import League, LeagueMembership, User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + 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() + ) + + reset_url = None + if request.args.get("show_reset") == "1": + 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)}" + + return render_template( + "admin/user_detail.html", + user=user, + memberships=memberships, + reset_url=reset_url, + viewing_self=user_id == current_user_id(), + ) + + +@site_admin_bp.post("/users//send-reset") +@site_admin_required +def send_reset(user_id: str): + from flask import current_app + + from ..db_models import User + from ..emails import send_password_reset + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is not None: + flash("Cannot send a reset link to a disabled account.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + 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)}" + + email_sent = False + try: + send_password_reset(user.email, reset_url) + email_sent = True + except Exception: + pass + + if email_sent: + flash(f"Password reset email sent to {user.email}.", "success") + else: + flash("Email not sent (mail not configured). Copy the link below.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1")) + + +@site_admin_bp.post("/users//disable") +@site_admin_required +def disable_user(user_id: str): + from ..db_models import User + + if user_id == current_user_id(): + flash("You cannot disable your own account.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is None: + user.disabled_at = utc_now() + db.session.commit() + flash(f"{user.email} has been disabled.", "success") + else: + flash("Account is already disabled.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//enable") +@site_admin_required +def enable_user(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + if user.disabled_at is not None: + user.disabled_at = None + db.session.commit() + flash(f"{user.email} has been re-enabled.", "success") + else: + flash("Account is not disabled.", "error") + + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//grant-admin") +@site_admin_required +def grant_admin(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + user.is_site_admin = True + db.session.commit() + flash(f"{user.email} is now a site admin.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//revoke-admin") +@site_admin_required +def revoke_admin(user_id: str): + from ..db_models import User + + if user_id == current_user_id(): + flash("You cannot revoke your own admin access.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + user.is_site_admin = False + db.session.commit() + flash(f"Admin access removed from {user.email}.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.post("/users//reset-password") +@site_admin_required +def admin_reset_password(user_id: str): + from ..db_models import User + + user = db.session.get(User, user_id) + if user is None: + flash("User not found.", "error") + return redirect(url_for("site_admin.users")) + + new_password = request.form.get("new_password", "") + if len(new_password) < 8: + flash("Password must be at least 8 characters.", "error") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + user.password_hash = hash_password(new_password) + db.session.commit() + flash(f"Password updated for {user.email}.", "success") + return redirect(url_for("site_admin.user_detail", user_id=user_id)) + + +@site_admin_bp.get("/leagues") +@site_admin_required +def leagues(): + from ..db_models import League, LeagueMembership, PokerSession, User + + search = request.args.get("q", "").strip() + visibility = request.args.get("visibility", "").strip() + show_archived = request.args.get("archived", "0") == "1" + page = max(1, int(request.args.get("page", 1))) + per_page = 50 + + query = ( + db.session.query(League) + .outerjoin(User, User.id == League.created_by_user_id) + ) + if search: + query = query.filter( + db.or_( + League.name.ilike(f"%{search}%"), + User.email.ilike(f"%{search}%"), + ) + ) + if visibility in ("public", "private"): + query = query.filter(League.visibility == visibility) + if not show_archived: + query = query.filter(League.archived_at.is_(None)) + query = query.order_by(League.created_at.desc()) + + total = query.count() + league_list = query.offset((page - 1) * per_page).limit(per_page).all() + + league_ids = [lg.id for lg in league_list] + owner_ids = [lg.created_by_user_id for lg in league_list] + + owners_by_id = {} + if owner_ids: + owners_by_id = { + u.id: u + for u in db.session.query(User).filter(User.id.in_(owner_ids)).all() + } + + member_counts = {} + session_counts = {} + if league_ids: + for row in ( + db.session.query(LeagueMembership.league_id, db.func.count(LeagueMembership.id)) + .filter(LeagueMembership.league_id.in_(league_ids)) + .group_by(LeagueMembership.league_id) + .all() + ): + member_counts[row[0]] = row[1] + + for row in ( + db.session.query(PokerSession.league_id, db.func.count(PokerSession.id)) + .filter(PokerSession.league_id.in_(league_ids)) + .group_by(PokerSession.league_id) + .all() + ): + session_counts[row[0]] = row[1] + + total_pages = max(1, (total + per_page - 1) // per_page) + + return render_template( + "admin/leagues.html", + leagues=league_list, + owners_by_id=owners_by_id, + member_counts=member_counts, + session_counts=session_counts, + search=search, + visibility=visibility, + show_archived=show_archived, + page=page, + total=total, + total_pages=total_pages, + ) + + +@site_admin_bp.get("/leagues/") +@site_admin_required +def league_detail(league_id: str): + from ..db_models import League, LedgerEvent, LeagueMembership, Player, PokerSession, User + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + memberships = ( + 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() + ) + + owner = db.session.get(User, league.created_by_user_id) + + session_count = db.session.query(db.func.count(PokerSession.id)).filter( + PokerSession.league_id == league_id + ).scalar() or 0 + + open_sessions = db.session.query(db.func.count(PokerSession.id)).filter( + PokerSession.league_id == league_id, + PokerSession.status == "open", + ).scalar() or 0 + + event_count = db.session.query(db.func.count(LedgerEvent.id)).filter( + LedgerEvent.league_id == league_id, + LedgerEvent.voided_at.is_(None), + ).scalar() or 0 + + player_count = db.session.query(db.func.count(Player.id)).filter( + Player.league_id == league_id + ).scalar() or 0 + + return render_template( + "admin/league_detail.html", + league=league, + memberships=memberships, + owner=owner, + session_count=session_count, + open_sessions=open_sessions, + event_count=event_count, + player_count=player_count, + ) + + +@site_admin_bp.post("/leagues//invite") +@site_admin_required +def admin_invite_member(league_id: str): + from flask import current_app + + from ..auth import generate_invite_token + from ..db_models import League + from ..emails import send_league_invite + from ..repositories.leagues import find_membership, find_user_by_email + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + email = normalize_email(request.form.get("email", "")) + role = request.form.get("role", "manager").strip() + + if not email or "@" not in email: + flash("Enter a valid email address.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + if role not in ("manager", "viewer"): + flash("Invalid role.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + existing_user = find_user_by_email(email) + if existing_user: + existing_membership = find_membership(league.id, existing_user.id) + if existing_membership: + flash(f"{email} is already a member of this league.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + token = generate_invite_token(league.id, email, role, current_user_id() or "") + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + invite_url = f"{base_url}{url_for('account.accept_invite', token=token)}" + + try: + send_league_invite(email, league.name, invite_url, current_user_id() or "") + flash(f"Invitation sent to {email}.", "success") + except Exception: + flash(f"Email not configured. Invite link (copy manually): {invite_url}", "info") + + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + +@site_admin_bp.post("/leagues//members//remove") +@site_admin_required +def admin_remove_member(league_id: str, user_id: str): + from ..db_models import League, LeagueMembership + from ..repositories.leagues import remove_league_member + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + membership = LeagueMembership.query.filter_by( + league_id=league_id, + user_id=user_id, + disabled_at=None, + ).one_or_none() + + if membership is None: + flash("Member not found.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + if membership.role == "owner": + flash("Cannot remove the league owner. Transfer ownership first.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + remove_league_member(league_id, user_id) + db.session.commit() + flash("Member removed.", "success") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + +@site_admin_bp.post("/leagues//transfer") +@site_admin_required +def admin_transfer_ownership(league_id: str): + from ..db_models import League + from ..repositories.leagues import transfer_league_ownership + + league = db.session.get(League, league_id) + if league is None: + flash("League not found.", "error") + return redirect(url_for("site_admin.leagues")) + + new_owner_id = request.form.get("new_owner_user_id", "").strip() + if not new_owner_id: + flash("Select a member to transfer ownership to.", "error") + return redirect(url_for("site_admin.league_detail", league_id=league_id)) + + try: + transfer_league_ownership(league_id, new_owner_id) + db.session.commit() + flash("Ownership transferred.", "success") + except ValueError as exc: + flash(str(exc), "error") + + return redirect(url_for("site_admin.league_detail", league_id=league_id)) diff --git a/boker/static/css/admin.css b/boker/static/css/admin.css new file mode 100644 index 0000000..2b40188 --- /dev/null +++ b/boker/static/css/admin.css @@ -0,0 +1,562 @@ +/* ================================================================ + ADMIN SHELL — left sidebar layout + ================================================================ */ +.admin-html, .admin-body { + margin: 0; + height: 100%; +} +.admin-body { + display: flex; + min-height: 100vh; + background: var(--bg); + color: var(--text-body); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ---- sidebar ---- */ +.adm-sidebar { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-right: 1px solid var(--border); + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 100; + overflow-y: auto; +} +.adm-sidebar__logo { + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 2px; +} +.adm-sidebar__wordmark { + font-size: 13px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.01em; +} +.adm-sidebar__tag { + font-size: 10px; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + border-radius: 4px; + padding: 1px 5px; + display: inline-block; + width: fit-content; +} +.adm-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.adm-nav__section { + font-size: 10px; + font-weight: 600; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--faintest); + padding: 12px 8px 4px; +} +.adm-nav__link { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--r-pill); + color: var(--text-2); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background .12s, color .12s; +} +.adm-nav__link:hover { + background: var(--surface-raised); + color: var(--text); +} +.adm-nav__link.is-active { + background: var(--accent-chip); + color: var(--accent); + font-weight: 600; +} +.adm-nav__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: .7; +} +.adm-nav__link.is-active .adm-nav__icon { opacity: 1; } +/* ---- main content ---- */ +.adm-main { + margin-left: 220px; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ---- header ---- */ +.adm-header { + height: 60px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + gap: 16px; + background: var(--surface); + position: sticky; + top: 0; + z-index: 50; +} +.adm-header__left { + display: flex; + align-items: center; + min-width: 0; +} +.adm-header__title { + font-size: 16px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.01em; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.adm-header__breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + min-width: 0; +} +.adm-header__breadcrumb a { + color: var(--faintest); + text-decoration: none; + font-weight: 500; + transition: color .12s; + white-space: nowrap; +} +.adm-header__breadcrumb a:hover { color: var(--accent); } +.adm-header__sep { color: var(--border-strong); flex-shrink: 0; } +.adm-header__title.adm-header__breadcrumb-end { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* breadcrumb leaf node */ +.adm-header__breadcrumb .adm-header__title { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} +.adm-header__right { + display: flex; + align-items: center; + gap: 14px; + flex-shrink: 0; +} +.adm-header__user { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--faintest); + font-family: var(--font-mono); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.adm-header__back { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-2); + text-decoration: none; + border: 1px solid var(--border-strong); + border-radius: var(--r-pill); + padding: 4px 10px; + transition: border-color .12s, color .12s, background .12s; + white-space: nowrap; +} +.adm-header__back:hover { + color: var(--text-strong); + border-color: var(--border-hi); + background: var(--surface-raised); +} +.adm-content { + padding: 28px; + max-width: 1200px; +} + +/* ---- flash messages ---- */ +.adm-flash-stack { + padding: 0 28px 0; + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} +.adm-flash { + padding: 10px 14px; + border-radius: var(--r-sm); + font-size: 13px; +} +.adm-flash.success { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-flash.error { background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); } +.adm-flash.info { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); } + +/* ---- stat sections ---- */ +.adm-stat-section { + margin-bottom: 24px; +} +.adm-stat-section__label { + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 10px; + padding-left: 2px; +} +.adm-stat-section__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; +} + +/* ---- stat grid (legacy, still used on other pages) ---- */ +.adm-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 28px; +} +.adm-stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px; +} +.adm-stat--accent { + border-left: 3px solid var(--accent); + padding-left: 15px; + background: var(--accent-tint); +} +.adm-stat--pos { + border-left: 3px solid var(--pos); + padding-left: 15px; + background: var(--pos-tint); +} +.adm-stat--warn { + border-left: 3px solid var(--warn); + padding-left: 15px; + background: var(--warn-tint); +} +.adm-stat__label { + font-size: 11px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + color: var(--faintest); + margin-bottom: 6px; +} +.adm-stat__val { + font-size: 28px; + font-weight: 700; + color: var(--text-strong); + line-height: 1; +} +.adm-stat__sub { + font-size: 11px; + color: var(--faintest); + margin-top: 4px; +} +.adm-stat__sub b { color: var(--text-2); } + +/* ---- section heading ---- */ +.adm-section { + margin-bottom: 28px; +} +.adm-section__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 12px; +} +.adm-section__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); +} + +/* ---- table ---- */ +.adm-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); +} +.adm-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.adm-table th { + padding: 9px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--faintest); + border-bottom: 1px solid var(--border); + white-space: nowrap; + background: var(--surface-head); +} +.adm-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--divider); + color: var(--text-2); + vertical-align: middle; +} +.adm-table tr:last-child td { border-bottom: none; } +.adm-table tr:hover td { background: var(--surface-raised); } +.adm-table a { color: var(--accent); text-decoration: none; } +.adm-table a:hover { text-decoration: underline; } +.adm-table .adm-mono { font-family: var(--font-mono); font-size: 12px; } +.adm-table .cell-muted { color: var(--faintest); } + +/* ---- pill badges ---- */ +.adm-pill { + display: inline-block; + font-size: 10px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 99px; + white-space: nowrap; +} +.adm-pill--active { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-pill--disabled{ background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); } +.adm-pill--admin { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); } +.adm-pill--open { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); } +.adm-pill--public { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); } +.adm-pill--private { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); } +.adm-pill--owner { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); } +.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); } +.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); } +.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); } + +/* ---- search bar ---- */ +.adm-search { + display: flex; + gap: 8px; + margin-bottom: 16px; +} +.adm-search input { + flex: 1; + min-width: 0; + padding: 8px 12px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-size: 13px; + font-family: inherit; +} +.adm-search input:focus { + outline: none; + border-color: var(--accent); +} + +/* ---- buttons ---- */ +.adm-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--r-sm); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; + transition: opacity .12s, background .12s; + white-space: nowrap; +} +.adm-btn:hover { opacity: .85; } +.adm-btn--primary { background: var(--accent); color: var(--accent-ink); } +.adm-btn--ghost { background: transparent; color: var(--text-2); border-color: var(--border-strong); } +.adm-btn--ghost:hover { background: var(--surface-raised); opacity: 1; } +.adm-btn--danger { background: var(--neg-tint); color: var(--neg); border-color: var(--neg-tint-bd); } +.adm-btn--warn { background: var(--warn-tint); color: var(--warn); border-color: var(--warn-tint-bd); } +.adm-btn--sm { padding: 4px 10px; font-size: 11px; } + +/* ---- card ---- */ +.adm-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 20px; + margin-bottom: 20px; +} +.adm-card__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} +.adm-kv { display: grid; gap: 8px; } +.adm-kv-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 12px; + font-size: 13px; +} +.adm-kv-row dt { color: var(--faintest); font-size: 12px; } +.adm-kv-row dd { margin: 0; color: var(--text-2); word-break: break-all; } +.adm-kv-row dd b { color: var(--text-strong); font-weight: 600; } + +/* ---- action group ---- */ +.adm-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +/* ---- reset link box ---- */ +.adm-reset-box { + margin-top: 12px; + padding: 12px 14px; + background: var(--warn-tint); + border: 1px solid var(--warn-tint-bd); + border-radius: var(--r-sm); +} +.adm-reset-box__label { + font-size: 11px; + font-weight: 600; + color: var(--warn); + letter-spacing: .04em; + text-transform: uppercase; + margin-bottom: 6px; +} +.adm-reset-box__url { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-2); + word-break: break-all; + user-select: all; +} + +/* ---- pagination ---- */ +.adm-pagination { + display: flex; + align-items: center; + gap: 6px; + margin-top: 16px; + font-size: 12px; + color: var(--faintest); +} +.adm-pagination a { + padding: 4px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--r-pill); + color: var(--text-2); + text-decoration: none; +} +.adm-pagination a:hover { background: var(--surface-raised); } +.adm-pagination span { padding: 4px 10px; color: var(--faintest); } + +/* ---- inline form ---- */ +.adm-inline-form { display: inline; } +.adm-pass-form { + display: flex; + gap: 8px; + align-items: flex-end; + margin-top: 8px; +} +.adm-pass-form input { + padding: 6px 10px; + background: var(--field); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-size: 12px; + font-family: inherit; + min-width: 220px; +} +.adm-pass-form input:focus { outline: none; border-color: var(--accent); } + +/* ---- chart layout ---- */ +.adm-chart-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 28px; +} +@media (max-width: 768px) { + .adm-chart-row { grid-template-columns: 1fr; } +} +.adm-chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px 14px; +} +.adm-chart-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.adm-chart-card__title { + font-size: 12px; + font-weight: 700; + color: var(--text-strong); + text-transform: uppercase; + letter-spacing: .05em; + line-height: 1.2; +} +.adm-chart-card__sub { + font-size: 11px; + color: var(--faintest); + margin-top: 3px; +} +.adm-chart-card__meta { + font-size: 11px; + color: var(--faintest); + white-space: nowrap; + margin-top: 2px; +} +.adm-chart-card__meta b { color: var(--text-2); } diff --git a/boker/static/css/style.css b/boker/static/css/style.css index 6d36ff5..bc6975b 100644 --- a/boker/static/css/style.css +++ b/boker/static/css/style.css @@ -597,6 +597,13 @@ h3 { outline: none; appearance: none; } +.form-card select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1l4.5 4.5L10 1' stroke='%2366646f' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 34px; + cursor: pointer; +} .form-card input:focus, .form-card select:focus, .form-card textarea:focus { border-color: var(--accent-chip-bd); box-shadow: 0 0 0 3px var(--accent-a22); diff --git a/boker/static/css/tokens.css b/boker/static/css/tokens.css index 00e4681..f434282 100644 --- a/boker/static/css/tokens.css +++ b/boker/static/css/tokens.css @@ -78,6 +78,12 @@ --w-admin:1580px; --gutter: clamp(16px, 4vw, 40px); + /* ---- typography ---- */ + --font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: var(--font-ui); + --font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Roboto Mono", "Liberation Mono", monospace; + --font-money: var(--font-ui); + /* ---- Chart.js backward compat (read as raw strings by JS) ---- */ --text-muted: #bcbac6; --line: #26262f; diff --git a/boker/templates/admin/base.html b/boker/templates/admin/base.html new file mode 100644 index 0000000..1c0cd90 --- /dev/null +++ b/boker/templates/admin/base.html @@ -0,0 +1,95 @@ + + + + + + {% block title %}Admin{% endblock %} · boker admin + + + + + + + + + +
+
+
+ {% block breadcrumb %} +

{% block page_title %}{% endblock %}

+ {% endblock %} +
+
+ + + + + + {{ current_user_id }} + + + + + + Back to site + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+
+ + +{% block extra_scripts %}{% endblock %} + + diff --git a/boker/templates/admin/dashboard.html b/boker/templates/admin/dashboard.html new file mode 100644 index 0000000..1cb2d71 --- /dev/null +++ b/boker/templates/admin/dashboard.html @@ -0,0 +1,269 @@ +{% extends "admin/base.html" %} +{% block title %}Dashboard{% endblock %} +{% block page_title %}Dashboard{% endblock %} + +{% block content %} + +
+ +
+
+
Total users
+
{{ stats.total_users }}
+
{{ stats.new_users_7d }} new this week · {{ stats.new_users_30d }} this month
+
+
+
Active accounts
+
{{ stats.active_users }}
+
{{ stats.disabled_users }} disabled · {{ stats.admin_count }} admin{% if stats.admin_count != 1 %}s{% endif %}
+
+
+
Daily active
+
{{ stats.dau }}
+
logged in today
+
+
+
Weekly active
+
{{ stats.wau }}
+
past 7 days
+
+
+
Monthly active
+
{{ stats.mau }}
+
past 30 days
+
+
+
+ +
+ +
+
+
Active leagues
+
{{ stats.active_leagues }}
+
{{ stats.public_leagues }} public · {{ stats.new_leagues_30d }} new this month
+
+
+
Avg sessions / league
+
{{ stats.avg_sessions_per_league }}
+
across all leagues
+
+
+
Avg buyin / league
+
{{ stats.avg_buyin_cents | money }}
+
total buyins per league
+
+
+
+ +
+ +
+
+
Sessions
+
{{ stats.total_sessions }}
+
{{ stats.open_sessions }} currently open
+
+
+
Ledger events
+
{{ stats.total_events }}
+
across all leagues
+
+
+
+ +
+
+
+
+
User activity
+
Logins & new signups — last 30 days
+
+ DAU {{ stats.dau }} · MAU {{ stats.mau }} +
+ +
+
+
+
+
Session frequency
+
Sessions played per day — last 30 days
+
+ Total {{ stats.total_sessions }} +
+ +
+
+ +
+
+ Recent sign-ups + View all users +
+ {% if recent_users %} +
+ + + + + + + + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailJoinedStatus
{{ user.email }}{% if user.is_site_admin %} admin{% endif %}{{ user.created_at.strftime('%b %d, %Y') }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ {% else %} +

No users yet.

+ {% endif %} +
+ +
+
+ Recently created leagues + View all leagues +
+ {% if recent_leagues %} +
+ + + + + + + + + + + {% for league in recent_leagues %} + + + + + + + {% endfor %} + +
NameOwnerVisibilityCreated
{{ league.name }}{% if league.archived_at %} archived{% endif %} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ league.created_at.strftime('%b %d, %Y') }}
+
+ {% else %} +

No leagues yet.

+ {% endif %} +
+ +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/boker/templates/admin/league_detail.html b/boker/templates/admin/league_detail.html new file mode 100644 index 0000000..0aadc9d --- /dev/null +++ b/boker/templates/admin/league_detail.html @@ -0,0 +1,209 @@ +{% extends "admin/base.html" %} +{% block title %}{{ league.name }}{% endblock %} + +{% block breadcrumb %} +
+ Leagues + / + {{ league.name }} +
+{% endblock %} + +{% block content %} + +
+
League info
+
+
+
Name
+
{{ league.name }}
+
+
+
League ID
+
{{ league.id }}
+
+
+
Slug / key
+
{{ league.slug }}-{{ league.public_key }}
+
+ {% if league.description %} +
+
Description
+
{{ league.description }}
+
+ {% endif %} +
+
Visibility
+
+ {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} +
+
+
+
Owner
+
+ {% if owner %} + {{ owner.email }} + {% else %} + Unknown + {% endif %} +
+
+
+
Created
+
{{ league.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Status
+
+ {% if league.archived_at %} + Archived + {{ league.archived_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
+ +
+
+
Members
+
{{ memberships | length }}
+
+
+
Players
+
{{ player_count }}
+
added to this league
+
+
+
Sessions
+
{{ session_count }}
+
{{ open_sessions }} open
+
+
+
Ledger events
+
{{ event_count }}
+
+
+ +{% if not league.archived_at %} + +{% endif %} + +{% if memberships %} +
+
Members ({{ memberships | length }})
+
+ + + + + + + + + + {% for membership, user in memberships %} + + + + + + {% endfor %} + +
UserRoleJoined league
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {% if user.disabled_at %}disabled{% endif %} + {{ membership.role }}{{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +
+
Admin actions
+ +
+
Send invite
+
+ +
+ + +
+
+ + +
+ +
+
+ + {% set has_non_owners = [] %} + {% for membership, user in memberships %}{% if membership.role != 'owner' %}{% set _ = has_non_owners.append(1) %}{% endif %}{% endfor %} + {% if has_non_owners %} +
+
Remove member
+
+ + + + + + {% for membership, user in memberships %} + {% if membership.role != 'owner' %} + + + + + + {% endif %} + {% endfor %} + +
UserRole
{{ user.email }}{{ membership.role }} +
+ + +
+
+
+
+ {% endif %} + + {% set has_managers = [] %} + {% for membership, user in memberships %}{% if membership.role == 'manager' %}{% set _ = has_managers.append((membership, user)) %}{% endif %}{% endfor %} + {% if has_managers %} +
+
Transfer ownership
+
+ +
+ + +
+ +
+
+ {% endif %} +
+ +{% endblock %} diff --git a/boker/templates/admin/leagues.html b/boker/templates/admin/leagues.html new file mode 100644 index 0000000..b4a8f75 --- /dev/null +++ b/boker/templates/admin/leagues.html @@ -0,0 +1,90 @@ +{% extends "admin/base.html" %} +{% block title %}Leagues{% endblock %} +{% block page_title %}Leagues {{ total }}{% endblock %} + +{% block content %} + + + +{% if leagues %} +
+ + + + + + + + + + + + + + {% for league in leagues %} + + + + + + + + + + {% endfor %} + +
NameOwnerVisibilityMembersSessionsCreatedStatus
{{ league.name }} + {% set owner = owners_by_id.get(league.created_by_user_id) %} + {% if owner %} + {{ owner.email }} + {% else %} + + {% endif %} + + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ member_counts.get(league.id, 0) }}{{ session_counts.get(league.id, 0) }}{{ league.created_at.strftime('%b %d, %Y') }} + {% if league.archived_at %} + Archived + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search or visibility %}No leagues matching those filters.{% else %}No leagues yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/user_detail.html b/boker/templates/admin/user_detail.html new file mode 100644 index 0000000..95c27eb --- /dev/null +++ b/boker/templates/admin/user_detail.html @@ -0,0 +1,168 @@ +{% extends "admin/base.html" %} +{% block title %}{{ user.email }}{% endblock %} + +{% block breadcrumb %} +
+ Users + / + {{ user.email }} +
+{% endblock %} + +{% block content %} + +
+
Account
+
+
+
Email
+
{{ user.email }}
+
+
+
User ID
+
{{ user.id }}
+
+
+
Joined
+
{{ user.created_at.strftime('%b %d, %Y at %H:%M UTC') }}
+
+
+
Last login
+
{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}Never{% endif %}
+
+
+
Status
+
+ {% if user.disabled_at %} + Disabled + since {{ user.disabled_at.strftime('%b %d, %Y') }} + {% else %} + Active + {% endif %} +
+
+
+
Site admin
+
+ {% if user.is_site_admin %} + Yes + {% else %} + No + {% endif %} +
+
+
+
Leagues
+
{{ memberships | length }}
+
+
+
+ +
+
Actions
+ +
+
Password reset
+
+
+ + +
+ + Generate link (no email) + +
+ {% if reset_url %} +
+
Reset link — valid 1 hour
+
{{ reset_url }}
+
+ {% endif %} +
+ +
+
Set password directly
+
+ + + +
+
+ + {% if not viewing_self %} +
+
Account status
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ +
+
Admin access
+
+ {% if user.is_site_admin %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ {% endif %} +
+ +{% if memberships %} +
+
League memberships ({{ memberships | length }})
+
+ + + + + + + + + + + {% for membership, league in memberships %} + + + + + + + {% endfor %} + +
LeagueRoleVisibilityJoined
+ {{ league.name }} + {% if league.archived_at %}archived{% endif %} + {{ membership.role }} + {% if league.visibility == 'public' %} + Public + {% else %} + Private + {% endif %} + {{ membership.created_at.strftime('%b %d, %Y') }}
+
+
+{% endif %} + +{% endblock %} diff --git a/boker/templates/admin/users.html b/boker/templates/admin/users.html new file mode 100644 index 0000000..80cc6c3 --- /dev/null +++ b/boker/templates/admin/users.html @@ -0,0 +1,68 @@ +{% extends "admin/base.html" %} +{% block title %}Users{% endblock %} +{% block page_title %}Users {{ total }}{% endblock %} + +{% block content %} + + + +{% if users %} +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
EmailJoinedLast loginLeaguesStatus
+ {{ user.email }} + {% if user.is_site_admin %}admin{% endif %} + {{ user.created_at.strftime('%b %d, %Y') }} + {% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y') }}{% else %}—{% endif %} + {{ league_counts.get(user.id, 0) }} + {% if user.disabled_at %} + Disabled + {% else %} + Active + {% endif %} +
+
+ +{% if total_pages > 1 %} +
+ {% if page > 1 %} + ← Prev + {% endif %} + Page {{ page }} of {{ total_pages }} + {% if page < total_pages %} + Next → + {% endif %} +
+{% endif %} + +{% else %} +

+ {% if search %}No users matching "{{ search }}".{% else %}No users yet.{% endif %} +

+{% endif %} + +{% endblock %} diff --git a/boker/templates/league_settings.html b/boker/templates/league_settings.html index 4f316fb..54ba9cd 100644 --- a/boker/templates/league_settings.html +++ b/boker/templates/league_settings.html @@ -64,18 +64,33 @@ Email -