diff --git a/.env.example b/.env.example index d072de1..b73c996 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ SECRET_KEY=replace-this-with-a-long-random-string -ADMIN_USERNAME=admin -ADMIN_PASSWORD=replace-this-password +DATABASE_URL=postgresql+psycopg://user:password@host/dbname +APP_BASE_URL=https://myboker.org +MAIL_SERVER=smtp.sendgrid.net +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=apikey +MAIL_PASSWORD=your-sendgrid-api-key +MAIL_DEFAULT_SENDER=noreply@myboker.org diff --git a/app.py b/app.py index be8c595..b0164e7 100644 --- a/app.py +++ b/app.py @@ -1,21 +1,31 @@ #!/usr/bin/env python3 from __future__ import annotations +import click from flask import Flask -from auth import is_admin +from auth import current_user_id, is_logged_in from config import Config -from routes.admin import admin_bp +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() -> Flask: +def create_app(config_overrides: dict | None = None) -> Flask: app = Flask(__name__) app.config.from_object(Config) + 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 @@ -24,11 +34,25 @@ def create_app() -> Flask: def inject_globals() -> dict: return { "app_version": app.config["APP_VERSION"], - "is_admin": is_admin(), + "current_user_id": current_user_id(), + "is_logged_in": is_logged_in(), } app.register_blueprint(public_bp) - app.register_blueprint(admin_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/auth.py index 19da07b..c33f1f1 100644 --- a/auth.py +++ b/auth.py @@ -1,13 +1,79 @@ #!/usr/bin/env python3 -"""Authentication helpers. - -Kept in its own module so both app.py (context processor) and routes/admin.py -(route guards) can import is_admin() without circular imports. -""" from __future__ import annotations -from flask import session as flask_session +from functools import wraps + +from flask import current_app, flash, redirect, request, session as flask_session, url_for +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from werkzeug.security import check_password_hash, generate_password_hash -def is_admin() -> bool: - return bool(flask_session.get("is_admin")) +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) + + +def generate_reset_token(user_id: str) -> str: + return _serializer().dumps(user_id, salt="password-reset") + + +def verify_reset_token(token: str, max_age: int = 3600) -> str | None: + try: + return _serializer().loads(token, salt="password-reset", max_age=max_age) + except (SignatureExpired, BadSignature): + return None + + +def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str: + return _serializer().dumps( + {"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id}, + salt="league-invite", + ) + + +def verify_invite_token(token: str, max_age: int = 604800) -> dict | None: + try: + data = _serializer().loads(token, salt="league-invite", max_age=max_age) + return data if isinstance(data, dict) else None + except (SignatureExpired, BadSignature): + return None + + +def hash_password(password: str) -> str: + return generate_password_hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + return check_password_hash(password_hash, password) + + +def normalize_email(email: str) -> str: + return email.strip().casefold() + + +def log_user_in(user_id: str) -> None: + flask_session.pop("is_admin", None) + flask_session["user_id"] = user_id + + +def log_user_out() -> None: + flask_session.pop("user_id", None) + + +def current_user_id() -> str | None: + user_id = flask_session.get("user_id") + return str(user_id) if user_id else None + + +def is_logged_in() -> bool: + return current_user_id() is not None + + +def login_required(view): + @wraps(view) + def wrapped_view(*args, **kwargs): + if not is_logged_in(): + flash("Login required.", "error") + return redirect(url_for("account.login", next=request.full_path)) + return view(*args, **kwargs) + + return wrapped_view diff --git a/config.py b/config.py index 0df2473..c5a83af 100644 --- a/config.py +++ b/config.py @@ -6,9 +6,10 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().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 = "1.8.6" +APP_VERSION = "2.3.0" def load_local_env(env_path: Path) -> None: @@ -32,9 +33,23 @@ load_local_env(BASE_DIR / ".env") class Config: APP_VERSION: str = APP_VERSION SECRET_KEY: str = os.getenv("SECRET_KEY", "change-this-before-deploying") - ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") - ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "change-me") + SQLALCHEMY_DATABASE_URI: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) + SQLALCHEMY_TRACK_MODIFICATIONS: bool = False SESSION_COOKIE_HTTPONLY: bool = True SESSION_COOKIE_SAMESITE: str = "Lax" SESSION_COOKIE_NAME: str = "poker_portal_session" DATA_PATH: Path = DATA_PATH + WTF_CSRF_TIME_LIMIT: int = 3600 + APP_BASE_URL: str = os.getenv("APP_BASE_URL", "http://localhost:5000") + MAIL_SERVER: str = os.getenv("MAIL_SERVER", "") + MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587")) + MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes") + MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None + MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None + MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org") + + +class ProductionConfig(Config): + SESSION_COOKIE_SECURE: bool = True + SESSION_COOKIE_SAMESITE: str = "Strict" + PREFERRED_URL_SCHEME: str = "https" diff --git a/db.py b/db.py new file mode 100644 index 0000000..050416c --- /dev/null +++ b/db.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from typing import Any + +try: + from flask_migrate import Migrate + from flask_sqlalchemy import SQLAlchemy +except ModuleNotFoundError: # pragma: no cover - exercised only before deps install. + Migrate = None + SQLAlchemy = None + + +db = SQLAlchemy() if SQLAlchemy is not None else None +migrate = Migrate() if Migrate is not None else None + + +def database_extensions_available() -> bool: + return db is not None and migrate is not None + + +def init_database(app: Any) -> bool: + if not database_extensions_available(): + app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False + return False + + import db_models # noqa: F401 - registers SQLAlchemy models with metadata. + + db.init_app(app) + migrate.init_app(app, db) + app.config["DATABASE_EXTENSIONS_AVAILABLE"] = True + return True diff --git a/db_models.py b/db_models.py new file mode 100644 index 0000000..23b2915 --- /dev/null +++ b/db_models.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone + +from db import db + +if db is None: # pragma: no cover - imported only when DB dependencies exist. + raise RuntimeError("Database dependencies are not installed.") + + +LEAGUE_ROLES = ("owner", "manager", "viewer") +PLAYER_STATUSES = ("active", "archived") +LEAGUE_VISIBILITIES = ("private", "public") +SESSION_STATUSES = ("open", "closed") + +CANONICAL_EVENT_TYPES = { + "buyin", + "front", + "rollover_in", + "payout_carry_in", + "cashout", + "paid_out", + "rollover_out", + "debt_repayment", + "writeoff", + "note", + "session_open", + "session_close", +} + +EVENT_TYPE_ALIASES = { + "paid": "paid_out", + "front_collected": "debt_repayment", + "front_writeoff": "writeoff", +} + +SUPPORTED_EVENT_TYPES = CANONICAL_EVENT_TYPES | set(EVENT_TYPE_ALIASES) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def uuid_str() -> str: + return str(uuid.uuid4()) + + +def normalize_lookup(value: str) -> str: + return " ".join(value.strip().casefold().split()) + + +def canonical_event_type(event_type: str) -> str: + normalized = event_type.strip().lower() + return EVENT_TYPE_ALIASES.get(normalized, normalized) + + +class TimestampMixin: + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now) + updated_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + default=utc_now, + onupdate=utc_now, + ) + + +class User(TimestampMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + email = db.Column(db.String(255), nullable=False, unique=True, index=True) + password_hash = db.Column(db.String(255), nullable=False) + email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True) + last_login_at = db.Column(db.DateTime(timezone=True), nullable=True) + disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) + + +class League(TimestampMixin, db.Model): + __tablename__ = "leagues" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + name = db.Column(db.String(160), nullable=False) + slug = db.Column(db.String(120), nullable=False, index=True) + public_key = db.Column(db.String(12), nullable=False, unique=True, index=True) + description = db.Column(db.Text, nullable=True) + visibility = db.Column(db.String(20), nullable=False, default="private") + created_by_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + archived_at = db.Column(db.DateTime(timezone=True), nullable=True) + + __table_args__ = ( + db.CheckConstraint( + f"visibility IN {LEAGUE_VISIBILITIES}", + name="ck_leagues_visibility", + ), + ) + + @property + def url_ref(self) -> str: + return f"{self.slug}-{self.public_key}" + + +class LeagueMembership(db.Model): + __tablename__ = "league_memberships" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + league_id = db.Column( + db.String(36), + db.ForeignKey("leagues.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + role = db.Column(db.String(20), nullable=False) + invited_by_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now) + disabled_at = db.Column(db.DateTime(timezone=True), nullable=True) + + __table_args__ = ( + db.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"), + db.CheckConstraint(f"role IN {LEAGUE_ROLES}", name="ck_memberships_role"), + ) + + +class Player(TimestampMixin, db.Model): + __tablename__ = "players" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + league_id = db.Column( + db.String(36), + db.ForeignKey("leagues.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + display_name = db.Column(db.String(160), nullable=False) + normalized_name = db.Column(db.String(160), nullable=False) + slug = db.Column(db.String(120), nullable=False) + status = db.Column(db.String(20), nullable=False, default="active") + linked_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + notes = db.Column(db.Text, nullable=True) + + __table_args__ = ( + db.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"), + db.UniqueConstraint("league_id", "slug", name="uq_players_slug"), + db.UniqueConstraint("league_id", "id", name="uq_players_league_id"), + db.CheckConstraint(f"status IN {PLAYER_STATUSES}", name="ck_players_status"), + ) + + +class Season(TimestampMixin, db.Model): + __tablename__ = "seasons" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + league_id = db.Column( + db.String(36), + db.ForeignKey("leagues.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name = db.Column(db.String(160), nullable=False) + start_date = db.Column(db.Date, nullable=True) + end_date = db.Column(db.Date, nullable=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + archived_at = db.Column(db.DateTime(timezone=True), nullable=True) + + __table_args__ = ( + db.UniqueConstraint("league_id", "name", name="uq_seasons_name"), + db.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"), + db.CheckConstraint( + "start_date IS NULL OR end_date IS NULL OR start_date <= end_date", + name="ck_seasons_date_order", + ), + ) + + +class PokerSession(TimestampMixin, db.Model): + __tablename__ = "sessions" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + league_id = db.Column( + db.String(36), + db.ForeignKey("leagues.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + season_id = db.Column(db.String(36), nullable=True) + session_date = db.Column(db.Date, nullable=False, index=True) + sequence_on_date = db.Column(db.Integer, nullable=False, default=1) + label = db.Column(db.String(80), nullable=True) + status = db.Column(db.String(20), nullable=False, default="closed") + opened_at = db.Column(db.DateTime(timezone=True), nullable=True) + closed_at = db.Column(db.DateTime(timezone=True), nullable=True) + notes = db.Column(db.Text, nullable=True) + + __table_args__ = ( + db.UniqueConstraint( + "league_id", + "session_date", + "sequence_on_date", + name="uq_sessions_date_sequence", + ), + db.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"), + db.ForeignKeyConstraint( + ["league_id", "season_id"], + ["seasons.league_id", "seasons.id"], + name="fk_sessions_season_same_league", + ondelete="RESTRICT", + ), + db.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"), + db.CheckConstraint(f"status IN {SESSION_STATUSES}", name="ck_sessions_status"), + ) + + @property + def display_label(self) -> str: + if self.label: + return self.label + return f"{self.session_date.isoformat()} - S{self.sequence_on_date}" + + +class LedgerEvent(db.Model): + __tablename__ = "ledger_events" + + id = db.Column(db.String(36), primary_key=True, default=uuid_str) + league_id = db.Column(db.String(36), nullable=False, index=True) + session_id = db.Column(db.String(36), nullable=False, index=True) + player_id = db.Column(db.String(36), nullable=True, index=True) + event_type = db.Column(db.String(40), nullable=False) + amount_cents = db.Column(db.Integer, nullable=False, default=0) + note = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utc_now) + created_by_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + legacy_event_id = db.Column(db.String(80), nullable=True) + legacy_player_name = db.Column(db.String(160), nullable=True) + voided_at = db.Column(db.DateTime(timezone=True), nullable=True) + voided_by_user_id = db.Column( + db.String(36), + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + void_reason = db.Column(db.Text, nullable=True) + + __table_args__ = ( + db.ForeignKeyConstraint( + ["league_id", "session_id"], + ["sessions.league_id", "sessions.id"], + name="fk_events_session_same_league", + ondelete="CASCADE", + ), + db.ForeignKeyConstraint( + ["league_id", "player_id"], + ["players.league_id", "players.id"], + name="fk_events_player_same_league", + ondelete="RESTRICT", + ), + db.CheckConstraint( + f"event_type IN {tuple(sorted(SUPPORTED_EVENT_TYPES))}", + name="ck_events_type", + ), + db.CheckConstraint( + "player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')", + name="ck_events_player_required", + ), + db.Index("ix_events_league_created", "league_id", "created_at"), + db.Index("ix_events_league_session_created", "league_id", "session_id", "created_at"), + ) + + +def make_user(email: str, password_hash: str) -> User: + return User(email=email.strip().casefold(), password_hash=password_hash) + + +def make_player(league_id: str, display_name: str, slug: str) -> Player: + return Player( + league_id=league_id, + display_name=display_name.strip(), + normalized_name=normalize_lookup(display_name), + slug=slug.strip().lower(), + ) + + +def make_session( + league_id: str, + session_date: date, + sequence_on_date: int, + season_id: str | None = None, + status: str = "closed", +) -> PokerSession: + return PokerSession( + league_id=league_id, + season_id=season_id, + session_date=session_date, + sequence_on_date=sequence_on_date, + status=status, + ) diff --git a/emails.py b/emails.py new file mode 100644 index 0000000..9f15ce6 --- /dev/null +++ b/emails.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from flask import current_app +from flask_mail import Message + +from extensions import mail + + +def send_password_reset(to_email: str, reset_url: str) -> None: + msg = Message( + subject="Reset your myboker.org password", + recipients=[to_email], + body=( + f"You requested a password reset for your myboker.org account.\n\n" + f"Click the link below to set a new password:\n\n" + f"{reset_url}\n\n" + f"This link expires in 1 hour. If you did not request this, you can ignore this email." + ), + sender=current_app.config.get("MAIL_DEFAULT_SENDER"), + ) + mail.send(msg) + + +def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str) -> None: + msg = Message( + subject=f"You've been invited to manage {league_name} on myboker.org", + recipients=[to_email], + body=( + f"{invited_by_email} has invited you to join {league_name} as a manager on myboker.org.\n\n" + f"Accept your invitation:\n\n" + f"{invite_url}\n\n" + f"This link expires in 7 days. You'll need a myboker.org account with this email address to accept." + ), + sender=current_app.config.get("MAIL_DEFAULT_SENDER"), + ) + mail.send(msg) diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..4c711f3 --- /dev/null +++ b/extensions.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_mail import Mail +from flask_wtf.csrf import CSRFProtect + +csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address) +mail = Mail() diff --git a/league_repositories.py b/league_repositories.py new file mode 100644 index 0000000..3d4417a --- /dev/null +++ b/league_repositories.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import secrets +import string +from datetime import date + +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 + LedgerEvent, + League, + LeagueMembership, + Player, + PokerSession, + Season, + User, + make_player, + make_session, + make_user, + normalize_lookup, +) +from utils import slugify + +PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits + + +def generate_public_key(length: int = 6) -> str: + return "".join(secrets.choice(PUBLIC_KEY_ALPHABET) for _ in range(length)) + + +def unique_league_public_key() -> str: + while True: + public_key = generate_public_key() + if League.query.filter_by(public_key=public_key).first() is None: + return public_key + + +def create_user(email: str, password: str) -> User: + user = make_user(normalize_email(email), hash_password(password)) + db.session.add(user) + return user + + +def create_league(owner: User, name: str, slug: str, description: str | None = None) -> League: + league = League( + name=name.strip(), + slug=slug.strip().lower(), + public_key=unique_league_public_key(), + description=description.strip() if description else None, + created_by_user_id=owner.id, + ) + db.session.add(league) + db.session.flush() + + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=owner.id, + role="owner", + ) + ) + return league + + +def unique_league_slug(name: str, requested_slug: str | None = None) -> str: + return slugify(requested_slug or name) + + +def find_user_by_email(email: str) -> User | None: + return User.query.filter_by(email=normalize_email(email)).one_or_none() + + +def find_league_by_slug(slug: str) -> League | None: + return League.query.filter_by(slug=slug.strip().lower(), archived_at=None).one_or_none() + + +def find_league_by_id(league_id: str) -> League | None: + return League.query.filter_by(id=league_id, archived_at=None).one_or_none() + + +def find_league_by_public_key(public_key: str) -> League | None: + return League.query.filter_by( + public_key=public_key.strip().lower(), + archived_at=None, + ).one_or_none() + + +def list_public_leagues(name_query: str = "") -> list[League]: + query = League.query.filter_by(visibility="public", archived_at=None) + if name_query: + query = query.filter(League.name.ilike(f"%{name_query}%")) + return query.order_by(League.name.asc()).all() + + +def list_leagues_for_user(user_id: str) -> list[tuple[League, LeagueMembership]]: + return ( + db.session.query(League, LeagueMembership) + .join(LeagueMembership, LeagueMembership.league_id == League.id) + .filter( + LeagueMembership.user_id == user_id, + LeagueMembership.disabled_at.is_(None), + League.archived_at.is_(None), + ) + .order_by(League.name.asc()) + .all() + ) + + +def league_counts(league_id: str) -> dict[str, int]: + return { + "players": Player.query.filter_by(league_id=league_id, status="active").count(), + "seasons": Season.query.filter_by(league_id=league_id, archived_at=None).count(), + "sessions": PokerSession.query.filter_by(league_id=league_id).count(), + "open_sessions": PokerSession.query.filter_by(league_id=league_id, status="open").count(), + "events": LedgerEvent.query.filter_by(league_id=league_id, voided_at=None).count(), + } + + +def create_player(league_id: str, display_name: str, slug: str) -> Player: + player = make_player(league_id=league_id, display_name=display_name, slug=slug) + db.session.add(player) + return player + + +def unique_player_slug(league_id: str, display_name: str, requested_slug: str | None = None) -> str: + base_slug = slugify(requested_slug or display_name, fallback="player") + candidate = base_slug + suffix = 2 + + while Player.query.filter_by(league_id=league_id, slug=candidate).first() is not None: + candidate = f"{base_slug}-{suffix}" + suffix += 1 + + return candidate + + +def list_players_for_league(league_id: str) -> list[Player]: + return ( + Player.query.filter_by(league_id=league_id) + .order_by(Player.status.asc(), Player.display_name.asc()) + .all() + ) + + +def find_player_for_league(league_id: str, player_id: str) -> Player | None: + return Player.query.filter_by(league_id=league_id, id=player_id).one_or_none() + + +def set_player_status(player: Player, status: str) -> Player: + if status not in {"active", "archived"}: + raise ValueError(f"Unsupported player status: {status}") + + player.status = status + return player + + +def create_season( + league_id: str, + name: str, + start_date: date | None = None, + end_date: date | None = None, + sort_order: int = 0, +) -> Season: + season = Season( + league_id=league_id, + name=name.strip(), + start_date=start_date, + end_date=end_date, + sort_order=sort_order, + ) + db.session.add(season) + return season + + +def next_sequence_on_date(league_id: str, session_date: date) -> int: + current = ( + db.session.query(db.func.max(PokerSession.sequence_on_date)) + .filter_by(league_id=league_id, session_date=session_date) + .scalar() + ) + return int(current or 0) + 1 + + +def create_poker_session( + league_id: str, + session_date: date, + season_id: str | None = None, + status: str = "closed", +) -> PokerSession: + session = make_session( + league_id=league_id, + season_id=season_id, + session_date=session_date, + sequence_on_date=next_sequence_on_date(league_id, session_date), + status=status, + ) + db.session.add(session) + return session + + +def list_sessions_for_league(league_id: str) -> list[PokerSession]: + return ( + PokerSession.query.filter_by(league_id=league_id) + .order_by( + PokerSession.session_date.desc(), + PokerSession.sequence_on_date.desc(), + PokerSession.created_at.desc(), + ) + .all() + ) + + +def find_session_for_league(league_id: str, session_id: str) -> PokerSession | None: + return PokerSession.query.filter_by(league_id=league_id, id=session_id).one_or_none() + + +def set_session_status(session: PokerSession, status: str) -> PokerSession: + if status not in {"open", "closed"}: + raise ValueError(f"Unsupported session status: {status}") + + from db_models import utc_now + + session.status = status + if status == "open": + session.opened_at = utc_now() + session.closed_at = None + else: + session.closed_at = utc_now() + return session + + +def player_name_exists(league_id: str, display_name: str) -> bool: + return ( + Player.query.filter_by( + league_id=league_id, + normalized_name=normalize_lookup(display_name), + ).first() + is not None + ) + + +def player_name_taken(league_id: str, display_name: str, exclude_player_id: str) -> bool: + return ( + Player.query.filter( + Player.league_id == league_id, + Player.normalized_name == normalize_lookup(display_name), + Player.id != exclude_player_id, + ).first() + is not None + ) + + +def update_player(player: Player, display_name: str, notes: str | None) -> Player: + player.display_name = display_name.strip() + player.normalized_name = normalize_lookup(display_name) + player.notes = notes.strip() if notes else None + return player + + +def user_has_league_role( + user_id: str, + league_id: str, + allowed_roles: set[str] | tuple[str, ...] | list[str], +) -> bool: + membership = LeagueMembership.query.filter_by( + user_id=user_id, + league_id=league_id, + disabled_at=None, + ).one_or_none() + return bool(membership and membership.role in set(allowed_roles)) + + +def find_membership(league_id: str, user_id: str) -> LeagueMembership | None: + return LeagueMembership.query.filter_by( + league_id=league_id, + user_id=user_id, + disabled_at=None, + ).one_or_none() + + +def list_members_for_league(league_id: str) -> list[tuple[LeagueMembership, User]]: + return ( + db.session.query(LeagueMembership, User) + .join(User, User.id == LeagueMembership.user_id) + .filter( + LeagueMembership.league_id == league_id, + LeagueMembership.disabled_at.is_(None), + ) + .order_by(LeagueMembership.created_at.asc()) + .all() + ) + + +def add_league_member( + league_id: str, + user_id: str, + role: str, + invited_by_user_id: str, +) -> LeagueMembership: + membership = LeagueMembership( + league_id=league_id, + user_id=user_id, + role=role, + invited_by_user_id=invited_by_user_id or None, + ) + db.session.add(membership) + return membership + + +def remove_league_member(league_id: str, user_id: str) -> None: + from db_models import utc_now + + membership = LeagueMembership.query.filter_by( + league_id=league_id, + user_id=user_id, + disabled_at=None, + ).one_or_none() + if membership and membership.role != "owner": + membership.disabled_at = utc_now() diff --git a/ledger_repositories.py b/ledger_repositories.py new file mode 100644 index 0000000..93cb1e2 --- /dev/null +++ b/ledger_repositories.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +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 + CANONICAL_EVENT_TYPES, + LedgerEvent, + Player, + PokerSession, + User, + canonical_event_type, +) +from storage import EventRow # noqa: E402 + + +def session_event_ref(session: PokerSession) -> str: + return f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}" + + +def append_ledger_event( + league_id: str, + session_id: str, + event_type: str, + amount_cents: int, + created_by_user_id: str, + player_id: str | None = None, + note: str | None = None, + legacy_event_id: str | None = None, + legacy_player_name: str | None = None, +) -> LedgerEvent: + canonical_type = canonical_event_type(event_type) + if canonical_type not in CANONICAL_EVENT_TYPES: + raise ValueError(f"Unsupported event type: {event_type}") + + if canonical_type in {"note", "session_open", "session_close"}: + amount_cents = 0 + + if player_id is None and canonical_type not in {"note", "session_open", "session_close"}: + raise ValueError(f"Event type {canonical_type} requires a player.") + + event = LedgerEvent( + league_id=league_id, + session_id=session_id, + player_id=player_id, + event_type=canonical_type, + amount_cents=amount_cents, + note=note.strip() if note else None, + created_by_user_id=created_by_user_id, + legacy_event_id=legacy_event_id, + legacy_player_name=legacy_player_name, + ) + db.session.add(event) + return event + + +def list_ledger_events_for_league(league_id: str) -> list[LedgerEvent]: + return ( + LedgerEvent.query.filter_by(league_id=league_id, voided_at=None) + .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc()) + .all() + ) + + +def list_ledger_events_for_session(league_id: str, session_id: str) -> list[LedgerEvent]: + return ( + LedgerEvent.query.filter_by( + league_id=league_id, + session_id=session_id, + voided_at=None, + ) + .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc()) + .all() + ) + + +def _event_row(event: LedgerEvent, session: PokerSession, player: Player | None, user: User | None) -> EventRow: + return EventRow( + id=event.id, + created_at=event.created_at.isoformat() if event.created_at else "", + session_id=session_event_ref(session), + session_date=session.session_date.isoformat(), + player_name=(player.display_name if player is not None else event.legacy_player_name or ""), + event_type=event.event_type, + amount_cents=event.amount_cents, + note=event.note or "", + actor=user.email if user is not None else "", + voided_at=event.voided_at.isoformat() if event.voided_at else "", + void_reason=event.void_reason or "", + ) + + +def list_all_event_rows_for_league(league_id: str) -> list[EventRow]: + """All events including voided — for display feeds only, not accounting.""" + rows = ( + db.session.query(LedgerEvent, PokerSession, Player, User) + .join( + PokerSession, + (PokerSession.league_id == LedgerEvent.league_id) + & (PokerSession.id == LedgerEvent.session_id), + ) + .outerjoin( + Player, + (Player.league_id == LedgerEvent.league_id) + & (Player.id == LedgerEvent.player_id), + ) + .outerjoin(User, User.id == LedgerEvent.created_by_user_id) + .filter(LedgerEvent.league_id == league_id) + .order_by( + PokerSession.session_date.asc(), + PokerSession.sequence_on_date.asc(), + LedgerEvent.created_at.asc(), + LedgerEvent.id.asc(), + ) + .all() + ) + return [_event_row(event, session, player, user) for event, session, player, user in rows] + + +def void_ledger_event(event_id: str, league_id: str, user_id: str, reason: str) -> LedgerEvent: + from datetime import datetime, timezone + event = LedgerEvent.query.filter_by(id=event_id, league_id=league_id).first() + if event is None: + raise ValueError("Event not found.") + if event.voided_at is not None: + raise ValueError("Event is already voided.") + event.voided_at = datetime.now(timezone.utc) + event.voided_by_user_id = user_id + event.void_reason = reason.strip() or "Voided by owner." + return event + + +def list_event_rows_for_league(league_id: str) -> list[EventRow]: + rows = ( + db.session.query(LedgerEvent, PokerSession, Player, User) + .join( + PokerSession, + (PokerSession.league_id == LedgerEvent.league_id) + & (PokerSession.id == LedgerEvent.session_id), + ) + .outerjoin( + Player, + (Player.league_id == LedgerEvent.league_id) + & (Player.id == LedgerEvent.player_id), + ) + .outerjoin(User, User.id == LedgerEvent.created_by_user_id) + .filter( + LedgerEvent.league_id == league_id, + LedgerEvent.voided_at.is_(None), + ) + .order_by( + PokerSession.session_date.asc(), + PokerSession.sequence_on_date.asc(), + LedgerEvent.created_at.asc(), + LedgerEvent.id.asc(), + ) + .all() + ) + return [_event_row(event, session, player, user) for event, session, player, user in rows] + + +def list_all_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]: + """All events for a session including voided — for display only, not accounting.""" + rows = ( + db.session.query(LedgerEvent, PokerSession, Player, User) + .join( + PokerSession, + (PokerSession.league_id == LedgerEvent.league_id) + & (PokerSession.id == LedgerEvent.session_id), + ) + .outerjoin( + Player, + (Player.league_id == LedgerEvent.league_id) + & (Player.id == LedgerEvent.player_id), + ) + .outerjoin(User, User.id == LedgerEvent.created_by_user_id) + .filter( + LedgerEvent.league_id == league_id, + LedgerEvent.session_id == session_id, + ) + .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc()) + .all() + ) + return [_event_row(event, session, player, user) for event, session, player, user in rows] + + +def list_event_rows_for_session(league_id: str, session_id: str) -> list[EventRow]: + rows = ( + db.session.query(LedgerEvent, PokerSession, Player, User) + .join( + PokerSession, + (PokerSession.league_id == LedgerEvent.league_id) + & (PokerSession.id == LedgerEvent.session_id), + ) + .outerjoin( + Player, + (Player.league_id == LedgerEvent.league_id) + & (Player.id == LedgerEvent.player_id), + ) + .outerjoin(User, User.id == LedgerEvent.created_by_user_id) + .filter( + LedgerEvent.league_id == league_id, + LedgerEvent.session_id == session_id, + LedgerEvent.voided_at.is_(None), + ) + .order_by(LedgerEvent.created_at.asc(), LedgerEvent.id.asc()) + .all() + ) + return [_event_row(event, session, player, user) for event, session, player, user in rows] diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..a251733 --- /dev/null +++ b/migrations/README @@ -0,0 +1,13 @@ +Single-database Alembic environment managed through Flask-Migrate. + +Install dependencies with: + + pip install -r requirements.txt + +Then run migrations with: + + flask --app app db upgrade + +For a quick local development bootstrap without Alembic, the app also exposes: + + flask --app app init-db diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..056afa6 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,39 @@ +# A generic, single-database Alembic config used by Flask-Migrate. + +[alembic] +script_location = migrations +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..689bbaa --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_db = current_app.extensions["migrate"].db +target_metadata = target_db.metadata + + +def get_engine(): + try: + return target_db.engine + except AttributeError: + return target_db.get_engine() + + +def get_url() -> str: + return str(get_engine().url).replace("%", "%%") + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + with get_engine().connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..0d0804f --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_public_foundation.py b/migrations/versions/0001_public_foundation.py new file mode 100644 index 0000000..f002d06 --- /dev/null +++ b/migrations/versions/0001_public_foundation.py @@ -0,0 +1,191 @@ +"""public multi-league foundation + +Revision ID: 0001_public_foundation +Revises: +Create Date: 2026-06-23 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0001_public_foundation" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False) + + op.create_table( + "leagues", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("visibility", sa.String(length=20), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=False), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("visibility IN ('private', 'public')", name="ck_leagues_visibility"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_leagues_created_by_user_id"), "leagues", ["created_by_user_id"], unique=False) + op.create_index(op.f("ix_leagues_slug"), "leagues", ["slug"], unique=False) + + op.create_table( + "league_memberships", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("league_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("invited_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("role IN ('owner', 'manager', 'viewer')", name="ck_memberships_role"), + sa.ForeignKeyConstraint(["invited_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("league_id", "user_id", name="uq_memberships_league_user"), + ) + op.create_index(op.f("ix_league_memberships_league_id"), "league_memberships", ["league_id"], unique=False) + op.create_index(op.f("ix_league_memberships_user_id"), "league_memberships", ["user_id"], unique=False) + + op.create_table( + "players", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("league_id", sa.String(length=36), nullable=False), + sa.Column("display_name", sa.String(length=160), nullable=False), + sa.Column("normalized_name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("linked_user_id", sa.String(length=36), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("status IN ('active', 'archived')", name="ck_players_status"), + sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["linked_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("league_id", "id", name="uq_players_league_id"), + sa.UniqueConstraint("league_id", "normalized_name", name="uq_players_name"), + sa.UniqueConstraint("league_id", "slug", name="uq_players_slug"), + ) + op.create_index(op.f("ix_players_league_id"), "players", ["league_id"], unique=False) + + op.create_table( + "seasons", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("league_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("start_date", sa.Date(), nullable=True), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_seasons_date_order"), + sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("league_id", "id", name="uq_seasons_league_id"), + sa.UniqueConstraint("league_id", "name", name="uq_seasons_name"), + ) + op.create_index(op.f("ix_seasons_league_id"), "seasons", ["league_id"], unique=False) + + op.create_table( + "sessions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("league_id", sa.String(length=36), nullable=False), + sa.Column("season_id", sa.String(length=36), nullable=True), + sa.Column("session_date", sa.Date(), nullable=False), + sa.Column("sequence_on_date", sa.Integer(), nullable=False), + sa.Column("label", sa.String(length=80), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("opened_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("sequence_on_date > 0", name="ck_sessions_sequence_positive"), + sa.CheckConstraint("status IN ('open', 'closed')", name="ck_sessions_status"), + sa.ForeignKeyConstraint(["league_id"], ["leagues.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["league_id", "season_id"], ["seasons.league_id", "seasons.id"], name="fk_sessions_season_same_league", ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("league_id", "id", name="uq_sessions_league_id"), + sa.UniqueConstraint("league_id", "session_date", "sequence_on_date", name="uq_sessions_date_sequence"), + ) + op.create_index(op.f("ix_sessions_league_id"), "sessions", ["league_id"], unique=False) + op.create_index(op.f("ix_sessions_session_date"), "sessions", ["session_date"], unique=False) + + op.create_table( + "ledger_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("league_id", sa.String(length=36), nullable=False), + sa.Column("session_id", sa.String(length=36), nullable=False), + sa.Column("player_id", sa.String(length=36), nullable=True), + sa.Column("event_type", sa.String(length=40), nullable=False), + sa.Column("amount_cents", sa.Integer(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=False), + sa.Column("legacy_event_id", sa.String(length=80), nullable=True), + sa.Column("legacy_player_name", sa.String(length=160), nullable=True), + sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("voided_by_user_id", sa.String(length=36), nullable=True), + sa.Column("void_reason", sa.Text(), nullable=True), + sa.CheckConstraint("event_type IN ('buyin', 'cashout', 'debt_repayment', 'front', 'front_collected', 'front_writeoff', 'note', 'paid', 'paid_out', 'payout_carry_in', 'rollover_in', 'rollover_out', 'session_close', 'session_open', 'writeoff')", name="ck_events_type"), + sa.CheckConstraint("player_id IS NOT NULL OR event_type IN ('note', 'session_open', 'session_close')", name="ck_events_player_required"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["league_id", "player_id"], ["players.league_id", "players.id"], name="fk_events_player_same_league", ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["league_id", "session_id"], ["sessions.league_id", "sessions.id"], name="fk_events_session_same_league", ondelete="CASCADE"), + sa.ForeignKeyConstraint(["voided_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_ledger_events_league_id"), "ledger_events", ["league_id"], unique=False) + op.create_index(op.f("ix_ledger_events_player_id"), "ledger_events", ["player_id"], unique=False) + op.create_index(op.f("ix_ledger_events_session_id"), "ledger_events", ["session_id"], unique=False) + op.create_index("ix_events_league_created", "ledger_events", ["league_id", "created_at"], unique=False) + op.create_index("ix_events_league_session_created", "ledger_events", ["league_id", "session_id", "created_at"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_events_league_session_created", table_name="ledger_events") + op.drop_index("ix_events_league_created", table_name="ledger_events") + op.drop_index(op.f("ix_ledger_events_session_id"), table_name="ledger_events") + op.drop_index(op.f("ix_ledger_events_player_id"), table_name="ledger_events") + op.drop_index(op.f("ix_ledger_events_league_id"), table_name="ledger_events") + op.drop_table("ledger_events") + op.drop_index(op.f("ix_sessions_session_date"), table_name="sessions") + op.drop_index(op.f("ix_sessions_league_id"), table_name="sessions") + op.drop_table("sessions") + op.drop_index(op.f("ix_seasons_league_id"), table_name="seasons") + op.drop_table("seasons") + op.drop_index(op.f("ix_players_league_id"), table_name="players") + op.drop_table("players") + op.drop_index(op.f("ix_league_memberships_user_id"), table_name="league_memberships") + op.drop_index(op.f("ix_league_memberships_league_id"), table_name="league_memberships") + op.drop_table("league_memberships") + op.drop_index(op.f("ix_leagues_slug"), table_name="leagues") + op.drop_index(op.f("ix_leagues_public_key"), table_name="leagues") + op.drop_index(op.f("ix_leagues_created_by_user_id"), table_name="leagues") + op.drop_table("leagues") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/migrations/versions/0002_league_public_key.py b/migrations/versions/0002_league_public_key.py new file mode 100644 index 0000000..6d8eb5b --- /dev/null +++ b/migrations/versions/0002_league_public_key.py @@ -0,0 +1,52 @@ +"""add league public key + +Revision ID: 0002_league_public_key +Revises: 0001_public_foundation +Create Date: 2026-06-23 +""" +from __future__ import annotations + +import secrets +import string + +from alembic import op +import sqlalchemy as sa + + +revision = "0002_league_public_key" +down_revision = "0001_public_foundation" +branch_labels = None +depends_on = None + +ALPHABET = string.ascii_lowercase + string.digits + + +def _key(existing: set[str], length: int = 6) -> str: + while True: + value = "".join(secrets.choice(ALPHABET) for _ in range(length)) + if value not in existing: + existing.add(value) + return value + + +def upgrade() -> None: + op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True)) + + bind = op.get_bind() + rows = bind.execute(sa.text("SELECT id FROM leagues")).mappings().all() + existing: set[str] = set() + for row in rows: + bind.execute( + sa.text("UPDATE leagues SET public_key = :public_key WHERE id = :id"), + {"public_key": _key(existing), "id": row["id"]}, + ) + + with op.batch_alter_table("leagues") as batch_op: + batch_op.alter_column("public_key", existing_type=sa.String(length=12), nullable=False) + batch_op.create_index("ix_leagues_public_key", ["public_key"], unique=True) + + +def downgrade() -> None: + with op.batch_alter_table("leagues") as batch_op: + batch_op.drop_index("ix_leagues_public_key") + batch_op.drop_column("public_key") diff --git a/requirements.txt b/requirements.txt index 22ac75b..b0ea65e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,7 @@ Flask==3.1.0 +Flask-Limiter>=3.5,<4.0 +Flask-Mail>=0.10,<1.0 +Flask-Migrate>=4.0,<5.0 +Flask-SQLAlchemy>=3.1,<4.0 +Flask-WTF>=1.2,<2.0 +psycopg[binary]>=3.2,<4.0 diff --git a/routes/account.py b/routes/account.py new file mode 100644 index 0000000..1bd77c0 --- /dev/null +++ b/routes/account.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from auth import ( + current_user_id, + generate_invite_token, + generate_reset_token, + hash_password, + log_user_in, + log_user_out, + login_required, + normalize_email, + verify_invite_token, + verify_password, + verify_reset_token, +) +from db import database_extensions_available, db +from extensions import limiter + +account_bp = Blueprint("account", __name__, url_prefix="/account") + + +def db_ready() -> bool: + return database_extensions_available() and db is not None + + +@account_bp.get("/") +@login_required +def home(): + return redirect(url_for("leagues.index")) + + +@account_bp.route("/register", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def register(): + if current_user_id(): + return redirect(url_for("leagues.index")) + + if not db_ready(): + flash("Account database is not available.", "error") + return render_template("account_register.html", form={}) + + form = { + "email": request.form.get("email", "").strip(), + } + + if request.method == "POST": + from league_repositories import create_user, find_user_by_email + + email = normalize_email(form["email"]) + password = request.form.get("password", "") + confirm_password = request.form.get("confirm_password", "") + + if not email or "@" not in email: + flash("Enter a valid email address.", "error") + elif len(password) < 8: + flash("Password must be at least 8 characters.", "error") + elif password != confirm_password: + flash("Passwords do not match.", "error") + elif find_user_by_email(email): + flash("An account already exists for that email.", "error") + else: + user = create_user(email, password) + db.session.commit() + log_user_in(user.id) + flash("Account created.", "success") + return redirect(url_for("leagues.new")) + + return render_template("account_register.html", form=form) + + +@account_bp.route("/login", methods=["GET", "POST"]) +@limiter.limit("20 per minute") +def login(): + if current_user_id(): + return redirect(url_for("leagues.index")) + + if not db_ready(): + flash("Account database is not available.", "error") + return render_template("account_login.html", form={}) + + form = { + "email": request.form.get("email", "").strip(), + } + + if request.method == "POST": + from league_repositories import find_user_by_email + + user = find_user_by_email(form["email"]) + password = request.form.get("password", "") + + if user is None or not verify_password(user.password_hash, password): + flash("Invalid email or password.", "error") + elif user.disabled_at is not None: + flash("That account is disabled.", "error") + else: + log_user_in(user.id) + next_url = request.args.get("next") or url_for("leagues.index") + flash("Logged in.", "success") + return redirect(next_url) + + return render_template("account_login.html", form=form) + + +@account_bp.post("/logout") +def logout(): + log_user_out() + flash("Logged out.", "success") + return redirect(url_for("public.home")) + + +@account_bp.get("/settings") +@login_required +def settings(): + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("leagues.index")) + + from db_models import User + + user = db.session.get(User, current_user_id()) + if user is None: + flash("User not found.", "error") + return redirect(url_for("public.home")) + + return render_template("account_settings.html", user=user) + + +@account_bp.post("/settings/email") +@login_required +def update_email(): + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("account.settings")) + + from db_models import User + + user = db.session.get(User, current_user_id()) + if user is None: + flash("User not found.", "error") + return redirect(url_for("account.settings")) + + new_email = normalize_email(request.form.get("email", "")) + current_password = request.form.get("current_password", "") + + if not new_email or "@" not in new_email: + flash("Enter a valid email address.", "error") + elif not verify_password(user.password_hash, current_password): + flash("Current password is incorrect.", "error") + else: + from league_repositories import find_user_by_email + + existing = find_user_by_email(new_email) + if existing and existing.id != user.id: + flash("That email is already in use.", "error") + else: + user.email = new_email + db.session.commit() + flash("Email updated.", "success") + + return redirect(url_for("account.settings")) + + +@account_bp.post("/settings/password") +@login_required +def update_password(): + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("account.settings")) + + from db_models import User + + user = db.session.get(User, current_user_id()) + if user is None: + flash("User not found.", "error") + return redirect(url_for("account.settings")) + + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + + if not verify_password(user.password_hash, current_password): + flash("Current password is incorrect.", "error") + elif len(new_password) < 8: + flash("New password must be at least 8 characters.", "error") + elif new_password != confirm_password: + flash("Passwords do not match.", "error") + else: + user.password_hash = hash_password(new_password) + db.session.commit() + flash("Password changed.", "success") + + return redirect(url_for("account.settings")) + + +@account_bp.post("/delete") +@login_required +def delete_account(): + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("account.settings")) + + from db_models import League, User, utc_now + + user = db.session.get(User, current_user_id()) + if user is None: + flash("User not found.", "error") + return redirect(url_for("account.settings")) + + confirm = request.form.get("confirm", "").strip() + current_password = request.form.get("current_password", "") + + if confirm != "DELETE": + flash("Confirmation text did not match.", "error") + return redirect(url_for("account.settings")) + + if not verify_password(user.password_hash, current_password): + flash("Password is incorrect.", "error") + return redirect(url_for("account.settings")) + + owned_leagues = League.query.filter_by(created_by_user_id=user.id, archived_at=None).all() + for league in owned_leagues: + league.archived_at = utc_now() + + user.disabled_at = utc_now() + db.session.commit() + log_user_out() + flash("Your account has been deleted.", "success") + return redirect(url_for("public.home")) + + +@account_bp.route("/forgot-password", methods=["GET", "POST"]) +@limiter.limit("10 per minute") +def forgot_password(): + if current_user_id(): + return redirect(url_for("leagues.index")) + + if request.method == "POST": + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("account.forgot_password")) + + from emails import send_password_reset + from flask import current_app + from league_repositories import find_user_by_email + + email = normalize_email(request.form.get("email", "")) + user = find_user_by_email(email) + + if user and user.disabled_at is None: + token = generate_reset_token(user.id) + base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") + reset_url = f"{base_url}{url_for('account.reset_password', token=token)}" + try: + send_password_reset(email, reset_url) + except Exception: + pass + + flash("If that email is registered, a reset link has been sent.", "success") + return redirect(url_for("account.login")) + + return render_template("forgot_password.html") + + +@account_bp.route("/reset-password/", methods=["GET", "POST"]) +def reset_password(token): + if current_user_id(): + return redirect(url_for("leagues.index")) + + user_id = verify_reset_token(token) + if user_id is None: + flash("That reset link is invalid or has expired.", "error") + return redirect(url_for("account.forgot_password")) + + if not db_ready(): + flash("Account database is not available.", "error") + return redirect(url_for("account.login")) + + from db_models import User + + user = db.session.get(User, user_id) + if user is None or user.disabled_at is not None: + flash("That reset link is invalid or has expired.", "error") + return redirect(url_for("account.forgot_password")) + + if request.method == "POST": + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + + if len(new_password) < 8: + flash("Password must be at least 8 characters.", "error") + elif new_password != confirm_password: + flash("Passwords do not match.", "error") + else: + user.password_hash = hash_password(new_password) + db.session.commit() + flash("Password reset. You can now log in.", "success") + return redirect(url_for("account.login")) + + return render_template("reset_password.html", token=token) + + +@account_bp.get("/accept-invite/") +def accept_invite(token): + data = verify_invite_token(token) + if data is None: + flash("That invitation link is invalid or has expired.", "error") + return redirect(url_for("public.home")) + + if not current_user_id(): + return redirect(url_for("account.login", next=url_for("account.accept_invite", token=token))) + + if not db_ready(): + 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 + + user = db.session.get(User, current_user_id()) + if user is None or user.email != data.get("email"): + flash("This invitation was sent to a different email address.", "error") + return redirect(url_for("leagues.index")) + + league = find_league_by_id(data["league_id"]) + if league is None: + flash("That league no longer exists.", "error") + return redirect(url_for("leagues.index")) + + existing = find_membership(league.id, user.id) + if existing: + flash(f"You are already a member of {league.name}.", "info") + return redirect(url_for("leagues.dashboard", league_ref=league.url_ref)) + + add_league_member(league.id, user.id, data["role"], data.get("invited_by", "")) + db.session.commit() + flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success") + return redirect(url_for("leagues.dashboard", league_ref=league.url_ref)) diff --git a/routes/admin.py b/routes/admin.py index 08f44ab..f959d89 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -51,6 +51,7 @@ def login(): username == current_app.config["ADMIN_USERNAME"] and password == current_app.config["ADMIN_PASSWORD"] ): + flask_session.pop("user_id", None) flask_session["is_admin"] = True flash("Admin login successful.", "success") return redirect(url_for("admin.dashboard")) @@ -64,7 +65,7 @@ def login(): def logout(): flask_session.clear() flash("Logged out.", "success") - return redirect(url_for("public.leaderboard")) + return redirect(url_for("public.home")) @admin_bp.post("/admin/session-state") diff --git a/routes/leagues.py b/routes/leagues.py new file mode 100644 index 0000000..44bcb13 --- /dev/null +++ b/routes/leagues.py @@ -0,0 +1,1377 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from datetime import date + +import csv +import io +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 + +leagues_bp = Blueprint("leagues", __name__) + +LEAGUE_EVENT_TYPES = [ + ("buyin", "Buy-in"), + ("front", "Front"), + ("rollover_in", "Rollover-in"), + ("payout_carry_in", "Payout carry-in"), + ("cashout", "Cashout result"), + ("paid_out", "Paid out cash"), + ("rollover_out", "Rollover-out"), + ("debt_repayment", "Debt repayment"), + ("writeoff", "Write-off"), + ("note", "Note"), +] + + +def db_ready() -> bool: + return database_extensions_available() and db is not None + + +def league_url_values(league) -> dict[str, str]: + return {"league_ref": league.url_ref} + + +def split_league_ref(league_ref: str) -> tuple[str, str]: + slug, separator, public_key = league_ref.rpartition("-") + if not separator or not slug or not public_key: + abort(404) + return slug, public_key + + +def require_league(league_ref: str, allowed_roles: set[str]): + from league_repositories 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) + if league is None: + abort(404) + + user_id = current_user_id() or "" + if not user_has_league_role(user_id, league.id, allowed_roles): + abort(403) + + return league + + +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 + + _slug, public_key = split_league_ref(league_ref) + league = find_league_by_public_key(public_key) + if league is None: + abort(404) + + if league.visibility != "public": + user_id = current_user_id() + if not user_id: + return None, redirect(url_for("account.login", next=request.path)) + if not user_has_league_role(user_id, league.id, {"owner", "manager", "viewer"}): + abort(403) + + return league, None + + +def parse_amount_cents(raw_amount: str) -> int | None: + try: + return int(round(float(raw_amount.strip() or "0") * 100)) + except ValueError: + return None + + +def empty_session_summary(session) -> SessionSummary: + return SessionSummary( + session_id=f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}", + session_date=session.session_date.isoformat(), + entries=[], + status=session.status, + opened_at=session.opened_at.isoformat() if session.opened_at else "", + ) + + +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 + + return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} + + +@leagues_bp.get("/leagues") +@login_required +def index(): + if not db_ready(): + 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 + + memberships = list_leagues_for_user(current_user_id() or "") + league_summaries = [] + total_sessions = 0 + total_players = 0 + total_events = 0 + total_open_items_cents = 0 + live_count = 0 + for league, membership in memberships: + events = list_event_rows_for_league(league.id) + summaries = build_session_summaries(events) + leaderboard = build_leaderboard(summaries) + live_sessions = [session for session in summaries if session.status == "open"] + latest_session = sorted(summaries, key=session_sort_key, reverse=True)[0] if summaries else None + players_count = len({ + entry.player_name + for session in summaries + for entry in session.entries + }) + open_items_cents = sum( + session.total_current_due_to_player_cents + + session.total_current_due_to_house_cents + for session in summaries + ) + total_sessions += len(summaries) + total_players += players_count + total_events += len(events) + total_open_items_cents += open_items_cents + live_count += len(live_sessions) + league_summaries.append( + { + "league": league, + "membership": membership, + "sessions": summaries, + "live_session": live_sessions[0] if live_sessions else None, + "latest_session": latest_session, + "top_players": leaderboard[:3], + "leader": leaderboard[0] if leaderboard else None, + "players_count": players_count, + "sessions_count": len(summaries), + "events_count": len(events), + "cash_paid_out_cents": sum( + session.total_paid_out_cents for session in summaries + ), + "open_items_cents": open_items_cents, + } + ) + page_summary = { + "league_count": len(league_summaries), + "live_count": live_count, + "players_count": total_players, + "sessions_count": total_sessions, + "events_count": total_events, + "open_items_cents": total_open_items_cents, + } + return render_template( + "leagues_index.html", + leagues=league_summaries, + page_summary=page_summary, + session_label=session_label, + ) + + +@leagues_bp.route("/leagues/new", methods=["GET", "POST"]) +@login_required +def new(): + if not db_ready(): + flash("League database is not available.", "error") + return render_template("league_new.html", form={}) + + form = { + "name": request.form.get("name", "").strip(), + "description": request.form.get("description", "").strip(), + } + + if request.method == "POST": + from db_models import User + from league_repositories import create_league, unique_league_slug + + owner = db.session.get(User, current_user_id()) + if owner is None: + flash("Login required.", "error") + return redirect(url_for("account.login")) + + if len(form["name"]) < 2: + flash("League name must be at least 2 characters.", "error") + else: + slug = unique_league_slug(form["name"]) + league = create_league( + owner=owner, + name=form["name"], + slug=slug, + description=form["description"] or None, + ) + db.session.commit() + flash("League created.", "success") + return redirect(url_for("leagues.dashboard", **league_url_values(league))) + + return render_template("league_new.html", form=form) + + +@leagues_bp.get("/l/") +def dashboard(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import league_counts, user_has_league_role + + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + return render_template( + "league_dashboard.html", + league=league, + counts=league_counts(league.id), + is_owner=is_owner, + ) + + +@leagues_bp.get("/l//") +@login_required +def legacy_dashboard_redirect(league_id: str, league_slug: str): + if not db_ready(): + 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 + + league = find_league_by_id(league_id) + if league is None: + abort(404) + if not user_has_league_role(current_user_id() or "", league.id, {"owner", "manager", "viewer"}): + abort(403) + + return redirect(url_for("leagues.dashboard", **league_url_values(league)), code=301) + + +@leagues_bp.get("/l//leaderboard") +def leaderboard(league_ref: str): + if not db_ready(): + 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 + + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) + ordered_sessions = sorted(all_sessions, key=session_sort_key) + + session_ids = [session.session_id for session in ordered_sessions] + selected_session_id = request.args.get("through_session", "").strip() + mode = request.args.get("mode", "eligible").strip() + if mode not in ("eligible", "all", "recent"): + mode = "eligible" + label = "" + cutoff_index = len(session_ids) - 1 + + if session_ids: + if selected_session_id in session_ids: + cutoff_index = session_ids.index(selected_session_id) + label = session_label(ordered_sessions[cutoff_index]) + else: + label = session_label(ordered_sessions[cutoff_index]) + + filtered_sessions = ordered_sessions[: cutoff_index + 1] + previous_sessions = ordered_sessions[:cutoff_index] + else: + filtered_sessions = [] + previous_sessions = [] + + board = apply_rank_changes( + build_leaderboard(filtered_sessions), + build_leaderboard(previous_sessions), + ) + eligible_count = sum( + 1 for player in board if player.sessions_played >= ELIGIBLE_MIN_SESSIONS + ) + all_count = len(board) + recent_sessions_slice = filtered_sessions[-5:] + recent_count = len({ + entry.player_name + for session in recent_sessions_slice + for entry in session.entries + }) + + if mode == "recent": + main_board = build_leaderboard(recent_sessions_slice) + provisional_board = [] + elif mode == "eligible": + main_board = [ + player + for player in board + if player.sessions_played >= ELIGIBLE_MIN_SESSIONS + ] + provisional_board = [ + player + for player in board + if player.sessions_played < ELIGIBLE_MIN_SESSIONS + ] + else: + main_board = board + provisional_board = [] + + chart_data = cumulative_profit_series(filtered_sessions) + cash_paid_out_cents = sum(session.total_paid_out_cents for session in all_sessions) + + return render_template( + "league_leaderboard.html", + league=league, + main_board=main_board, + provisional_board=provisional_board, + mode=mode, + eligible_count=eligible_count, + all_count=all_count, + recent_count=recent_count, + eligible_min_sessions=ELIGIBLE_MIN_SESSIONS, + session_count=len(filtered_sessions), + total_session_count=len(all_sessions), + cash_paid_out_cents=cash_paid_out_cents, + chart_data=chart_data, + available_sessions=all_sessions, + selected_session_id=selected_session_id, + selected_session_label=(label if selected_session_id else "Latest session"), + selected_session_date=( + ordered_sessions[cutoff_index].session_date + if selected_session_id and session_ids + else "" + ), + session_label=session_label, + session_ids=session_ref_map(league.id), + player_ids={ + player.display_name: player.id + for player in list_players_for_league(league.id) + }, + is_owner=is_owner, + ) + + +@leagues_bp.route("/l//ledger", methods=["GET", "POST"]) +@login_required +def ledger(league_ref: str): + if not db_ready(): + 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_league, + list_event_rows_for_league, + ) + from league_repositories import ( + find_player_for_league, + find_session_for_league, + list_players_for_league, + user_has_league_role, + ) + + league = require_league( + league_ref, + {"owner", "manager"} if request.method == "POST" else {"owner", "manager", "viewer"}, + ) + can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"}) + is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + + rows = list_event_rows_for_league(league.id) + summaries = build_session_summaries(rows) + session_ids = session_ref_map(league.id) + player_ids = {player.display_name: player.id for player in list_players_for_league(league.id)} + debt_entries = [ + {"session": session, "entry": entry} + for session in summaries + for entry in session.entries + if entry.current_due_to_house_cents > 0 + ] + due_to_player_items = [ + {"session": session, "entry": entry} + for session in summaries + for entry in session.entries + if entry.current_due_to_player_cents > 0 + ] + open_sessions = [s for s in summaries if s.is_open] + + if request.method == "POST": + session_id = request.form.get("session_id", "").strip() + player_id = request.form.get("player_id", "").strip() + event_type = request.form.get("event_type", "").strip() + note = request.form.get("note", "").strip() + amount_cents = parse_amount_cents(request.form.get("amount", "0")) + + session = find_session_for_league(league.id, session_id) + player = find_player_for_league(league.id, player_id) + if session is None or player is None: + abort(404) + if event_type not in {"debt_repayment", "writeoff"}: + flash("Select a valid debt resolution type.", "error") + elif amount_cents is None or amount_cents <= 0: + flash("Amount must be greater than zero.", "error") + else: + session_ref = next( + ( + ref + for ref, db_session_id in session_ids.items() + if db_session_id == session.id + ), + "", + ) + target_summary = next( + (summary for summary in summaries if summary.session_id == session_ref), + None, + ) + target_entry = ( + next( + ( + entry + for entry in target_summary.entries + if entry.player_name == player.display_name + ), + None, + ) + if target_summary is not None + else None + ) + if target_entry is None or target_entry.current_due_to_house_cents <= 0: + flash("That player does not have an outstanding debt in this session.", "error") + elif amount_cents > target_entry.current_due_to_house_cents: + flash( + f"Debt resolution cannot exceed {cents_to_dollars(target_entry.current_due_to_house_cents)}.", + "error", + ) + else: + append_ledger_event( + league.id, + session.id, + event_type, + amount_cents, + current_user_id() or "", + player_id=player.id, + note=note or ("Debt repaid." if event_type == "debt_repayment" else "Debt written off."), + ) + db.session.commit() + flash("Debt resolution recorded.", "success") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + cash_in_cents = sum(s.total_real_cash_in_cents for s in summaries) + cash_out_cents = sum(s.total_real_cash_out_cents for s in summaries) + due_to_players_cents = sum(s.total_current_due_to_player_cents for s in summaries) + due_to_house_cents = sum(s.total_current_due_to_house_cents for s in summaries) + totals = { + "cash_in_cents": cash_in_cents, + "cash_out_cents": cash_out_cents, + "due_to_players_cents": due_to_players_cents, + "due_to_house_cents": due_to_house_cents, + "book_held_cents": cash_in_cents - cash_out_cents, + "net_position_cents": (cash_in_cents - cash_out_cents) + due_to_house_cents - due_to_players_cents, + "collected_cents": sum(s.total_debt_repayment_cents for s in summaries), + "written_off_cents": sum(s.total_writeoff_cents for s in summaries), + "events": len(rows), + } + + all_rows = list_all_event_rows_for_league(league.id) + + return render_template( + "league_ledger.html", + league=league, + totals=totals, + sessions=summaries, + debt_entries=debt_entries, + due_to_player_items=due_to_player_items, + open_sessions=open_sessions, + raw_events=list(reversed(all_rows)), + session_label=session_label, + session_ids=session_ids, + player_ids=player_ids, + can_manage=can_manage, + is_owner=is_owner, + ) + + +@leagues_bp.post("/l//events//void") +@login_required +def void_event(league_ref: str, event_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from ledger_repositories import void_ledger_event + + league = require_league(league_ref, {"owner"}) + reason = request.form.get("void_reason", "").strip() + session_id = None + try: + event = void_ledger_event(event_id, league.id, current_user_id() or "", reason) + session_id = event.session_id + db.session.commit() + flash("Event voided.", "success") + except ValueError as exc: + flash(str(exc), "error") + if session_id: + return redirect(url_for("leagues.session_detail", **league_url_values(league), session_id=session_id)) + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + +@leagues_bp.get("/l//players/") +def player_detail(league_ref: str, player_id: str): + if not db_ready(): + 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 + + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + user_id = current_user_id() or "" + is_owner = user_has_league_role(user_id, league.id, {"owner"}) + can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"}) + player_record = find_player_for_league(league.id, player_id) + if player_record is None: + abort(404) + + all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) + board = build_leaderboard(all_sessions) + player_stats = next( + (player for player in board if player.player_name == player_record.display_name), + None, + ) + if player_stats is None: + flash("That player has no ledger events yet.", "error") + return redirect(url_for("leagues.players", **league_url_values(league))) + + player_rank = next( + ( + index + for index, ranked_player in enumerate(board, start=1) + if ranked_player.player_name == player_record.display_name + ), + None, + ) + player_sessions = [ + {"session": session, "entry": entry} + for session in sorted(all_sessions, key=session_sort_key, reverse=True) + for entry in session.entries + if entry.player_name == player_record.display_name + ] + + return render_template( + "league_player_detail.html", + league=league, + player_record=player_record, + player=player_stats, + player_rank=player_rank, + chart_data=player_session_series(all_sessions, player_record.display_name), + player_sessions=player_sessions, + session_label=session_label, + session_ids=session_ref_map(league.id), + is_owner=is_owner, + can_manage=can_manage, + ) + + +@leagues_bp.route("/l//players", methods=["GET", "POST"]) +def players(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import ( + create_player, + list_players_for_league, + player_name_exists, + unique_player_slug, + user_has_league_role, + ) + + if request.method == "POST": + league = require_league(league_ref, {"owner", "manager"}) + else: + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + user_id = current_user_id() or "" + can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"}) + is_owner = user_has_league_role(user_id, league.id, {"owner"}) + form = { + "display_name": request.form.get("display_name", "").strip(), + "slug": request.form.get("slug", "").strip(), + "notes": request.form.get("notes", "").strip(), + } + + if request.method == "POST": + if len(form["display_name"]) < 2: + flash("Player name must be at least 2 characters.", "error") + elif player_name_exists(league.id, form["display_name"]): + flash("A player with that name already exists in this league.", "error") + else: + player = create_player( + league.id, + form["display_name"], + unique_player_slug(league.id, form["display_name"], form["slug"] or None), + ) + player.notes = form["notes"] or None + db.session.commit() + flash("Player added.", "success") + return redirect(url_for("leagues.players", **league_url_values(league))) + + from ledger_repositories import list_event_rows_for_league + + all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) + board = build_leaderboard(all_sessions) + stats_by_name = {p.player_name: p for p in board} + + return render_template( + "league_players.html", + league=league, + players=list_players_for_league(league.id), + stats_by_name=stats_by_name, + form=form, + can_manage=can_manage, + is_owner=is_owner, + ) + + +@leagues_bp.post("/l//players//archive") +@login_required +def archive_player(league_ref: str, player_id: str): + return update_player_status(league_ref, player_id, "archived", "Player archived.") + + +@leagues_bp.post("/l//players//reactivate") +@login_required +def reactivate_player(league_ref: str, player_id: str): + return update_player_status(league_ref, player_id, "active", "Player reactivated.") + + +def update_player_status(league_ref: str, player_id: str, status: str, message: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import find_player_for_league, set_player_status + + league = require_league(league_ref, {"owner", "manager"}) + player = find_player_for_league(league.id, player_id) + if player is None: + abort(404) + + set_player_status(player, status) + db.session.commit() + flash(message, "success") + return redirect(url_for("leagues.players", **league_url_values(league))) + + +@leagues_bp.post("/l//players//edit") +@login_required +def edit_player(league_ref: str, player_id: str): + if not db_ready(): + 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 + + league = require_league(league_ref, {"owner", "manager"}) + player = find_player_for_league(league.id, player_id) + if player is None: + abort(404) + + new_name = request.form.get("display_name", "").strip() + new_notes = request.form.get("notes", "").strip() or None + + if len(new_name) < 2: + flash("Player name must be at least 2 characters.", "error") + elif player_name_taken(league.id, new_name, player.id): + flash("A player with that name already exists in this league.", "error") + else: + update_player(player, new_name, new_notes) + db.session.commit() + flash("Player updated.", "success") + + return redirect(url_for("leagues.players", **league_url_values(league))) + + +@leagues_bp.route("/l//sessions", methods=["GET", "POST"]) +def sessions(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from ledger_repositories import append_ledger_event + from league_repositories import ( + create_poker_session, + list_sessions_for_league, + user_has_league_role, + ) + + if request.method == "POST": + league = require_league(league_ref, {"owner", "manager"}) + else: + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + user_id = current_user_id() or "" + can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"}) + is_owner = user_has_league_role(user_id, league.id, {"owner"}) + form = { + "session_date": request.form.get("session_date", date.today().isoformat()).strip(), + "label": request.form.get("label", "").strip(), + "notes": request.form.get("notes", "").strip(), + "status": request.form.get("status", "open").strip(), + } + + if request.method == "POST": + try: + session_date = date.fromisoformat(form["session_date"]) + except ValueError: + flash("Session date must be a valid date.", "error") + else: + status = "closed" if form["status"] == "closed" else "open" + session = create_poker_session( + league.id, + session_date, + status=status, + ) + session.label = form["label"] or None + session.notes = form["notes"] or None + if status == "open": + from league_repositories import set_session_status + + set_session_status(session, "open") + + db.session.flush() + if status == "open": + append_ledger_event( + league.id, + session.id, + "session_open", + 0, + current_user_id() or "", + note="Session created.", + ) + db.session.commit() + 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 + + all_sessions = list_sessions_for_league(league.id) + summaries = build_session_summaries(list_event_rows_for_league(league.id)) + ref_to_db_id = { + f"{s.session_date.isoformat()}-{s.sequence_on_date:02d}": s.id + for s in all_sessions + } + db_id_to_summary = { + ref_to_db_id[sm.session_id]: sm + for sm in summaries + if sm.session_id in ref_to_db_id + } + empty_count = sum( + 1 for s in all_sessions + if s.id not in db_id_to_summary or not db_id_to_summary[s.id].entries + ) + + return render_template( + "league_sessions.html", + league=league, + sessions=all_sessions, + db_id_to_summary=db_id_to_summary, + form=form, + can_manage=can_manage, + is_owner=is_owner, + empty_count=empty_count, + ) + + +@leagues_bp.post("/l//sessions/prune-empty") +@login_required +def prune_empty_sessions(league_ref: str): + if not db_ready(): + 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 + + league = require_league(league_ref, {"owner", "manager"}) + + all_sessions = list_sessions_for_league(league.id) + session_ids_with_players = { + row[0] + for row in db.session.query(LedgerEvent.session_id).filter( + LedgerEvent.league_id == league.id, + LedgerEvent.player_id.isnot(None), + LedgerEvent.voided_at.is_(None), + ).all() + } + empty_sessions = [s for s in all_sessions if s.id not in session_ids_with_players] + + if not empty_sessions: + flash("No empty sessions to prune.", "success") + return redirect(url_for("leagues.sessions", **league_url_values(league))) + + for session in empty_sessions: + db.session.delete(session) + db.session.commit() + + n = len(empty_sessions) + flash(f"Pruned {n} empty session{'s' if n != 1 else ''}.", "success") + return redirect(url_for("leagues.sessions", **league_url_values(league))) + + +@leagues_bp.post("/l//sessions//delete") +@login_required +def delete_session(league_ref: str, session_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import find_session_for_league + + league = require_league(league_ref, {"owner"}) + session = find_session_for_league(league.id, session_id) + if session is None: + abort(404) + label = session.display_label + db.session.delete(session) + db.session.commit() + flash(f"Deleted {label} and all its events.", "success") + return redirect(url_for("leagues.sessions", **league_url_values(league))) + + +@leagues_bp.post("/l//sessions//edit") +@login_required +def edit_session(league_ref: str, session_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import find_session_for_league + + league = require_league(league_ref, {"owner", "manager"}) + session = find_session_for_league(league.id, session_id) + if session is None: + abort(404) + + new_label = request.form.get("label", "").strip() or None + new_notes = request.form.get("notes", "").strip() or None + new_date_str = request.form.get("session_date", "").strip() + + try: + new_date = date.fromisoformat(new_date_str) + except ValueError: + flash("Invalid session date.", "error") + 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 + max_seq = db.session.query(db.func.max(_PS.sequence_on_date)).filter( + _PS.league_id == league.id, + _PS.session_date == new_date, + _PS.id != session.id, + ).scalar() + session.sequence_on_date = int(max_seq or 0) + 1 + session.session_date = new_date + + session.label = new_label + session.notes = new_notes + db.session.commit() + flash("Session updated.", "success") + return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) + + +@leagues_bp.route("/l//sessions/", methods=["GET", "POST"]) +@login_required +def session_detail(league_ref: str, session_id: str): + if not db_ready(): + 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 ( + find_session_for_league, + list_players_for_league, + user_has_league_role, + ) + + league = require_league( + league_ref, + {"owner", "manager"} if request.method == "POST" else {"owner", "manager", "viewer"}, + ) + session = find_session_for_league(league.id, session_id) + if session is None: + abort(404) + + can_manage = user_has_league_role(current_user_id() or "", league.id, {"owner", "manager"}) + is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + players = [player for player in list_players_for_league(league.id) if player.status == "active"] + append_form = { + "player_id": request.form.get("player_id", "").strip(), + "event_type": request.form.get("event_type", "buyin").strip(), + "amount": request.form.get("amount", "").strip(), + "note": request.form.get("note", "").strip(), + } + + if request.method == "POST": + if session.status != "open": + flash("That session is closed. Reopen it before adding events.", "error") + elif append_form["event_type"] not in {event_type for event_type, _ in LEAGUE_EVENT_TYPES}: + flash("Select a valid event type.", "error") + else: + amount_cents = parse_amount_cents(append_form["amount"]) + if amount_cents is None: + flash("Amount must be a number.", "error") + elif amount_cents <= 0 and append_form["event_type"] != "note": + flash("Amount must be greater than zero.", "error") + else: + player_id = append_form["player_id"] or None + if append_form["event_type"] != "note" and player_id is None: + flash("Select a player for this event.", "error") + else: + if player_id is not None and not any(player.id == player_id for player in players): + abort(404) + + append_ledger_event( + league.id, + session.id, + append_form["event_type"], + amount_cents, + current_user_id() or "", + player_id=player_id, + note=append_form["note"], + ) + db.session.commit() + flash("Ledger event added.", "success") + return redirect( + url_for( + "leagues.session_detail", + **league_url_values(league), + session_id=session.id, + ) + ) + + rows = list_event_rows_for_session(league.id, session.id) + summaries = build_session_summaries(rows) + summary = summaries[0] if summaries else empty_session_summary(session) + all_rows = list_all_event_rows_for_session(league.id, session.id) + + return render_template( + "league_session_detail.html", + league=league, + session_model=session, + session=summary, + players=players, + raw_events=all_rows, + event_types=LEAGUE_EVENT_TYPES, + append_form=append_form, + can_manage=can_manage, + is_owner=is_owner, + session_label=session_label, + ) + + +@leagues_bp.get("/l//sessions//view") +def session_public_view(league_ref: str, session_id: str): + if not db_ready(): + 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 + + _slug, public_key = split_league_ref(league_ref) + league = find_league_by_public_key(public_key) + if league is None: + abort(404) + + if league.visibility != "public": + user_id = current_user_id() + if not user_id: + return redirect(url_for("account.login", next=request.path)) + if not user_has_league_role(user_id, league.id, {"owner", "manager", "viewer"}): + abort(403) + + session = find_session_for_league(league.id, session_id) + if session is None: + abort(404) + + rows = list_event_rows_for_session(league.id, session.id) + summaries = build_session_summaries(rows) + summary = summaries[0] if summaries else empty_session_summary(session) + + all_sessions = sorted( + build_session_summaries(list_event_rows_for_league(league.id)), + key=session_sort_key, + ) + session_ref = f"{session.session_date.isoformat()}-{session.sequence_on_date:02d}" + chrono_idx = next((i for i, s in enumerate(all_sessions) if s.session_id == session_ref), 0) + session_number = chrono_idx + 1 + + all_db_sessions = list_sessions_for_league(league.id) + ref_to_db_id = { + f"{s.session_date.isoformat()}-{s.sequence_on_date:02d}": s.id + for s in all_db_sessions + } + + prev_summary = all_sessions[chrono_idx - 1] if chrono_idx > 0 else None + next_summary = all_sessions[chrono_idx + 1] if chrono_idx < len(all_sessions) - 1 else None + + return render_template( + "league_session_view.html", + league=league, + session_model=session, + session=summary, + session_number=session_number, + raw_events=session_events(rows, summary.session_id), + chart_data=session_breakdown_series(summary), + session_label=session_label, + prev_session_id=ref_to_db_id.get(prev_summary.session_id) if prev_summary else None, + next_session_id=ref_to_db_id.get(next_summary.session_id) if next_summary else None, + is_owner=False, + ) + + +@leagues_bp.post("/l//sessions//open") +@login_required +def open_league_session(league_ref: str, session_id: str): + return update_session_status(league_ref, session_id, "open", "Session opened.") + + +@leagues_bp.post("/l//sessions//close") +@login_required +def close_league_session(league_ref: str, session_id: str): + return update_session_status(league_ref, session_id, "closed", "Session closed.") + + +def update_session_status(league_ref: str, session_id: str, status: str, message: str): + if not db_ready(): + 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 + + league = require_league(league_ref, {"owner", "manager"}) + session = find_session_for_league(league.id, session_id) + if session is None: + abort(404) + + if session.status == status: + flash(f"Session is already {status}.", "success") + return redirect(url_for("leagues.sessions", **league_url_values(league))) + + set_session_status(session, status) + append_ledger_event( + league.id, + session.id, + "session_open" if status == "open" else "session_close", + 0, + current_user_id() or "", + note=message, + ) + db.session.commit() + flash(message, "success") + return redirect(url_for("leagues.sessions", **league_url_values(league))) + + +@leagues_bp.route("/l//settings", methods=["GET", "POST"]) +@login_required +def league_settings(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + league = require_league(league_ref, {"owner"}) + + form = { + "name": league.name, + "description": league.description or "", + "visibility": league.visibility, + } + + if request.method == "POST": + form = { + "name": request.form.get("name", "").strip(), + "description": request.form.get("description", "").strip(), + "visibility": request.form.get("visibility", "private").strip(), + } + if len(form["name"]) < 2: + flash("League name must be at least 2 characters.", "error") + elif form["visibility"] not in ("private", "public"): + flash("Invalid visibility value.", "error") + else: + from utils import slugify + + league.name = form["name"] + league.slug = slugify(form["name"]) + league.description = form["description"] or None + league.visibility = form["visibility"] + db.session.commit() + 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 + + members = list_members_for_league(league.id) + return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) + + +@leagues_bp.post("/l//settings/invite") +@login_required +def invite_member(league_ref: str): + if not db_ready(): + 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 + + league = require_league(league_ref, {"owner"}) + 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("leagues.league_settings", league_ref=league.url_ref)) + + if role not in ("manager", "viewer"): + flash("Invalid role.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + 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("leagues.league_settings", league_ref=league.url_ref)) + + 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("Failed to send invitation email. Check your mail configuration.", "error") + + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + +@leagues_bp.post("/l//settings/members//remove") +@login_required +def remove_member(league_ref: str, user_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import remove_league_member + + league = require_league(league_ref, {"owner"}) + + if user_id == current_user_id(): + flash("You cannot remove yourself from the league.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + remove_league_member(league.id, user_id) + db.session.commit() + flash("Member removed.", "success") + return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) + + +@leagues_bp.post("/l//archive") +@login_required +def archive_league(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from db_models import utc_now + + league = require_league(league_ref, {"owner"}) + confirm_name = request.form.get("confirm_name", "").strip() + + if confirm_name != league.name: + flash("League name did not match. Archive cancelled.", "error") + return redirect(url_for("leagues.league_settings", league_ref=league_ref)) + + league.archived_at = utc_now() + db.session.commit() + flash(f'"{league.name}" has been archived.', "success") + return redirect(url_for("leagues.index")) + + +@leagues_bp.get("/l//ledger/export") +@login_required +def export_ledger_csv(league_ref: str): + if not db_ready(): + 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 + + league = require_league(league_ref, {"owner", "manager", "viewer"}) + rows = list_event_rows_for_league(league.id) + + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=CSV_HEADERS) + writer.writeheader() + for row in rows: + writer.writerow({k: row[k] for k in CSV_HEADERS}) + + filename = f"ledger_{league.slug}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" + response = make_response(buf.getvalue()) + response.headers["Content-Type"] = "text/csv; charset=utf-8" + response.headers["Content-Disposition"] = f"attachment; filename={filename}" + return response + + +@leagues_bp.post("/l//ledger/import") +@login_required +def import_ledger_csv(league_ref: str): + if not db_ready(): + 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 ( + 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 + + league = require_league(league_ref, {"owner", "manager"}) + + uploaded = request.files.get("csv_file") + if uploaded is None or uploaded.filename == "": + flash("Choose a CSV file to import.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + if not uploaded.filename.lower().endswith(".csv"): + flash("Only CSV files are accepted.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + try: + text = uploaded.stream.read().decode("utf-8-sig") + except UnicodeDecodeError: + flash("CSV must be valid UTF-8.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + lines = text.splitlines() + if not lines: + flash("CSV file is empty.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + try: + header = next(csv.reader(lines)) + except Exception: + flash("Could not parse CSV header.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + if header != CSV_HEADERS: + flash(f"CSV header mismatch. Expected: {', '.join(CSV_HEADERS)}", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + sessions = list_sessions_for_league(league.id) + session_by_ref = {session_event_ref(s): s for s in sessions} + + players = list_players_for_league(league.id) + player_by_name = {p.display_name.strip().casefold(): p for p in players} + + existing_legacy_ids = { + e.legacy_event_id + for e in list_ledger_events_for_league(league.id) + if e.legacy_event_id + } + + errors: list[str] = [] + queued: list[dict] = [] + skipped = 0 + + for row_num, row in enumerate(csv.DictReader(lines), start=2): + event_id = row.get("id", "").strip() + if event_id and event_id in existing_legacy_ids: + skipped += 1 + continue + + raw_type = row.get("event_type", "").strip() + canonical = canonical_event_type(raw_type) + if canonical not in CANONICAL_EVENT_TYPES: + errors.append(f"Row {row_num}: unknown event_type '{raw_type}'") + continue + + try: + amount_cents = int(row.get("amount_cents", "0")) + except ValueError: + errors.append(f"Row {row_num}: invalid amount_cents '{row.get('amount_cents')}'") + continue + + session_ref = row.get("session_id", "").strip() + session = session_by_ref.get(session_ref) + if session is None: + errors.append(f"Row {row_num}: session '{session_ref}' not found in this league") + continue + + player_name = row.get("player_name", "").strip() + player_id = None + if canonical not in {"note", "session_open", "session_close"}: + player = player_by_name.get(player_name.casefold()) + if player is None: + errors.append(f"Row {row_num}: player '{player_name}' not found in this league") + continue + player_id = player.id + + queued.append({ + "session_id": session.id, + "event_type": canonical, + "amount_cents": amount_cents, + "player_id": player_id, + "note": row.get("note", "").strip() or None, + "legacy_event_id": event_id or None, + "legacy_player_name": player_name or None, + }) + + if errors: + for msg in errors[:5]: + flash(msg, "error") + if len(errors) > 5: + flash(f"… and {len(errors) - 5} more error(s). Nothing was imported.", "error") + return redirect(url_for("leagues.ledger", **league_url_values(league))) + + for ev in queued: + append_ledger_event( + league.id, + ev["session_id"], + ev["event_type"], + ev["amount_cents"], + current_user_id() or "", + player_id=ev["player_id"], + note=ev["note"], + legacy_event_id=ev["legacy_event_id"], + legacy_player_name=ev["legacy_player_name"], + ) + + db.session.commit() + s_count = f"{len(queued)} event{'s' if len(queued) != 1 else ''}" + s_skip = f"{skipped} duplicate{'s' if skipped != 1 else ''}" + flash(f"Imported {s_count}, skipped {s_skip}.", "success") + return redirect(url_for("leagues.ledger", **league_url_values(league))) diff --git a/routes/public.py b/routes/public.py index 93bef28..c2371be 100644 --- a/routes/public.py +++ b/routes/public.py @@ -20,7 +20,24 @@ public_bp = Blueprint("public", __name__) @public_bp.get("/") def home(): - return redirect(url_for("public.leaderboard")) + return render_template("landing.html") + + +@public_bp.get("/help") +def help(): + return render_template("docs.html") + + +@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 + + q = request.args.get("q", "").strip() + leagues = list_public_leagues(q) if database_extensions_available() else [] + counts = {league.id: league_counts(league.id) for league in leagues} + return render_template("explore.html", leagues=leagues, counts=counts, q=q) @public_bp.get("/leaderboard") diff --git a/static/css/style.css b/static/css/style.css index c57957e..6d36ff5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -13,6 +13,8 @@ body { width: 100%; min-width: 0; min-height: 100vh; + display: flex; + flex-direction: column; font-family: var(--font-ui); font-size: 15px; line-height: 1.5; @@ -40,6 +42,63 @@ h1, h2, h3, h4 { margin: 0; } --font-money: var(--font-ui); } +/* ================================================================ + SUBNAV + ================================================================ */ +.subnav { + position: sticky; top: 62px; z-index: 40; + background: rgba(15,15,18,.92); + backdrop-filter: blur(6px); + border-bottom: 1px solid var(--border); +} +.subnav__inner { + max-width: var(--w-admin); + margin: 0 auto; + padding: 0 var(--gutter); + height: 44px; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; +} +.subnav__inner::-webkit-scrollbar { display: none; } +.subnav__name { + font-size: .8125rem; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + text-decoration: none; + flex-shrink: 0; +} +.subnav__name:hover { color: var(--accent); } +.subnav__divider { + display: block; + width: 1px; + height: 16px; + background: var(--border); + margin: 0 8px; + flex-shrink: 0; +} +.subnav__nav { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; +} +.subnav__link { + padding: 4px 10px; + font-size: .8125rem; + color: var(--text-muted); + text-decoration: none; + border-radius: 6px; + white-space: nowrap; + transition: color .15s, background .15s; +} +.subnav__link:hover { color: var(--text); background: var(--panel-hover); } +.subnav__link.is-active { color: var(--accent); font-weight: 600; } + /* ================================================================ APP SHELL ================================================================ */ @@ -140,6 +199,7 @@ h1, h2, h3, h4 { margin: 0; } } .navlink:hover { color: var(--text-strong); } .navlink.is-active { color: var(--text-strong); background: var(--field-alt); } +.navlink--mobile-auth { display: none; } .topbar__spacer { flex: 1; } .topbar__season { font-family: var(--font-mono); @@ -151,8 +211,12 @@ h1, h2, h3, h4 { margin: 0; } .topbar__admin-actions { display: flex; align-items: center; gap: 8px; } .page { + flex: 1; + display: flex; + flex-direction: column; margin: 0 auto; padding: clamp(22px,3.4vw,46px) var(--gutter) 96px; + width: 100%; min-width: 0; } .page--public { max-width: var(--w-public); } @@ -166,8 +230,8 @@ h1, h2, h3, h4 { margin: 0; } } } .site-footer { - margin-top: clamp(44px, 6vw, 76px); - padding-top: 18px; + margin-top: auto; + padding-top: clamp(44px, 6vw, 76px); border-top: 1px solid var(--divider); display: grid; grid-template-columns: 1fr auto 1fr; @@ -539,6 +603,27 @@ h3 { } .form-card textarea { resize: vertical; min-height: 92px; } .form-card input::placeholder, .form-card textarea::placeholder { color: var(--faintest-2); } +.session-locked-banner { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + background: var(--field); + border: 1px solid var(--border); + border-left: 3px solid var(--warn); + border-radius: var(--r-sm); + font-size: .8125rem; +} +.session-locked-banner strong { color: var(--warn); } +.session-locked-banner span { color: var(--text-muted); } +fieldset[disabled] input, +fieldset[disabled] select, +fieldset[disabled] textarea, +fieldset[disabled] button { + opacity: .45; + cursor: not-allowed; + pointer-events: none; +} .session-filter-form { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .session-filter-select { @@ -1141,6 +1226,14 @@ td a:hover { color: var(--accent); } .split { display: flex; flex-wrap: wrap; gap: var(--s4); align-items: flex-start; } .split > .col-main { flex: 2; min-width: 360px; } .split > .col-side { flex: 1; min-width: 300px; } +.debt-row { display: flex; flex-direction: column; gap: 8px; padding: 14px 0; border-bottom: 1px solid var(--border); } +.debt-row:last-child { border-bottom: none; } +.debt-row__info { display: flex; flex-direction: column; gap: 2px; } +.debt-row__info strong { font-size: .9375rem; color: var(--text-strong); } +.debt-row__info span { font-size: .8125rem; color: var(--text-muted); } +.debt-row__form { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.debt-row__form input[type=number] { width: 100px; } +.debt-row__form select { flex: 1; min-width: 120px; } .stack { display: grid; gap: 20px; } .inline-action-form { margin: 0; } .button-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } @@ -1653,6 +1746,517 @@ select.control { cursor: pointer; } .form-shell { display: grid; justify-content: center; padding-top: 40px; } .form-shell.narrow { grid-template-columns: minmax(320px, 480px); } +/* Account / league shell */ +.league-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +} +.league-action-grid, +.league-manage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} +.league-manage-grid { + align-items: start; +} +.league-card { + display: grid; + gap: 8px; + padding: 18px; + color: inherit; + transition: border-color 140ms, transform 140ms; +} +.league-card:hover { + border-color: var(--border-hi); + transform: translateY(-1px); +} +.league-card--disabled { + opacity: .72; +} +.league-card--disabled:hover { + border-color: var(--border-strong); + transform: none; +} +.league-card strong { + color: var(--text-strong); + font-size: 18px; +} +.league-card span:last-child { + color: var(--muted); + line-height: 1.45; +} +.league-hub-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.league-board { + display: grid; + gap: 14px; +} +.league-row { + display: grid; + grid-template-columns: minmax(260px, 1.2fr) minmax(420px, 1.6fr) minmax(240px, .9fr); + grid-template-areas: + "identity metrics leaders" + "actions actions leaders"; + align-items: stretch; + overflow: hidden; + border-top: 2px solid var(--accent); +} +.league-row__identity { + grid-area: identity; + display: grid; + align-content: start; + gap: 10px; + padding: 20px; + border-right: 1px solid var(--divider); +} +.league-row__chips { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.league-row__title { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + color: inherit; + text-decoration: none; +} +.league-row__title h2 { + margin: 4px 0 0; + color: var(--text-strong); + font-size: 24px; + transition: color 140ms; +} +.league-row__title:hover h2 { + color: var(--accent); +} +.league-row__desc { + margin: 0; + color: var(--muted); + line-height: 1.45; +} +.league-row__meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: var(--faintest); + font-size: 12.5px; +} +.league-row__metrics { + grid-area: metrics; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + background: var(--divider); + border-right: 1px solid var(--divider); +} +.league-row__metrics div { + min-width: 0; + display: grid; + align-content: center; + gap: 6px; + padding: 18px 14px; + background: var(--surface); +} +.league-row__metrics span { + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.league-row__metrics strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 16px; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.league-row__leaders { + grid-area: leaders; + display: grid; + align-content: start; + gap: 12px; + padding: 18px; + background: var(--surface-subtle); +} +.league-row__section-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.league-row__section-head a { + color: var(--accent); + font-size: 12.5px; + font-weight: 700; + text-decoration: none; +} +.league-row__actions { + grid-area: actions; + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: flex-start; + gap: 8px; + padding: 14px 20px; + border-top: 1px solid var(--divider); + border-right: 1px solid var(--divider); + background: rgba(255,255,255,.018); +} +.league-top-list { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + list-style: none; + background: var(--divider); + border: 1px solid var(--divider); + border-radius: var(--r-sm); + overflow: hidden; +} +.league-top-list li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: var(--surface); +} +.league-top-list span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-strong); +} +.league-top-list strong { + flex: none; + font-family: var(--font-mono); + font-size: 12.5px; + font-variant-numeric: tabular-nums; +} +.league-row__empty { + display: grid; + gap: 6px; + padding: 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--r-sm); + color: var(--muted); +} +.league-row__empty a { + color: var(--accent); + font-size: 13px; + font-weight: 700; + text-decoration: none; +} +.empty-panel { + display: grid; + gap: 12px; + justify-items: start; + padding: 22px; +} +.player-list { + display: grid; +} +.player-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} +.player-row:first-child { + border-top-color: var(--border-strong); +} +.player-row strong { + display: block; + color: var(--text-strong); +} +.player-row span { + color: var(--muted); + font-size: 13px; +} +.player-row__actions { + display: flex; + align-items: center; + gap: 10px; +} +.player-row__actions form { + margin: 0; +} +/* Player card grid */ +.player-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 12px; + padding: 16px; +} +.player-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + text-decoration: none; + transition: border-color 0.15s; +} +.player-card:hover { border-color: var(--accent); } +.player-card--archived { opacity: 0.5; } +.player-card__head { + display: flex; + align-items: center; + gap: 10px; +} +.player-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--surface-2); + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + flex-shrink: 0; + letter-spacing: 0.02em; +} +.player-avatar--pos { background: color-mix(in srgb, var(--pos) 18%, transparent); color: var(--pos); } +.player-avatar--neg { background: color-mix(in srgb, var(--neg) 18%, transparent); color: var(--neg); } +.player-card__name-block { flex: 1; min-width: 0; } +.player-card__name { + font-weight: 600; + color: var(--text); + text-decoration: none; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.player-card__name:hover { color: var(--accent); } +.player-card__sub { font-size: 12px; color: var(--muted); display: block; margin-top: 1px; } +.player-card__stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; + border-top: 1px solid var(--border); + padding-top: 12px; +} +.player-card__stat { + display: flex; + flex-direction: column; + gap: 2px; +} +.player-card__stat .kicker { font-size: 9px; } +.player-card__stat-val { font-size: 13px; font-weight: 600; color: var(--text); } +/* Danger zone row (description left, action button right) */ +.danger-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} +.danger-row__title { + display: block; + color: var(--text-strong); + margin-bottom: 4px; +} + +/* Danger button */ +.btn--danger { + background: var(--neg); + color: #fff; +} +.btn--danger:hover { opacity: .88; } +.btn--danger:disabled { opacity: .4; cursor: not-allowed; } + +/* Session date block (used in session list rows) */ +.session-date-block { + flex: none; + display: grid; + text-align: center; + padding: 6px 10px; + background: var(--surface-sunk); + border: 1px solid var(--border); + border-radius: 8px; + min-width: 48px; +} +.session-date-block__mon { + display: block; + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--faintest); + line-height: 1; +} +.session-date-block__day { + display: block; + font-family: var(--font-display); + font-size: 20px; + font-weight: 700; + color: var(--text-strong); + line-height: 1.1; +} + +/* Session create form — horizontal 3-col grid */ +.session-create-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 14px; +} +@media (max-width: 860px) { + .session-create-row { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 540px) { + .session-create-row { + grid-template-columns: 1fr; + } +} + +/* Optional field hint inside label */ +.form-opt-hint { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: .06em; + color: var(--faintest-2); + text-transform: none; + font-weight: 400; +} + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 4px 9px; + border-radius: var(--r-pill); + border: 1px solid var(--border-strong); + color: var(--muted); + font: 11px var(--font-mono); + text-transform: uppercase; +} +.status-pill--active { + border-color: var(--pos-tint-bd); + color: var(--pos); + background: var(--pos-tint); +} +.status-pill--open { + border-color: var(--pos-tint-bd); + color: var(--pos); + background: var(--pos-tint); +} +.status-pill--closed, +.status-pill--archived { + border-color: var(--border-strong); + color: var(--faint); + background: var(--field); +} + +/* Session list row with left accent */ +.session-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + border-left: 3px solid transparent; +} +.session-row:last-child { border-bottom: none; } +.session-row--open { border-left-color: var(--pos); } +.session-row--closed { border-left-color: var(--border-strong); } +.session-row__info { display: flex; align-items: center; gap: 14px; min-width: 0; } +.session-row__text { min-width: 0; } +.session-row__title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.session-row__title a { color: var(--text-strong); text-decoration: none; } +.session-row__title a:hover { color: var(--accent); } +.session-row__meta { font-size: 12px; color: var(--muted); margin-top: 2px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.session-row__stat { display: inline-flex; align-items: center; gap: 4px; } +.session-row__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.session-row__actions form { margin: 0; } + +/* Session detail — full-width player ledger rows */ +.player-ledger-list { + display: flex; + flex-direction: column; + max-height: 480px; + overflow-y: auto; + border-radius: 0 0 var(--r-lg) var(--r-lg); +} +.player-ledger-row { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + border-left: 3px solid var(--border-strong); + display: flex; + flex-direction: column; + gap: 10px; +} +.player-ledger-row:last-child { border-bottom: none; } +.player-ledger-row--pos { border-left-color: var(--pos); } +.player-ledger-row--neg { border-left-color: var(--neg); } +.player-ledger-row__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.player-ledger-row__name { font-size: 15px; font-weight: 700; color: var(--text-strong); } +.player-ledger-row__fields { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--border); + border-radius: var(--r-sm); + overflow: hidden; +} +.player-ledger-row__fields--secondary { + grid-template-columns: repeat(3, 1fr); + background: var(--surface-sunk); +} +.player-ledger-row__field { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 3px; + border-right: 1px solid var(--border); + background: var(--surface); +} +.player-ledger-row__fields--secondary .player-ledger-row__field { + background: var(--surface-sunk); +} +.player-ledger-row__field:last-child { border-right: none; } +.player-ledger-row__field--net { background: var(--surface-subtle); } +.player-ledger-row__lbl { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--faintest); + font-family: var(--font-mono); + white-space: nowrap; +} +.player-ledger-row__val { + font-size: 14px; + font-weight: 600; + color: var(--text); + font-variant-numeric: tabular-nums; +} +@media (max-width: 600px) { + .player-ledger-row__fields { grid-template-columns: repeat(2, 1fr); } + .player-ledger-row__field { border-bottom: 1px solid var(--border); } + .player-ledger-row__fields--secondary { grid-template-columns: repeat(2, 1fr); } +} + /* Modal */ .modal-backdrop { position: fixed; @@ -1896,6 +2500,9 @@ select.control { cursor: pointer; } height: 38px; padding-inline: 11px; } + .topbar__account-actions { + display: none; + } } /* Toolbar: stack left/right sections vertically on narrow viewports */ @@ -1943,3 +2550,805 @@ select.control { cursor: pointer; } .seg { padding: 6px 10px; font-size: 12px; } .hero__title, h1 { font-size: 24px; } } + +/* ================================================================ + LEAGUE DASHBOARD + ================================================================ */ + +/* Hero */ +.db-hero { + position: relative; + overflow: hidden; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 28px 0 26px; + flex-wrap: wrap; +} +.db-hero__bg-letter { + position: absolute; + right: -12px; + top: -24px; + font-family: var(--font-display); + font-size: 210px; + font-weight: 800; + line-height: 1; + color: var(--surface-raised); + user-select: none; + pointer-events: none; + z-index: 0; + letter-spacing: -6px; +} +.db-hero__body { + position: relative; + z-index: 1; + flex: 1; + min-width: 0; +} +.db-hero__actions { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: none; + padding-top: 6px; +} +.db-hero__chips { + display: flex; + gap: 6px; + margin-bottom: 12px; +} +.db-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: var(--r-pill); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: .08em; + text-transform: uppercase; + font-weight: 500; + background: var(--surface-raised); + border: 1px solid var(--border-strong); + color: var(--muted); +} +.db-chip--accent { + background: var(--accent-chip); + border-color: var(--accent-chip-bd); + color: var(--accent); +} +.db-chip--public { + background: var(--pos-tint); + border-color: var(--pos-tint-bd); + color: var(--pos); +} + +.league-back-link { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 13px; + font-weight: 600; + color: var(--muted); + text-decoration: none; + margin-bottom: 4px; + transition: color 120ms; +} +.league-back-link:hover { color: var(--accent); } + +/* Live session banner */ +.db-live-bar { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 18px; + background: var(--pos-tint); + border: 1px solid var(--pos-tint-bd); + border-radius: var(--r-lg); + margin-bottom: 16px; + flex-wrap: wrap; +} +.db-live-bar__text { + flex: 1; + min-width: 0; + color: var(--pos); + font-size: 13.5px; + font-weight: 500; +} +.db-live-bar__btn { + background: var(--pos); + color: #0f0f12; + font-weight: 700; + flex: none; +} +.db-live-bar__btn:hover { opacity: .88; } + +/* Stats row — uses existing stat + stat--rail */ +.db-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--s3); + margin-bottom: 20px; +} + +/* Navigation card grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.dashboard-card { + position: relative; + display: grid; + grid-template-rows: auto auto 1fr auto; + gap: 8px; + padding: 20px; + padding-top: 22px; + color: inherit; + text-decoration: none; + overflow: hidden; + transition: border-color 140ms, transform 140ms, box-shadow 140ms; + min-height: 152px; +} +.dashboard-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--card-accent, var(--border-strong)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + transition: opacity 140ms; +} +.dashboard-card:hover { + border-color: var(--border-hi); + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(0,0,0,.22); +} +.dashboard-card--live { + border-color: var(--pos-tint-bd); + background: linear-gradient(160deg, rgba(111,192,147,.07) 0%, transparent 55%), var(--surface); +} +.dashboard-card--live:hover { border-color: var(--pos); } +.dashboard-card__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.dashboard-card__top .kicker { margin: 0; } +.dashboard-card__count { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--faintest); +} +.dashboard-card__title { + display: block; + font-size: 21px; + font-weight: 700; + color: var(--text-strong); +} +.dashboard-card__desc { + margin: 0; + color: var(--muted); + font-size: 13.5px; + line-height: 1.45; +} +.dashboard-card__arrow { + font-size: 18px; + color: var(--faintest); + justify-self: end; + transition: color 140ms, transform 140ms; +} +.dashboard-card:hover .dashboard-card__arrow { + color: var(--card-accent, var(--accent)); + transform: translateX(3px); +} + +@media (max-width: 640px) { + .db-stats { grid-template-columns: 1fr 1fr; } + .dashboard-grid { grid-template-columns: 1fr; } + .db-hero { flex-direction: column; gap: 14px; } + .db-hero__bg-letter { font-size: 140px; } +} + +/* Landing page */ +.l-bg { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(ellipse 70% 55% at 12% 18%, rgba(155,140,240,.11) 0%, transparent 65%), + radial-gradient(ellipse 50% 45% at 88% 78%, rgba(111,192,147,.07) 0%, transparent 55%), + radial-gradient(ellipse 55% 50% at 68% -8%, rgba(155,140,240,.06) 0%, transparent 60%); +} +.l-bg::after { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgba(255,255,255,.032) 1px, transparent 1px); + background-size: 28px 28px; + mask-image: radial-gradient(ellipse 90% 90% at 50% 40%, black 30%, transparent 100%); +} + +.l-hero { + padding: clamp(52px, 8vw, 96px) 0 clamp(36px, 5vw, 52px); + max-width: 700px; +} +.l-hero__kicker { + display: inline-block; + padding: 4px 12px; + border-radius: var(--r-pill); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .10em; + text-transform: uppercase; + font-weight: 500; + margin-bottom: 22px; +} +.l-hero__h1 { + font-size: clamp(36px, 5.8vw, 66px); + font-weight: 700; + line-height: 1.04; + letter-spacing: -.02em; + color: var(--text); + margin: 0 0 18px; +} +.l-hero__sub { + font-size: clamp(15px, 1.8vw, 17px); + color: var(--muted); + line-height: 1.65; + margin: 0 0 28px; + max-width: 54ch; +} +.l-hero__cta { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Preview strip */ +.l-preview { + display: grid; + grid-template-columns: 1fr 1.25fr 1fr; + gap: 10px; + margin-bottom: clamp(48px, 7vw, 76px); + align-items: start; +} +.l-preview__panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; +} +.l-preview__panel--featured { + border-color: var(--accent-chip-bd); + box-shadow: 0 0 0 1px var(--accent-chip-bd), 0 8px 32px rgba(155,140,240,.10); +} +.l-preview__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 14px; + border-bottom: 1px solid var(--divider); + background: var(--surface-head); +} +.l-preview__panel--featured .l-preview__head { + background: var(--accent-tint); + border-bottom-color: var(--accent-chip-bd); +} +.l-preview__tag { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: .08em; + color: var(--faintest); + text-transform: uppercase; +} +.l-preview__rows { + display: grid; +} +.l-preview__row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--divider); + font-size: 13px; +} +.l-preview__row:last-child { border-bottom: none; } +.l-preview__row--faded { opacity: .4; } +.l-preview__rank { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--faintest); + min-width: 12px; +} +.l-preview__player { + flex: 1; + font-weight: 600; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.l-preview__val { + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.l-preview__val--pos { color: var(--pos); } +.l-preview__val--neg { color: var(--neg); } +.l-preview__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background: var(--divider); + border-bottom: 1px solid var(--divider); +} +.l-preview__stat { + padding: 12px 12px; + background: var(--surface); + display: grid; + gap: 5px; +} +.l-preview__panel--featured .l-preview__stat { + background: color-mix(in srgb, var(--accent) 4%, var(--surface)); +} +.l-preview__stat strong { + display: block; + font-family: var(--font-mono); + font-size: 15px; + font-variant-numeric: tabular-nums; + color: var(--text-strong); +} +.l-preview__evts { + display: grid; + gap: 0; +} +.l-preview__evt { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--divider); + font-size: 12px; +} +.l-preview__evt:last-child { border-bottom: none; } +.l-preview__evt-name { + flex: 1; + color: var(--text-2); + font-weight: 600; + font-size: 12px; +} +.l-preview__evt-val { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--num); +} + +/* Feature grid */ +.l-features { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + margin-bottom: clamp(40px, 6vw, 64px); +} +.l-feature { + background: var(--surface); + padding: 26px 22px; + display: grid; + align-content: start; + gap: 10px; +} +.l-feature__icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: var(--r-sm); + background: var(--accent-chip); + border: 1px solid var(--accent-chip-bd); + color: var(--accent); + margin-bottom: 4px; +} +.l-feature__title { + font-size: 15px; + font-weight: 600; + color: var(--text-strong); + margin: 0; +} +.l-feature__desc { + font-size: 13.5px; + color: var(--muted); + line-height: 1.6; + margin: 0; +} + +/* Explore page */ +.explore-search { + display: flex; + gap: 8px; + max-width: 480px; + margin-bottom: 24px; +} +.explore-search__input { + flex: 1; + width: auto; +} +.explore-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.explore-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: var(--r-lg); + background: var(--surface); + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} +.explore-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 12px color-mix(in srgb, var(--accent) 10%, transparent); +} +.explore-card__head { display: flex; flex-direction: column; gap: 4px; } +.explore-card__name { font-size: 16px; font-weight: 700; color: var(--text-strong); margin: 0; } +.explore-card__desc { font-size: 13px; color: var(--muted); margin: 0; line-height: 1.4; } +.explore-card__stats { + display: flex; + gap: 16px; + padding: 10px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.explore-card__stat { display: flex; flex-direction: column; gap: 2px; } +.explore-card__stat strong { font-size: 18px; font-weight: 700; color: var(--text-strong); } +.explore-card__footer { margin-top: auto; } +.explore-card__link { font-size: 13px; color: var(--accent); font-weight: 500; } + +/* Info tip */ +.info-tip { + position: relative; + display: inline-flex; + align-items: center; + margin-left: 5px; + color: var(--faintest-2); + cursor: default; + vertical-align: middle; + transition: color 120ms; +} +.info-tip:hover, +.info-tip:focus, +.info-tip.is-open { color: var(--accent); outline: none; } +.info-tip__bubble { + position: fixed; + width: max-content; + max-width: 260px; + padding: 9px 13px; + background: var(--surface-head); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + box-shadow: 0 8px 24px rgba(0,0,0,.35); + font: 400 12.5px/1.5 var(--font-ui); + color: var(--text-2); + text-transform: none; + letter-spacing: 0; + white-space: normal; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 120ms; + z-index: 1000; +} +.info-tip.is-open .info-tip__bubble { opacity: 1; visibility: visible; } + +/* Docs / help page */ +.docs-grid { + display: grid; + grid-template-columns: 200px 1fr; + gap: 48px; + align-items: start; + padding-top: 12px; +} +.docs-nav { + position: sticky; + top: 80px; + display: grid; + gap: 4px; +} +.docs-nav .kicker { margin-bottom: 6px; } +.docs-nav a { + display: block; + font-size: 13.5px; + color: var(--muted); + text-decoration: none; + padding: 4px 0; + border-left: 2px solid var(--divider); + padding-left: 12px; + transition: color 120ms, border-color 120ms; +} +.docs-nav a:hover { color: var(--accent); border-left-color: var(--accent); } +.docs-body { + display: grid; + gap: 52px; + padding-bottom: 80px; +} +.docs-section { display: grid; gap: 14px; } +.docs-h2 { + font-size: 22px; + font-weight: 700; + color: var(--text-strong); + margin: 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--divider); +} +.docs-section p { + margin: 0; + color: var(--muted); + font-size: 14.5px; + line-height: 1.7; +} +.docs-section p strong { color: var(--text-2); } +.docs-callout { + padding: 12px 16px; + background: var(--accent-tint); + border: 1px solid var(--accent-chip-bd); + border-left: 3px solid var(--accent); + border-radius: var(--r-sm); + font-size: 13.5px; + color: var(--text-2); + line-height: 1.6; +} +.docs-callout strong { color: var(--accent); } +.docs-term-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} +.docs-term { + display: grid; + gap: 4px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.docs-term strong { + font-size: 13px; + color: var(--text-strong); +} +.docs-term span { + font-size: 12.5px; + color: var(--muted); + line-height: 1.5; +} +.docs-events { + display: grid; + gap: 12px; +} +.docs-event { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} +.docs-event .pill { flex: none; margin-top: 2px; } +.docs-event div { display: grid; gap: 4px; } +.docs-event strong { font-size: 13.5px; color: var(--text-strong); } +.docs-event p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.55; } +@media (max-width: 760px) { + .docs-grid { + grid-template-columns: 1fr; + gap: 24px; + } + .docs-nav { + position: static; + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .docs-nav .kicker { width: 100%; } + .docs-nav a { + border-left: none; + padding-left: 0; + border-bottom: 2px solid var(--divider); + padding-bottom: 4px; + } +} + +@media (max-width: 900px) { + .l-preview { + grid-template-columns: 1fr; + } + .l-preview__panel:not(.l-preview__panel--featured) { + display: none; + } + .l-features { + grid-template-columns: 1fr 1fr; + } + .league-row { + grid-template-columns: 1fr; + grid-template-areas: + "identity" + "metrics" + "leaders" + "actions"; + } + .league-row__identity, + .league-row__metrics, + .league-row__actions { + border-right: 0; + } + .league-row__identity, + .league-row__metrics, + .league-row__leaders { + border-bottom: 1px solid var(--divider); + } +} + +@media (max-width: 560px) { + .l-features { + grid-template-columns: 1fr; + } + .league-hub-summary, + .league-row__metrics { + grid-template-columns: 1fr 1fr; + } + .league-row__title { + display: grid; + } + .league-row__actions .btn { + flex: none; + } +} + +/* ── Audit page ─────────────────────────────────────────────────── */ +.audit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--s3); + margin-top: 24px; +} + +.audit-alert { padding: 0; } +.audit-alert .panel__head { padding: 14px 20px; } + +.audit-item-list { display: flex; flex-direction: column; } + +.audit-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-top: 1px solid var(--divider); +} +.audit-item:first-child { border-top: none; } + +.audit-item > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.audit-item__right { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +/* ── Void events ────────────────────────────────────────────────── */ +.event-card--voided { + opacity: 0.45; +} +.event-card--voided .event-amount, +.event-card--voided strong { + text-decoration: line-through; +} + +.void-badge { + font: 600 10px/1 var(--font-ui); + text-transform: uppercase; + letter-spacing: .04em; + color: var(--neg); + background: color-mix(in srgb, var(--neg) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--neg) 30%, transparent); + border-radius: var(--r-sm); + padding: 2px 6px; +} + +.void-reason { color: var(--neg); font-style: italic; } + +.btn-void-toggle { + all: unset; + cursor: pointer; + display: block; + font: 400 10px/1 var(--font-ui); + letter-spacing: .04em; + color: var(--text-3); + margin-top: 6px; + opacity: 0; + transition: opacity 150ms, color 150ms; +} +.event-card:hover .btn-void-toggle { opacity: 0.3; } +.btn-void-toggle:hover { opacity: 1 !important; color: var(--neg); } + + +.member-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid var(--border); +} +.member-row:last-child { border-bottom: none; } +.member-row > div { min-width: 0; } +.member-row strong { font: 600 14px/1.4 var(--font-ui); color: var(--text-1); } + +.card-menu { position: relative; flex-shrink: 0; } +.card-menu__trigger { + all: unset; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--r-sm); + color: var(--faint); + font-size: 15px; + font-weight: 700; + letter-spacing: 3px; + line-height: 1; + transition: color 120ms, background 120ms; +} +.card-menu__trigger:hover { color: var(--text-2); background: var(--surface-raised); } +.card-menu__dropdown { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 200; + background: var(--surface-raised); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + min-width: 136px; + padding: 4px; + box-shadow: 0 8px 24px rgba(0,0,0,.55); +} +.card-menu__item { + all: unset; + cursor: pointer; + display: block; + width: 100%; + padding: 8px 12px; + font: 400 13px/1 var(--font-ui); + color: var(--text-2); + border-radius: calc(var(--r-sm) - 3px); + transition: background 100ms, color 100ms; + box-sizing: border-box; +} +.card-menu__item:hover { background: var(--field); color: var(--text); } +.card-menu__item--warn { color: var(--neg); } +.card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); } diff --git a/storage.py b/storage.py index 0cc324a..341aa36 100644 --- a/storage.py +++ b/storage.py @@ -30,6 +30,8 @@ class EventRow(TypedDict): amount_cents: int note: str actor: str + voided_at: str + void_reason: str VALID_EVENT_TYPES = { @@ -79,6 +81,8 @@ def load_events(csv_path: Path) -> list[EventRow]: amount_cents=int(row["amount_cents"] or 0), note=row.get("note", ""), actor=row.get("actor", ""), + voided_at="", + void_reason="", ) ) diff --git a/templates/_league_subnav.html b/templates/_league_subnav.html new file mode 100644 index 0000000..38380b0 --- /dev/null +++ b/templates/_league_subnav.html @@ -0,0 +1,18 @@ + diff --git a/templates/account_login.html b/templates/account_login.html new file mode 100644 index 0000000..da8ddb8 --- /dev/null +++ b/templates/account_login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Login · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Owner access

