diff --git a/config.py b/config.py index c0d56dc..21615ef 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.4.0" +APP_VERSION = "2.5.0" def load_local_env(env_path: Path) -> None: diff --git a/db_models.py b/db_models.py index 23b2915..525c0e4 100644 --- a/db_models.py +++ b/db_models.py @@ -93,6 +93,8 @@ class League(TimestampMixin, db.Model): index=True, ) archived_at = db.Column(db.DateTime(timezone=True), nullable=True) + eligible_min_sessions = db.Column(db.Integer, nullable=False, default=3) + break_even_cents = db.Column(db.Integer, nullable=False, default=100) __table_args__ = ( db.CheckConstraint( diff --git a/migrations/versions/0003_league_settings.py b/migrations/versions/0003_league_settings.py new file mode 100644 index 0000000..56ba72b --- /dev/null +++ b/migrations/versions/0003_league_settings.py @@ -0,0 +1,32 @@ +"""add per-league eligible_min_sessions and break_even_cents + +Revision ID: 0003_league_settings +Revises: 0002_league_public_key +Create Date: 2026-06-26 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0003_league_settings" +down_revision = "0002_league_public_key" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("leagues") as batch_op: + batch_op.add_column(sa.Column( + "eligible_min_sessions", sa.Integer(), nullable=False, server_default="3" + )) + batch_op.add_column(sa.Column( + "break_even_cents", sa.Integer(), nullable=False, server_default="100" + )) + + +def downgrade() -> None: + with op.batch_alter_table("leagues") as batch_op: + batch_op.drop_column("break_even_cents") + batch_op.drop_column("eligible_min_sessions") diff --git a/routes/leagues.py b/routes/leagues.py index da5bf33..3ba2b4a 100644 --- a/routes/leagues.py +++ b/routes/leagues.py @@ -304,7 +304,6 @@ def leaderboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from config import ELIGIBLE_MIN_SESSIONS from ledger_repositories import list_event_rows_for_league from league_repositories import list_players_for_league, user_has_league_role @@ -312,6 +311,8 @@ def leaderboard(league_ref: str): if resp: return resp is_owner = user_has_league_role(current_user_id() or "", league.id, {"owner"}) + eligible_min_sessions = league.eligible_min_sessions + break_even_cents = league.break_even_cents all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) ordered_sessions = sorted(all_sessions, key=session_sort_key) @@ -337,11 +338,11 @@ def leaderboard(league_ref: str): previous_sessions = [] board = apply_rank_changes( - build_leaderboard(filtered_sessions), - build_leaderboard(previous_sessions), + build_leaderboard(filtered_sessions, break_even_cents), + build_leaderboard(previous_sessions, break_even_cents), ) eligible_count = sum( - 1 for player in board if player.sessions_played >= ELIGIBLE_MIN_SESSIONS + 1 for player in board if player.sessions_played >= eligible_min_sessions ) all_count = len(board) recent_sessions_slice = filtered_sessions[-5:] @@ -352,18 +353,18 @@ def leaderboard(league_ref: str): }) if mode == "recent": - main_board = build_leaderboard(recent_sessions_slice) + main_board = build_leaderboard(recent_sessions_slice, break_even_cents) provisional_board = [] elif mode == "eligible": main_board = [ player for player in board - if player.sessions_played >= ELIGIBLE_MIN_SESSIONS + if player.sessions_played >= eligible_min_sessions ] provisional_board = [ player for player in board - if player.sessions_played < ELIGIBLE_MIN_SESSIONS + if player.sessions_played < eligible_min_sessions ] else: main_board = board @@ -381,7 +382,7 @@ def leaderboard(league_ref: str): eligible_count=eligible_count, all_count=all_count, recent_count=recent_count, - eligible_min_sessions=ELIGIBLE_MIN_SESSIONS, + 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, @@ -1146,28 +1147,46 @@ def league_settings(league_ref: str): "name": league.name, "description": league.description or "", "visibility": league.visibility, + "eligible_min_sessions": league.eligible_min_sessions, + "break_even_dollars": f"{league.break_even_cents / 100:.2f}", } if request.method == "POST": + raw_eligible = request.form.get("eligible_min_sessions", "3").strip() + raw_break_even = request.form.get("break_even_dollars", "1.00").strip() form = { "name": request.form.get("name", "").strip(), "description": request.form.get("description", "").strip(), "visibility": request.form.get("visibility", "private").strip(), + "eligible_min_sessions": raw_eligible, + "break_even_dollars": raw_break_even, } - 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") + try: + eligible_min = int(raw_eligible) + break_even_cents = round(float(raw_break_even) * 100) + except (ValueError, TypeError): + flash("Eligible sessions and break-even threshold must be valid numbers.", "error") else: - from utils import slugify + 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") + elif eligible_min < 1 or eligible_min > 100: + flash("Eligible minimum must be between 1 and 100.", "error") + elif break_even_cents < 0 or break_even_cents > 10000: + flash("Break-even threshold must be between $0.00 and $100.00.", "error") + else: + 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)) + league.name = form["name"] + league.slug = slugify(form["name"]) + league.description = form["description"] or None + league.visibility = form["visibility"] + league.eligible_min_sessions = eligible_min + league.break_even_cents = break_even_cents + 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 diff --git a/services.py b/services.py index c364759..13458b0 100644 --- a/services.py +++ b/services.py @@ -103,7 +103,7 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: return sessions -def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | None]: +def summarize_player_runs(entries: list[SessionEntry], break_even_cents: int = 100) -> dict[str, int | str | None]: ordered_entries = sorted(entries, key=entry_sort_key) longest_win_streak = 0 @@ -116,7 +116,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | for entry in ordered_entries: net = entry.net_cents - bucket = net_result_bucket(net) + bucket = net_result_bucket(net, break_even_cents) if best_entry is None or net > best_entry.net_cents: best_entry = entry @@ -141,7 +141,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | current_loss_streak = 0 for entry in reversed(ordered_entries): - bucket = net_result_bucket(entry.net_cents) + bucket = net_result_bucket(entry.net_cents, break_even_cents) if bucket == "win": if current_loss_streak > 0: @@ -166,7 +166,7 @@ def summarize_player_runs(entries: list[SessionEntry]) -> dict[str, int | str | } -def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: +def build_leaderboard(sessions: list[SessionSummary], break_even_cents: int = 100) -> list[PlayerStats]: player_entries: dict[str, list[SessionEntry]] = defaultdict(list) for session in sessions: for entry in session.entries: @@ -175,9 +175,9 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: leaderboard: list[PlayerStats] = [] for player_name, entries in player_entries.items(): nets = [entry.net_cents for entry in entries] - run_summary = summarize_player_runs(entries) - wins = [value for value in nets if net_result_bucket(value) == "win"] - losses = [value for value in nets if net_result_bucket(value) == "loss"] + run_summary = summarize_player_runs(entries, break_even_cents) + wins = [value for value in nets if net_result_bucket(value, break_even_cents) == "win"] + losses = [value for value in nets if net_result_bucket(value, break_even_cents) == "loss"] sessions_played = len(entries) winning_sessions = len(wins) diff --git a/templates/league_settings.html b/templates/league_settings.html index 1daaa86..bc85561 100644 --- a/templates/league_settings.html +++ b/templates/league_settings.html @@ -28,6 +28,20 @@ +
Leaderboard
+Controls how players qualify and how sessions are classified.
+