+

Account login

+

Manage leagues, players, sessions, and ledger entries.

+
+ + + + Create account + Forgot password? +
+
+ +{% endblock %} diff --git a/templates/account_register.html b/templates/account_register.html new file mode 100644 index 0000000..d0c65b3 --- /dev/null +++ b/templates/account_register.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Create Account · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Owner account

+

Create account

+

Accounts are for league owners and managers.

+
+ + + + + Login instead +
+
+ +{% endblock %} diff --git a/templates/account_settings.html b/templates/account_settings.html new file mode 100644 index 0000000..8bbe18d --- /dev/null +++ b/templates/account_settings.html @@ -0,0 +1,164 @@ +{% extends "base.html" %} +{% block title %}Account settings · myboker.org{% endblock %} +{% block content %} + +
+

Account

+

Settings

+

Manage your email address, password, and account.

+
+ +
+ +
+ +
+ +
+ +
+

Profile

+

Email address

+
+ + +
+ +
+
+ +
+ +
+

Security

+

Change password

+
+ + + +
+ +
+
+ +
+
+
+

Danger zone

+

Destructive actions

+
+
+
+
+
+ Delete account +

Permanently disables your account and archives all leagues you own. This cannot be undone.

+
+ +
+
+
+ +
+ + + + + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 314cc60..1cb7909 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,9 +10,10 @@ +
- + myboker.org @@ -22,25 +23,35 @@ - {% if is_admin %} + {% if is_logged_in %}
-
+ +
+ {% else %} + {% endif %}
- + {% block subnav %}{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -57,7 +68,7 @@
© 2026 myboker.org Version {{ app_version }} - cedarline.digital + Help · cedarline.digital
+ diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..d6af72a --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,222 @@ +{% extends "base.html" %} +{% block title %}Help · myboker.org{% endblock %} +{% block content %} + +
+

Documentation

+

How myboker works

+

Everything you need to run a home poker league — sessions, ledgers, leaderboards, and settlement.

+
+ +
+ + + +
+ +
+

Leagues

+

A league is the top-level container for your home game. It holds all your players, sessions, and ledger events in one place. One account can manage multiple leagues — useful if you run separate games (e.g. a Thursday game and a weekend game).

+

When you create a league you give it a name and optional description. A unique public key is automatically generated so you can share a link to it later if you make it public.

+
+ Tip: Leagues are long-lived. You don't need to create a new league every season — use sessions to separate individual nights of play. +
+
+ +
+

Players

+

Players are the people in your league. They do not need a myboker account — only the league owner or manager needs one. You just add names.

+

Each player has a display name that appears on the leaderboard and in ledger events. You can add an optional note (like a payment handle) to help with settlement later.

+

Archiving a player hides them from the active roster but preserves all their history. You can reactivate them at any time.

+
+
+ Active + Shows on the roster and is available to record events against. +
+
+ Archived + Hidden from the roster. History and stats are preserved. Reactivate any time. +
+
+
+ +
+

Sessions

+

A session is a single night of poker. Sessions are date-stamped and sequenced — if you play twice on the same day, they become S1 and S2 automatically.

+

Sessions have two states: open and closed. While open, you can record buy-ins, cashouts, and other ledger events. When everyone has settled up and cashed out, you close the session. Closing doesn't delete anything — you can always reopen it.

+
+
+ Date + The calendar date the game was played. Used to order sessions and group same-day games. +
+
+ Label + Optional short name for this session (e.g. "Main table", "Side game"). Shown in lists. +
+
+ Notes + Optional freeform context visible in the session list (e.g. "Holiday game", "Marcus's place"). +
+
+ Open + Session is live — events can be added and settlement is still in progress. +
+
+ Closed + Session is settled. Books are considered final. Can be reopened if needed. +
+
+
+ +
+

Ledger events

+

All activity in a session is recorded as ledger events. The ledger is append-only — events are never deleted. This keeps the record clean and auditable, even if you need to correct a mistake (you just add a correcting entry).

+

There are eight event types:

+
+
+ buy-in +
+ A player buys chips with cash. +

Cash comes into the pot. Records how much the player has invested. Most common event — record one every time someone puts money on the table.

+
+
+
+ front +
+ The house covers a player's buy-in. +

When a player doesn't have cash handy, the organizer (house) can front them chips. This creates a debt — the player owes that amount back to the house. Track fronts carefully so you know who owes what at the end of the night.

+
+
+
+ cashout +
+ A player exchanges their chips for their cash value. +

Records the chip count a player walks away with. This is the "you are owed X" event — but it doesn't mean cash has left the house yet. Use paid out to record the actual cash handover.

+
+
+
+ paid out +
+ Cash physically leaves the house to the player. +

Closes the loop on a cashout. Once a player has been paid out, their balance goes to $0. Record this separately from cashout because you may pay people out at different times (end of night, next day via transfer, etc.).

+
+
+
+ rollover in +
+ Chips carried in from a previous session. +

If a player didn't cash out at the end of a session and instead carries their chip stack forward to the next game, record a rollover in at the start of the new session.

+
+
+
+ rollover out +
+ Chips carried out to a future session. +

Recorded when a player leaves a session without cashing out, intending to carry their stack forward. Paired with a rollover in at the next session.

+
+
+
+ debt repayment +
+ A player repays an outstanding front. +

When a player who was fronted chips pays the house back (cash, transfer, etc.), record a debt repayment. This reduces their outstanding balance with the house.

+
+
+
+ write-off +
+ The house forgives a debt. +

If a front won't be collected (player left, debt forgiven, etc.), write it off. The debt is cleared from the open balance without cash changing hands. This is irreversible — add a note explaining why.

+
+
+
+
+ +
+

Settlement

+

The ledger tracks two types of open balances at all times:

+
+
+ Due to players + Cash the house owes to players who have cashed out but haven't been paid yet. Pay these out to clear the balance. +
+
+ Due to house + Cash players owe to the house from unpaid fronts. Collect debt repayments or write these off to clear the balance. +
+
+ Cash in + Total real cash that has come into the pot across all sessions — buy-ins and repaid fronts only, not fronts themselves. +
+
+ Cash paid out + Total real cash that has left the house to players. Ideally this approaches cash in over time. +
+
+
+ Fronts vs buy-ins: A front doesn't add to "cash in" because no cash actually entered the pot — the house covered it. Cash in only counts real money on the table. +
+
+ +
+

Leaderboard & stats

+

The leaderboard ranks players by their all-time performance. Stats are computed from ledger events, not manually entered.

+
+
+ Net + Total cash out minus total cash in across all sessions. Positive means up, negative means down. +
+
+ ROI + Net divided by total invested, expressed as a percentage. Compares performance independent of how much someone plays. +
+
+ Win rate + Percentage of sessions where the player finished with a positive net. Ties (exactly break even) count as a loss. +
+
+ Sessions played + Number of sessions with at least one ledger event for this player. +
+
+ Biggest win + The single session where the player netted the most profit. +
+
+ Biggest loss + The single session where the player lost the most money. +
+
+
+ +
+

Public vs private

+

Each league has a visibility setting you can change in league settings.

+
+
+ Private + Only members (owners and managers) can see this league. The league won't appear on the Explore page. This is the default. +
+
+ Public + Anyone with the link — or who finds it on the Explore page — can view the leaderboard and session history. No account required to view. Members still need an account to manage it. +
+
+
+ Note: Making a league public shares leaderboard rankings and session results, but not private financial details like individual debt balances or account emails. +
+
+ +
+
+ +{% endblock %} diff --git a/templates/explore.html b/templates/explore.html new file mode 100644 index 0000000..f643244 --- /dev/null +++ b/templates/explore.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Explore leagues · myboker.org{% endblock %} +{% block content %} + +
+

Public leagues

+

Explore

+

Browse poker leagues that have made their results publicly visible.

+
+ + + +{% if leagues %} + +{% elif q %} +
+

No results for "{{ q }}"

+

Try a shorter search or browse all leagues.

+
+{% else %} +
+

No public leagues yet

+

League owners can set their league to public in settings.

+
+{% endif %} + +{% endblock %} diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000..2b3f802 --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Forgot Password · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Account recovery

+

Forgot password

+

Enter your email and we'll send a reset link if an account exists.

+
+ + + Back to login +
+
+ +{% endblock %} diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..ff6f705 --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block title %}myboker.org · Poker league records{% endblock %} +{% block content %} + +
+

For private poker leagues

+

Your home game,
properly tracked.

+

Sessions, ledgers, leaderboards, and settlement visibility — built for home groups that want clean records without a payment app or social network in the way.

+
+ {% if is_logged_in %} + Open leagues + {% else %} + Create account + Sign in + Explore league stats + {% endif %} +
+
+ + + +
+
+
+ +
+

League workspaces

+

Each league has its own players, sessions, leaderboard, and accounting. One account can manage multiple leagues independently.

+
+
+
+ +
+

Append-only ledger

+

Buy-ins, fronts, cashouts, rollovers, debt repayments, and write-offs stay permanently on record. Events are never deleted.

+
+
+
+ +
+

Performance analytics

+

Net, ROI, win rate, and all-time cash movement are computed from ledger events. Rankings stay separate from settlement records.

+
+
+
+ +
+

Settlement visibility

+

Open debts, player payables, and house receivables are always tracked. Share settlement status with the group, not just final numbers.

+
+
+ +{% endblock %} diff --git a/templates/league_dashboard.html b/templates/league_dashboard.html new file mode 100644 index 0000000..0638440 --- /dev/null +++ b/templates/league_dashboard.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}{{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +{% if is_logged_in %} +← Leagues +{% endif %} + +
+ +
+
+ {% if is_owner %} + Owner + {% endif %} + {{ league.visibility }} +
+

{{ league.name }}

+

{{ league.description or 'No description set.' }}

+
+
+ +{% if counts.open_sessions > 0 %} +
+ {{ counts.open_sessions }} live + + {% if counts.open_sessions == 1 %}A session is currently in progress.{% else %}{{ counts.open_sessions }} sessions are in progress.{% endif %} + + View sessions → +
+{% endif %} + +
+
+ Active players +
{{ counts.players }}
+
+
+ Sessions +
{{ counts.sessions }}
+ {% if counts.open_sessions > 0 %} +
{{ counts.open_sessions }} open now
+ {% endif %} +
+
+ Ledger events +
{{ counts.events }}
+
+
+ Seasons +
{{ counts.seasons }}
+
+
+ + + +{% endblock %} diff --git a/templates/league_leaderboard.html b/templates/league_leaderboard.html new file mode 100644 index 0000000..67f87db --- /dev/null +++ b/templates/league_leaderboard.html @@ -0,0 +1,350 @@ +{% extends "base.html" %} +{% block title %}Leaderboard · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+
+

Leaderboard

+

{{ session_count }} session{{ 's' if session_count != 1 else '' }}{% if selected_session_date %} through {{ selected_session_label }}{% endif %}

+
+
+
+ Cash paid out +
{{ cash_paid_out_cents | money }}
+
+
+ Sessions +
{{ session_count }}{% if total_session_count != session_count %} / {{ total_session_count }}{% endif %}
+
+
+ Players +
{{ all_count }}
+
+
+
+ +
+
+
+ Trend +

Cumulative profit

+
+ +
+
+
+ +
+
+
+ +
+
+
+ + + +
+

+ {% if mode == 'eligible' %} + {{ eligible_min_sessions }}+ sessions required. Keeps one-off guests from skewing standings. + {% elif mode == 'all' %} + Everyone who's sat down — rankings can swing on tiny samples. + {% else %} + Form guide — aggregated over the last 5 sessions only. + {% endif %} +

+
+
+
+ + + {% if selected_session_id %} + Reset + {% endif %} +
+
+
+ +
+ + + + + + + + + + + + + + + + {% for player in main_board %} + {% set idx = loop.index %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ # + + +
+ #{{ idx }} + + {% if player_href %}{{ player.player_name }}{% else %}{{ player.player_name }}{% endif %} + + {% if player.rank_change > 0 %} + ▲{{ player.rank_change }} + {% elif player.rank_change < 0 %} + ▼{{ player.rank_change | abs }} + {% else %} + + {% endif %} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} + {{ "%.1f"|format(player.win_pct) }}%{{ "%.1f"|format(player.roi_pct) }}%{{ player.avg_win_cents | money }}{{ player.avg_loss_cents | money }}{{ player.sessions_played }}
+ No players yet — add the first event from a league session ledger. +
+
+ +{% if mode == 'eligible' and provisional_board %} +
+
+ Provisional · fewer than {{ eligible_min_sessions }} sessions +
+
+ {% for player in provisional_board %} + {% set player_href = url_for('leagues.player_detail', league_ref=league.url_ref, player_id=player_ids[player.player_name]) if player_ids.get(player.player_name) else '' %} + +

{{ player.player_name }}

+
+ {{ player.sessions_played }} sess + {{ player.total_net_cents | money }} +
+
+ {% endfor %} +
+
+{% endif %} + + + + + +{% endblock %} diff --git a/templates/league_ledger.html b/templates/league_ledger.html new file mode 100644 index 0000000..bb696bf --- /dev/null +++ b/templates/league_ledger.html @@ -0,0 +1,303 @@ +{% extends "base.html" %} +{% block title %}Ledger · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+
+ Cash in Total real cash brought into the pot across all sessions. Counts buy-ins and collected fronts only — not fronts that haven't been repaid. +
{{ totals.cash_in_cents | money }}
+
+
+ Cash paid out Total cash physically returned to players via 'paid out' events. Should approach Cash In once everyone has settled. +
{{ totals.cash_out_cents | money }}
+
+
+ House holds Cash in minus cash paid out — what the house physically holds right now before settling remaining claims. +
{{ totals.book_held_cents | money }}
+
+
+ Due to players Cash the house still owes to players who cashed out but haven't been paid yet. Record a 'paid out' event to clear these. +
{{ totals.due_to_players_cents | money }}
+
+
+ Due to house Outstanding fronts — chips the house covered that players haven't repaid yet. Collect or write off using the Debt resolution panel below. +
{{ totals.due_to_house_cents | money }}
+
+
+ Net position House holds + owed by players − owed to players. Should be near zero once all sessions are settled. +
{{ totals.net_position_cents | money }}
+
+
+ Events Total number of active ledger entries across all sessions. +
{{ totals.events }}
+
+
+ + +{% if open_sessions %} +
+
+ {{ open_sessions | length }} live Open sessions +
+
+ {% for s in open_sessions %} +
+ {{ session_label(s) }} + + {{ s.entries | length }} entries · {{ s.total_real_cash_in_cents | money }} in + View → + +
+ {% endfor %} +
+
+{% endif %} + + +
+
+
+ Front receivables A front is chips the house covered for a player who didn't have cash. This section tracks who still owes money back to the house. +

Debt resolution

+
+
+ Open {{ totals.due_to_house_cents | money }} + Collected {{ totals.collected_cents | money }} + Written off {{ totals.written_off_cents | money }} +
+
+ {% if debt_entries %} +
+
+ {% for item in debt_entries %} + {% set s = item.session %} + {% set e = item.entry %} +
+
+ {{ e.player_name }} + {{ session_label(s) }} +
+ {{ e.current_due_to_house_cents | money }} +
+ {% endfor %} +
+ + {% if can_manage %} +
+
+ +

Collect debt

+ + + + + + + +
+ +
+ +

Write off debt

+ + + + + + + +
+
+ {% endif %} +
+ {% else %} +
+
$0
+

No open player debts right now.

+
+ {% endif %} +
+ + +{% if due_to_player_items %} +
+
+
+ Unpaid cashout claims +

Due to players

+
+ {{ totals.due_to_players_cents | money }} +
+
+ {% for item in due_to_player_items %} +
+
+ {{ item.entry.player_name }} + {{ session_label(item.session) }} +
+ {{ item.entry.current_due_to_player_cents | money }} +
+ {% endfor %} +
+
+{% endif %} + + +{% if can_manage %} +
+

Import / export

+

Back up or migrate the raw event ledger for this league.

+
+ +
+ ↓ Export CSV + +
+ + +
+
+{% else %} +
+

Export

+

Download the raw event ledger for this league.

+ +
+{% endif %} + + +
+
+
+
+ All sessions +

Ledger totals

+
+
+
+ + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + {% else %} + + {% endfor %} + +
SessionPlayersCash inPaid outDue to houseStatus
{{ session_label(s) }}{{ s.entries|length }}{{ s.total_real_cash_in_cents | money }}{{ s.total_real_cash_out_cents | money }}{{ s.total_current_due_to_house_cents | money }}{{ s.status }}
No sessions yet.
+
+
+ +
+
+
+ All events +

Ledger feed

+
+ append-only +
+
+ {% for event in raw_events %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type | replace('_', ' ') }} + {% if event.voided_at %}voided{% endif %} + {% if event.note and not event.voided_at %}{{ event.note }}{% endif %} +
+

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.session_date }}

+ {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+
+ + + +{% endblock %} diff --git a/templates/league_new.html b/templates/league_new.html new file mode 100644 index 0000000..46867c0 --- /dev/null +++ b/templates/league_new.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}New League · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

League setup

+

Create league

+

Leagues are long-lived containers. Seasons can be added later.

+
+ + + + Cancel +
+
+ +{% endblock %} diff --git a/templates/league_player_detail.html b/templates/league_player_detail.html new file mode 100644 index 0000000..e80cdbd --- /dev/null +++ b/templates/league_player_detail.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} +{% block title %}{{ player.player_name }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + + +
+
+ {% if player_rank %} +

Rank #{{ player_rank }}

+ {% endif %} +

{{ player.player_name }}

+

{{ player.sessions_played }} session{{ 's' if player.sessions_played != 1 else '' }} · {{ "%.1f"|format(player.win_pct) }}% win rate

+
+
+ +
+
+ Total net +
+ {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
+
+
+ Win rate +
{{ "%.1f"|format(player.win_pct) }}%
+
{{ player.winning_sessions }}W · {{ player.losing_sessions }}L · {{ player.break_even_sessions }}E
+
+
+ ROI +
{{ "%.1f"|format(player.roi_pct) }}%
+
+
+ Sessions +
{{ player.sessions_played }}
+
+
+ Current W/L +
+ {{ player.current_win_streak }}W + {{ player.current_loss_streak }}L +
+
Longest {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L
+
+
+ Best / worst +
+ {{ player.biggest_win_cents | money }} + {{ player.biggest_loss_cents | money }} +
+
+
+ Total invested +
{{ player.total_invested_cents | money }}
+
+
+ Gross cashouts +
{{ player.total_cash_out_cents | money }}
+
+
+ + +
+
+ Trend +

Cumulative profit

+
+
+
+ +
+
+
+ + +
+
+
+ Session by session +

Results

+
+
+
+ +
+
+
+
+
+ Breakdown +

Result mix

+
+
+
+ {% set result_total = player.winning_sessions + player.losing_sessions + player.break_even_sessions %} +
+ {% if player.winning_sessions > 0 %} + + {% endif %} + {% if player.break_even_sessions > 0 %} + + {% endif %} + {% if player.losing_sessions > 0 %} + + {% endif %} +
+
+
+ Wins + {{ player.winning_sessions }}{{ "%.1f"|format((player.winning_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Even + {{ player.break_even_sessions }}{{ "%.1f"|format((player.break_even_sessions / result_total * 100) if result_total else 0) }}% +
+
+ Losses + {{ player.losing_sessions }}{{ "%.1f"|format((player.losing_sessions / result_total * 100) if result_total else 0) }}% +
+
+ {% if result_total > 0 %} +
±$1.00 counts as even
+ {% endif %} +
+
+
+
+ + +
+ + +
+
+
+
+ + Poker performance +
+

Game results

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Total Net + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} +
Win %{{ "%.1f"|format(player.win_pct) }}%
Record + {{ player.winning_sessions }}W + · + {{ player.losing_sessions }}L + · + {{ player.break_even_sessions }}E +
ROI{{ "%.1f"|format(player.roi_pct) }}%
Average Win{{ player.avg_win_cents | money }}
Average Loss{{ player.avg_loss_cents | money }}
Best / Worst + {{ player.biggest_win_cents | money }} + / + {{ player.biggest_loss_cents | money }} +
Win / Loss Streak + {{ player.current_win_streak }}W + · + {{ player.current_loss_streak }}L +
Longest Win / Loss + {{ player.longest_win_streak }}W · {{ player.longest_loss_streak }}L +
+
+ + +
+
+
+
+ + Poker basis + settlement +
+

Investment and settlement

+
+
+ + + + + + + + + + + + + + + {% if player.total_payout_carry_in_cents > 0 %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% if player.total_debt_repayment_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_player_cents > 0 %} + + + + + {% endif %} + {% if player.current_due_to_house_cents > 0 %} + + + + + {% endif %} + {% if player.total_writeoff_cents > 0 %} + + + + + {% endif %} + +
Buy-ins paid{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
Rolled in{{ player.total_rollover_in_cents | money }}
Payout carry-in{{ player.total_payout_carry_in_cents | money }}
Total invested{{ player.total_invested_cents | money }}
Gross cashout result{{ player.total_cash_out_cents | money }}
Gross player claim{{ player.total_gross_payout_cents | money }}
Rolled out{{ player.total_rollover_out_cents | money }}
Cash paid out{{ player.total_paid_out_cents | money }}
Cash debt repaid{{ player.total_debt_repayment_cents | money }}
House owes player{{ player.current_due_to_player_cents | money }}
Owes house{{ player.current_due_to_house_cents | money }}
Written off{{ player.total_writeoff_cents | money }}
+
+
+ + +{% endblock %} diff --git a/templates/league_players.html b/templates/league_players.html new file mode 100644 index 0000000..7ed2cd3 --- /dev/null +++ b/templates/league_players.html @@ -0,0 +1,266 @@ +{% extends "base.html" %} +{% block title %}Players · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +{% if can_manage %} +
+ +
+

Roster

+

Add player

+
+
+ + +
+
+ +
+
+{% endif %} + +{% set active_players = players | selectattr('status', 'equalto', 'active') | list %} +{% set archived_players = players | selectattr('status', 'equalto', 'archived') | list %} + +
+
+
+

Roster

+

Active players

+
+ {{ active_players|length }} players +
+ {% if active_players %} +
+ {% for player in active_players %} + {% set stats = stats_by_name.get(player.display_name) %} + {% set initials = player.display_name.split()|map('first')|join('')|upper %} + {% set avatar_cls = 'player-avatar--pos' if stats and stats.total_net_cents > 0 else 'player-avatar--neg' if stats and stats.total_net_cents < 0 else '' %} +
+
+
{{ initials[:2] }}
+
+ {{ player.display_name }} + + {% if stats %}{{ stats.sessions_played }} session{{ 's' if stats.sessions_played != 1 else '' }}{% else %}No sessions yet{% endif %} + +
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+ {% if stats %} +
+
+ Net + + {{ '+' if stats.total_net_cents > 0 else '' }}{{ stats.total_net_cents | money }} + +
+
+ Win rate + {{ "%.0f"|format(stats.win_pct * 100) }}% +
+
+ Best win + {{ stats.biggest_win_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

Empty roster

+

No players yet

+

Add the people who play in this league. Players don't need accounts.

+
+ {% endif %} +
+ +{% if archived_players %} +
+
+
+

Archive

+

Archived players

+
+ {{ archived_players|length }} +
+
+ {% for player in archived_players %} + {% set stats = stats_by_name.get(player.display_name) %} +
+
+ {{ player.display_name }} + {% if stats %}{{ stats.sessions_played }} sessions · {{ stats.total_net_cents | money }} net{% endif %} +
+
+ {% if can_manage %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if can_manage %} + + + + + +{% endif %} + +{% endblock %} diff --git a/templates/league_session_detail.html b/templates/league_session_detail.html new file mode 100644 index 0000000..8ac0fa9 --- /dev/null +++ b/templates/league_session_detail.html @@ -0,0 +1,382 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+
+

{{ session_model.display_label }}

+

{{ session_model.session_date.isoformat() | pretty_date }} · {{ session_model.status }}

+
+
+ Public view + {% if can_manage %} + + {% if session_model.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} + {% if is_owner %} + + {% endif %} +
+
+ +
+
+ Invested +
{{ session.total_invested_cents | money }}
+
+
+ Cashout +
{{ session.total_cash_out_cents | money }}
+
+
+ Paid out +
{{ session.total_paid_out_cents | money }}
+
+
+ Open items +
{{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }}
+
+
+ +
+ {% if can_manage %} +
+ +
+

Append event

+

Ledger entry

+
+ {% if session_model.status != 'open' %} +
+ Session closed + Books assumed settled. Reopen to add events. +
+ {% endif %} +
+ + + + + +
+
+ {% else %} +
+

Read only

+

Your role can inspect this session but cannot append ledger events.

+
+ {% endif %} + +
+
+
+

Results

+

Player totals

+
+ {{ session.entries|length }} players +
+ {% if session.entries %} +
+ {% for entry in session.entries %} + {% set row_cls = 'player-ledger-row--pos' if entry.net_cents > 0 else 'player-ledger-row--neg' if entry.net_cents < 0 else '' %} +
+
+ {{ entry.player_name }} + {% if entry.current_due_to_house_cents > 0 %} + Owes {{ entry.current_due_to_house_cents | money }} + {% elif entry.current_due_to_player_cents > 0 %} + Due {{ entry.current_due_to_player_cents | money }} + {% else %} + Settled + {% endif %} +
+
+
+ Buy in + {{ entry.invested_cents | money }} +
+
+ Cashout claim + {{ entry.cash_out_cents | money }} +
+
+ Paid out + {{ entry.paid_out_cents | money if entry.paid_out_cents else '—' }} +
+
+ Net + + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} + +
+
+ {% if entry.rollover_in_cents or entry.rollover_out_cents or entry.payout_carry_in_cents %} +
+
+ Rolled in + {{ entry.rollover_in_cents | money }} +
+
+ Carry in + {{ entry.payout_carry_in_cents | money }} +
+
+ Rolled out + {{ entry.rollover_out_cents | money }} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No player ledger events yet.

+ {% endif %} +
+
+ +
+
+
+

Audit trail

+

Raw ledger events

+
+ append-only +
+
+ {% for event in raw_events | reverse %} +
+
+ {{ event.player_name or "Session" }} + {{ event.event_type | replace('_', ' ') }} + {% if event.voided_at %}voided{% endif %} + {% if event.note and not event.voided_at %}{{ event.note }}{% endif %} +
+

{{ event.amount_cents | money }}

+ {% if event.voided_at %} +

{{ event.void_reason }}

+ {% else %} +

{{ event.created_at }}

+ {% if is_owner %} + + {% endif %} + {% endif %} +
+ {% else %} +

No events recorded.

+ {% endfor %} +
+
+ + +{% if is_owner %} + +{% endif %} + + + + + +{% if can_manage %} + +{% endif %} + + + + + + +{% endblock %} diff --git a/templates/league_session_view.html b/templates/league_session_view.html new file mode 100644 index 0000000..f18ba6d --- /dev/null +++ b/templates/league_session_view.html @@ -0,0 +1,263 @@ +{% extends "base.html" %} +{% block title %}{{ session_model.display_label }} · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block page_class %}page--session{% endblock %} +{% block content %} + + + +
+

Session #{{ "%03d"|format(session_number) }}

+

{{ session_model.display_label }}

+

{{ session.entries|length }} player{{ 's' if session.entries|length != 1 else '' }} · {{ session_model.status }}

+
+ + +
+
+
+ Banker cash flow +
+
+
+ Actual cash in +
{{ session.total_real_cash_in_cents | money }}
+
+
+ Gross player claim +
{{ session.total_gross_payout_cents | money }}
+
+
+ Cash paid out +
{{ session.total_real_cash_out_cents | money }}
+
+
+ Rolled out +
{{ session.total_rollover_out_cents | money }}
+
+
+
+
+
+ Open items +
+
+
+ House owes players +
+ {{ session.total_current_due_to_player_cents | money }} +
+
+
+ Players owe house +
+ {{ session.total_current_due_to_house_cents | money }} +
+
+
+ Cash debt repaid +
{{ session.total_debt_repayment_cents | money }}
+
+
+ Written off +
{{ session.total_writeoff_cents | money }}
+
+
+
+
+ + +
+
+
+ Results +

Player totals

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + {% for entry in session.entries %} + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
PlayerInvestedGross cashoutNetPaid outOpen settlementSettlement statusFrontRolled inPayout carry-inGross player claimRolled outNotes
{{ entry.player_name }}{{ entry.invested_cents | money }}{{ entry.cash_out_cents | money }} + {{ '+' if entry.net_cents > 0 else '' }}{{ entry.net_cents | money }} + {{ entry.paid_out_cents | money }} + {% if entry.current_due_to_house_cents > 0 %} + owes {{ entry.current_due_to_house_cents | money }} + {% elif entry.current_due_to_player_cents > 0 %} + due {{ entry.current_due_to_player_cents | money }} + {% else %} + {{ 0 | money }} + {% endif %} + {{ entry.payout_status }}{{ entry.front_cents | money }}{{ entry.rollover_in_cents | money }}{{ entry.payout_carry_in_cents | money }} + {% if entry.gross_due_to_house_cents > 0 %}—{% else %}{{ entry.gross_payout_cents | money }}{% endif %} + {{ entry.rollover_out_cents | money }} + {% if entry.notes %} +
    {% for n in entry.notes %}
  • {{ n }}
  • {% endfor %}
+ {% else %} + + {% endif %} +
No player events yet.
+
+
+ + +
+
+
+
+ Breakdown +

Net by player

+
+
+
+
+
+
+ +
+
+
+ Quick read +

Session snapshot

+
+
+ {% set winners = session.entries | selectattr('net_cents', 'gt', 0) | list %} + {% set losers = session.entries | selectattr('net_cents', 'lt', 0) | list %} +
+
+ Biggest winner +
+ {% if winners %}{{ (winners | sort(attribute='net_cents', reverse=True) | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if winners %}
{{ winners | sort(attribute='net_cents', reverse=True) | first | attr('player_name') }}
{% endif %} +
+
+ Biggest loser +
+ {% if losers %}{{ (losers | sort(attribute='net_cents') | first).net_cents | money }}{% else %}—{% endif %} +
+ {% if losers %}
{{ losers | sort(attribute='net_cents') | first | attr('player_name') }}
{% endif %} +
+
+ Gross cashout result +
{{ session.total_cash_out_cents | money }}
+
+
+ Open items +
+ {{ (session.total_current_due_to_player_cents + session.total_current_due_to_house_cents) | money }} +
+
+
+
+
+ + + +{% endblock %} diff --git a/templates/league_sessions.html b/templates/league_sessions.html new file mode 100644 index 0000000..30e15c8 --- /dev/null +++ b/templates/league_sessions.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}Sessions · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + +
+ + {% if can_manage %} +
+ +
+

Add session

+

New session

+
+ +
+ + + +
+
+ +
+
+ {% endif %} + +
+
+
+

History

+

All sessions

+
+
+ {{ sessions|length }} total + {% if can_manage and empty_count > 0 %} +
+ + +
+ {% endif %} +
+
+ {% if sessions %} +
+ {% for session in sessions %} + {% set sm = db_id_to_summary.get(session.id) %} +
+
+
+ {{ session.session_date.strftime('%b') }} + {{ session.session_date.strftime('%-d') }} +
+
+ +
+ S{{ session.sequence_on_date }} + {% if sm and sm.entries %} + · {{ sm.entries|length }} player{{ 's' if sm.entries|length != 1 else '' }} + · {{ sm.total_invested_cents | money }} pot + {% if sm.total_current_due_to_house_cents > 0 or sm.total_current_due_to_player_cents > 0 %} + · {{ (sm.total_current_due_to_house_cents + sm.total_current_due_to_player_cents) | money }} open + {% endif %} + {% endif %} + {% if session.notes %}· {{ session.notes }}{% endif %} +
+
+
+
+ {{ session.status }} + View + {% if can_manage %} + Manage + {% if session.status == 'open' %} +
+ + +
+ {% else %} + + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+

No sessions yet

+

Create the first session above. Same-day sessions will automatically become S1, S2, and so on.

+
+ {% endif %} +
+ +
+ + + + + + +{% endblock %} diff --git a/templates/league_settings.html b/templates/league_settings.html new file mode 100644 index 0000000..4f316fb --- /dev/null +++ b/templates/league_settings.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{% block title %}Settings · {{ league.name }} · myboker.org{% endblock %} +{% block subnav %}{% include "_league_subnav.html" %}{% endblock %} +{% block content %} + + +
+ +
+ +
+

General

+

League info

+
+ + + +
+ +
+
+ +
+
+
+

Access

+

Members

+
+ {{ members|length }} member{{ 's' if members|length != 1 else '' }} +
+
+ {% for membership, member_user in members %} +
+
+ {{ member_user.email }} + {{ membership.role }} +
+ {% if membership.role != 'owner' %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+
+ +

Invite by email

+
+ + + +
+
+
+ +
+
+
+

Danger zone

+

Destructive actions

+
+
+
+
+
+ Archive league +

Removes this league from your dashboard. All players, sessions, and ledger data are preserved.

+
+ +
+
+
+ +
+ + + + + +{% endblock %} diff --git a/templates/leagues_index.html b/templates/leagues_index.html new file mode 100644 index 0000000..1c6aa9f --- /dev/null +++ b/templates/leagues_index.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}Leagues · myboker.org{% endblock %} +{% block content %} + +
+

Leagues

+

Your poker leagues

+

Owner and manager workspace.

+
+ +
+ +
+ +{% if leagues %} +
+
+ Leagues +
{{ page_summary.league_count }}
+
+
+ Live +
{{ page_summary.live_count }}
+
+
+ Sessions +
{{ page_summary.sessions_count }}
+
+
+ Open items +
{{ page_summary.open_items_cents | money }}
+
+
+ +
+ {% for item in leagues %} + {% set league = item.league %} + {% set membership = item.membership %} +
+
+ +
+ {{ membership.role }} +

{{ league.name }}

+
+ {% if item.live_session %} + {{ session_label(item.live_session) }} + {% else %} + idle + {% endif %} +
+
+ + {% if league.visibility == 'public' %} + + public + {% else %} + + private + {% endif %} + +
+

+ {% if league.description %}{{ league.description }}{% else %}{{ league.slug }}{% endif %} +

+
+ {% if item.latest_session %} + Latest {{ session_label(item.latest_session) }} + {% else %} + No sessions yet + {% endif %} + {% if item.leader %} + Leader {{ item.leader.player_name }} · {{ item.leader.total_net_cents | money }} + {% endif %} +
+
+ +
+
Players{{ item.players_count }}
+
Sessions{{ item.sessions_count }}
+
Paid out{{ item.cash_paid_out_cents | money }}
+
Open{{ item.open_items_cents | money }}
+
+ +
+
+ Top 3 + Full board +
+ {% if item.top_players %} +
    + {% for player in item.top_players %} +
  1. + {{ player.player_name }} + + {{ '+' if player.total_net_cents > 0 else '' }}{{ player.total_net_cents | money }} + +
  2. + {% endfor %} +
+ {% else %} +
+ No results yet + Create a session +
+ {% endif %} +
+ + +
+ {% endfor %} +
+{% else %} +
+

No leagues yet

+

Create the first league to start adding players, sessions, and ledger events.

+ Create league +
+{% endif %} + +{% endblock %} diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..4e38060 --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Reset Password · myboker.org{% endblock %} +{% block content %} + +
+
+ +
+

Account recovery

+

Reset password

+

Enter a new password for your account.

+
+ + + +
+
+ +{% endblock %} diff --git a/utils.py b/utils.py index badd71f..2fa8e79 100644 --- a/utils.py +++ b/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +import re from markupsafe import Markup @@ -11,6 +12,11 @@ from models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 +def slugify(value: str, fallback: str = "league") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-") + return slug or fallback + + def cents_to_dollars(cents: int) -> Markup: value = cents / 100 return Markup(f'${value:,.2f}')