From e376b3628aaab825aaaad85e8587b011ede0d910 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sun, 28 Jun 2026 17:41:11 -0700 Subject: [PATCH] Move application code into package --- README.md | 8 + app.py | 119 +------------- boker/__init__.py | 1 + boker/app.py | 122 +++++++++++++++ auth.py => boker/auth.py | 4 +- charts.py => boker/charts.py | 4 +- config.py => boker/config.py | 2 +- db.py => boker/db.py | 2 +- db_models.py => boker/db_models.py | 2 +- emails.py => boker/emails.py | 2 +- extensions.py => boker/extensions.py | 0 .../league_repositories.py | 14 +- .../ledger_repositories.py | 6 +- models.py => boker/models.py | 0 {routes => boker/routes}/__init__.py | 0 {routes => boker/routes}/account.py | 42 ++--- {routes => boker/routes}/internal.py | 14 +- {routes => boker/routes}/leagues.py | 148 +++++++++--------- {routes => boker/routes}/public.py | 16 +- services.py => boker/services.py | 6 +- storage.py => boker/storage.py | 0 utils.py => boker/utils.py | 2 +- tests/test_accounting.py | 6 +- tests/test_internal.py | 30 ++-- tests/test_stats.py | 4 +- 25 files changed, 285 insertions(+), 269 deletions(-) create mode 100644 boker/__init__.py create mode 100644 boker/app.py rename auth.py => boker/auth.py (96%) rename charts.py => boker/charts.py (96%) rename config.py => boker/config.py (97%) rename db.py => boker/db.py (90%) rename db_models.py => boker/db_models.py (99%) rename emails.py => boker/emails.py (99%) rename extensions.py => boker/extensions.py (100%) rename league_repositories.py => boker/league_repositories.py (97%) rename ledger_repositories.py => boker/ledger_repositories.py (98%) rename models.py => boker/models.py (100%) rename {routes => boker/routes}/__init__.py (100%) rename {routes => boker/routes}/account.py (94%) rename {routes => boker/routes}/internal.py (98%) rename {routes => boker/routes}/leagues.py (92%) rename {routes => boker/routes}/public.py (93%) rename services.py => boker/services.py (98%) rename storage.py => boker/storage.py (100%) rename utils.py => boker/utils.py (97%) diff --git a/README.md b/README.md index 66fa1be..361c621 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ The current app is account-based and league-based. League owners/managers record - Chart.js - plain CSS +## Project Layout + +- `app.py` is the deployment entrypoint for `flask --app app` and Gunicorn. +- `boker/` contains the Flask application package, routes, models, services, repositories, and config. +- `templates/` and `static/` contain Jinja views and public assets. +- `migrations/` contains Alembic migrations for database deploys. +- `tests/` contains the committed regression suite. + ## Ledger Model Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable. diff --git a/app.py b/app.py index 5fa6fca..ab569a9 100644 --- a/app.py +++ b/app.py @@ -1,125 +1,10 @@ #!/usr/bin/env python3 from __future__ import annotations -import click -import os -from flask import Flask, render_template +from boker.app import app, create_app -from auth import current_user_id, is_logged_in, is_site_admin, normalize_email -from config import DEFAULT_SECRET_KEY, Config, ProductionConfig -from db import database_extensions_available, db, init_database -from extensions import csrf, limiter, mail -from routes.account import account_bp -from routes.internal import internal_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 +__all__ = ["app", "create_app"] -def _config_for_environment(): - app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() - if app_env in {"prod", "production"}: - return ProductionConfig - return Config - - -def create_app(config_overrides: dict | None = None) -> Flask: - app = Flask(__name__) - app.config.from_object(_config_for_environment()) - if config_overrides: - app.config.update(config_overrides) - - if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: - raise RuntimeError("Set SECRET_KEY before running in production.") - - ensure_data_file(app.config["DATA_PATH"]) - init_database(app) - csrf.init_app(app) - limiter.init_app(app) - mail.init_app(app) - - app.jinja_env.filters["money"] = cents_to_dollars - app.jinja_env.filters["pretty_date"] = safe_date_label - - @app.context_processor - def inject_globals() -> dict: - return { - "app_version": app.config["APP_VERSION"], - "current_user_id": current_user_id(), - "is_logged_in": is_logged_in(), - "current_user_is_site_admin": is_site_admin(), - } - - app.register_blueprint(public_bp) - app.register_blueprint(account_bp) - app.register_blueprint(internal_bp) - app.register_blueprint(leagues_bp) - - @app.errorhandler(404) - def not_found(e): - return render_template("404.html"), 404 - - @app.errorhandler(403) - def forbidden(e): - return render_template("403.html"), 403 - - @app.cli.command("init-db") - def init_db_command() -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - with app.app_context(): - db.create_all() - - click.echo("Initialized database tables.") - - @app.cli.command("grant-site-admin") - @click.argument("email") - def grant_site_admin_command(email: str) -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - from db_models import User - - normalized_email = normalize_email(email) - with app.app_context(): - user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none() - if user is None: - raise click.ClickException(f"No active user found for {normalized_email}.") - user.is_site_admin = True - db.session.commit() - - click.echo(f"Granted site admin access to {normalized_email}.") - - @app.cli.command("revoke-site-admin") - @click.argument("email") - def revoke_site_admin_command(email: str) -> None: - if not database_extensions_available() or db is None: - raise click.ClickException( - "Database dependencies are not installed. Run pip install -r requirements.txt." - ) - - from db_models import User - - normalized_email = normalize_email(email) - with app.app_context(): - user = User.query.filter_by(email=normalized_email).one_or_none() - if user is None: - raise click.ClickException(f"No user found for {normalized_email}.") - user.is_site_admin = False - db.session.commit() - - click.echo(f"Revoked site admin access from {normalized_email}.") - - return app - - -app = create_app() - if __name__ == "__main__": app.run(debug=True) diff --git a/boker/__init__.py b/boker/__init__.py new file mode 100644 index 0000000..afd4634 --- /dev/null +++ b/boker/__init__.py @@ -0,0 +1 @@ +"""Application package for myboker.org.""" diff --git a/boker/app.py b/boker/app.py new file mode 100644 index 0000000..3247e99 --- /dev/null +++ b/boker/app.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import click +import os +from flask import Flask, render_template + +from boker.auth import current_user_id, is_logged_in, is_site_admin, normalize_email +from boker.config import DEFAULT_SECRET_KEY, Config, ProductionConfig +from boker.db import database_extensions_available, db, init_database +from boker.extensions import csrf, limiter, mail +from boker.routes.account import account_bp +from boker.routes.internal import internal_bp +from boker.routes.leagues import leagues_bp +from boker.routes.public import public_bp +from boker.storage import ensure_data_file +from boker.utils import cents_to_dollars, safe_date_label + + +def _config_for_environment(): + app_env = os.getenv("APP_ENV", os.getenv("FLASK_ENV", "")).lower() + if app_env in {"prod", "production"}: + return ProductionConfig + return Config + + +def create_app(config_overrides: dict | None = None) -> Flask: + app = Flask(__name__, template_folder="../templates", static_folder="../static") + app.config.from_object(_config_for_environment()) + if config_overrides: + app.config.update(config_overrides) + + if app.config["SESSION_COOKIE_SECURE"] and app.config["SECRET_KEY"] == DEFAULT_SECRET_KEY: + raise RuntimeError("Set SECRET_KEY before running in production.") + + ensure_data_file(app.config["DATA_PATH"]) + init_database(app) + csrf.init_app(app) + limiter.init_app(app) + mail.init_app(app) + + app.jinja_env.filters["money"] = cents_to_dollars + app.jinja_env.filters["pretty_date"] = safe_date_label + + @app.context_processor + def inject_globals() -> dict: + return { + "app_version": app.config["APP_VERSION"], + "current_user_id": current_user_id(), + "is_logged_in": is_logged_in(), + "current_user_is_site_admin": is_site_admin(), + } + + app.register_blueprint(public_bp) + app.register_blueprint(account_bp) + app.register_blueprint(internal_bp) + app.register_blueprint(leagues_bp) + + @app.errorhandler(404) + def not_found(e): + return render_template("404.html"), 404 + + @app.errorhandler(403) + def forbidden(e): + return render_template("403.html"), 403 + + @app.cli.command("init-db") + def init_db_command() -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + with app.app_context(): + db.create_all() + + click.echo("Initialized database tables.") + + @app.cli.command("grant-site-admin") + @click.argument("email") + def grant_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from boker.db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none() + if user is None: + raise click.ClickException(f"No active user found for {normalized_email}.") + user.is_site_admin = True + db.session.commit() + + click.echo(f"Granted site admin access to {normalized_email}.") + + @app.cli.command("revoke-site-admin") + @click.argument("email") + def revoke_site_admin_command(email: str) -> None: + if not database_extensions_available() or db is None: + raise click.ClickException( + "Database dependencies are not installed. Run pip install -r requirements.txt." + ) + + from boker.db_models import User + + normalized_email = normalize_email(email) + with app.app_context(): + user = User.query.filter_by(email=normalized_email).one_or_none() + if user is None: + raise click.ClickException(f"No user found for {normalized_email}.") + user.is_site_admin = False + db.session.commit() + + click.echo(f"Revoked site admin access from {normalized_email}.") + + return app + + +app = create_app() diff --git a/auth.py b/boker/auth.py similarity index 96% rename from auth.py rename to boker/auth.py index 86be33b..b5ad923 100644 --- a/auth.py +++ b/boker/auth.py @@ -72,12 +72,12 @@ def is_site_admin() -> bool: if not user_id: return False - from db import database_extensions_available + from boker.db import database_extensions_available if not database_extensions_available(): return False - from db_models import User + from boker.db_models import User user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none() return bool(user and user.is_site_admin) diff --git a/charts.py b/boker/charts.py similarity index 96% rename from charts.py rename to boker/charts.py index 8f6bf09..b21fdd0 100644 --- a/charts.py +++ b/boker/charts.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any -from models import SessionEntry, SessionSummary -from utils import net_result_bucket, session_chart_label, session_sort_key +from boker.models import SessionEntry, SessionSummary +from boker.utils import net_result_bucket, session_chart_label, session_sort_key PLAYER_PALETTE = [ "#9b8cf0", # --line-1 violet diff --git a/config.py b/boker/config.py similarity index 97% rename from config.py rename to boker/config.py index a8384a2..d90062a 100644 --- a/config.py +++ b/boker/config.py @@ -4,7 +4,7 @@ from __future__ import annotations import os from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +BASE_DIR = Path(__file__).resolve().parent.parent DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" DEFAULT_SECRET_KEY = "change-this-before-deploying" diff --git a/db.py b/boker/db.py similarity index 90% rename from db.py rename to boker/db.py index 050416c..018f6a0 100644 --- a/db.py +++ b/boker/db.py @@ -24,7 +24,7 @@ def init_database(app: Any) -> bool: app.config["DATABASE_EXTENSIONS_AVAILABLE"] = False return False - import db_models # noqa: F401 - registers SQLAlchemy models with metadata. + import boker.db_models # noqa: F401 - registers SQLAlchemy models with metadata. db.init_app(app) migrate.init_app(app, db) diff --git a/db_models.py b/boker/db_models.py similarity index 99% rename from db_models.py rename to boker/db_models.py index cf2c78a..a0da8d9 100644 --- a/db_models.py +++ b/boker/db_models.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from datetime import date, datetime, timezone -from db import db +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") diff --git a/emails.py b/boker/emails.py similarity index 99% rename from emails.py rename to boker/emails.py index 10391c3..c6874ab 100644 --- a/emails.py +++ b/boker/emails.py @@ -7,7 +7,7 @@ import socket from flask import current_app from flask_mail import Message -from extensions import mail +from boker.extensions import mail def _mail_send_suppressed() -> bool: diff --git a/extensions.py b/boker/extensions.py similarity index 100% rename from extensions.py rename to boker/extensions.py diff --git a/league_repositories.py b/boker/league_repositories.py similarity index 97% rename from league_repositories.py rename to boker/league_repositories.py index 9f4e5ab..7536b7b 100644 --- a/league_repositories.py +++ b/boker/league_repositories.py @@ -5,13 +5,13 @@ import secrets import string from datetime import date -from auth import hash_password, normalize_email -from db import db +from boker.auth import hash_password, normalize_email +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from boker.db_models import ( # noqa: E402 LedgerEvent, League, LeagueMembership, @@ -24,7 +24,7 @@ from db_models import ( # noqa: E402 make_user, normalize_lookup, ) -from utils import slugify +from boker.utils import slugify PUBLIC_KEY_ALPHABET = string.ascii_lowercase + string.digits @@ -223,7 +223,7 @@ def set_session_status(session: PokerSession, status: str) -> PokerSession: if status not in {"open", "closed"}: raise ValueError(f"Unsupported session status: {status}") - from db_models import utc_now + from boker.db_models import utc_now session.status = status if status == "open": @@ -323,7 +323,7 @@ def add_league_member( def remove_league_member(league_id: str, user_id: str) -> None: - from db_models import utc_now + from boker.db_models import utc_now membership = LeagueMembership.query.filter_by( league_id=league_id, @@ -421,7 +421,7 @@ def delete_season(season: Season) -> None: def delete_league(league: League) -> None: - from db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season + from boker.db_models import LedgerEvent, LeagueMembership, Player, PokerSession, Season LedgerEvent.query.filter_by(league_id=league.id).delete(synchronize_session=False) PokerSession.query.filter_by(league_id=league.id).delete(synchronize_session=False) Player.query.filter_by(league_id=league.id).delete(synchronize_session=False) diff --git a/ledger_repositories.py b/boker/ledger_repositories.py similarity index 98% rename from ledger_repositories.py rename to boker/ledger_repositories.py index 93cb1e2..0037160 100644 --- a/ledger_repositories.py +++ b/boker/ledger_repositories.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 from __future__ import annotations -from db import db +from boker.db import db if db is None: # pragma: no cover - imported only when DB dependencies exist. raise RuntimeError("Database dependencies are not installed.") -from db_models import ( # noqa: E402 +from boker.db_models import ( # noqa: E402 CANONICAL_EVENT_TYPES, LedgerEvent, Player, @@ -14,7 +14,7 @@ from db_models import ( # noqa: E402 User, canonical_event_type, ) -from storage import EventRow # noqa: E402 +from boker.storage import EventRow # noqa: E402 def session_event_ref(session: PokerSession) -> str: diff --git a/models.py b/boker/models.py similarity index 100% rename from models.py rename to boker/models.py diff --git a/routes/__init__.py b/boker/routes/__init__.py similarity index 100% rename from routes/__init__.py rename to boker/routes/__init__.py diff --git a/routes/account.py b/boker/routes/account.py similarity index 94% rename from routes/account.py rename to boker/routes/account.py index 3511698..78d8cdf 100644 --- a/routes/account.py +++ b/boker/routes/account.py @@ -7,7 +7,7 @@ from urllib.parse import urlsplit from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for -from auth import ( +from boker.auth import ( current_user_id, generate_invite_token, generate_reset_token, @@ -20,8 +20,8 @@ from auth import ( verify_password, verify_reset_token, ) -from db import database_extensions_available, db -from extensions import limiter +from boker.db import database_extensions_available, db +from boker.extensions import limiter account_bp = Blueprint("account", __name__, url_prefix="/account") EMAIL_VERIFICATION_TTL = timedelta(minutes=15) @@ -64,7 +64,7 @@ def _new_verification_code() -> str: def _issue_verification_code(user) -> str: - from db_models import utc_now + from boker.db_models import utc_now code = _new_verification_code() user.email_verification_code_hash = hash_password(code) @@ -73,7 +73,7 @@ def _issue_verification_code(user) -> str: def _send_verification_code(user) -> None: - from emails import send_email_verification_code + from boker.emails import send_email_verification_code code = _issue_verification_code(user) db.session.commit() @@ -112,7 +112,7 @@ def register(): } if request.method == "POST": - from league_repositories import create_user, find_user_by_email + from boker.league_repositories import create_user, find_user_by_email email = normalize_email(form["email"]) password = request.form.get("password", "") @@ -158,7 +158,7 @@ def login(): } if request.method == "POST": - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email user = find_user_by_email(form["email"]) password = request.form.get("password", "") @@ -195,7 +195,7 @@ def verify_email(): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User, utc_now + from boker.db_models import User, utc_now user_id = flask_session.get("pending_verification_user_id") user = db.session.get(User, user_id) if user_id else None @@ -245,7 +245,7 @@ def resend_verification_code(): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from boker.db_models import User user_id = flask_session.get("pending_verification_user_id") user = db.session.get(User, user_id) if user_id else None @@ -275,7 +275,7 @@ def settings(): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -292,7 +292,7 @@ def update_email(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -307,7 +307,7 @@ def update_email(): elif not verify_password(user.password_hash, current_password): flash("Current password is incorrect.", "error") else: - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email existing = find_user_by_email(new_email) if existing and existing.id != user.id: @@ -327,7 +327,7 @@ def update_password(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import User + from boker.db_models import User user = db.session.get(User, current_user_id()) if user is None: @@ -359,7 +359,7 @@ def disable_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, User, utc_now + from boker.db_models import League, User, utc_now user = db.session.get(User, current_user_id()) if user is None: @@ -395,8 +395,8 @@ def delete_account(): flash("Account database is not available.", "error") return redirect(url_for("account.settings")) - from db_models import League, LeagueMembership, User, utc_now - from league_repositories import delete_league + from boker.db_models import League, LeagueMembership, User, utc_now + from boker.league_repositories import delete_league user = db.session.get(User, current_user_id()) if user is None: @@ -436,9 +436,9 @@ def forgot_password(): flash("Account database is not available.", "error") return redirect(url_for("account.forgot_password")) - from emails import send_password_reset + from boker.emails import send_password_reset from flask import current_app - from league_repositories import find_user_by_email + from boker.league_repositories import find_user_by_email email = normalize_email(request.form.get("email", "")) user = find_user_by_email(email) @@ -472,7 +472,7 @@ def reset_password(token): flash("Account database is not available.", "error") return redirect(url_for("account.login")) - from db_models import User + from boker.db_models import User user = db.session.get(User, user_id) if user is None or user.disabled_at is not None: @@ -507,8 +507,8 @@ def accept_invite(token): flash("Account database is not available.", "error") return redirect(url_for("leagues.index")) - from db_models import User - from league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email + from boker.db_models import User + from boker.league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email invite_email = normalize_email(data.get("email", "")) invite_next = url_for("account.accept_invite", token=token) diff --git a/routes/internal.py b/boker/routes/internal.py similarity index 98% rename from routes/internal.py rename to boker/routes/internal.py index 9eacee5..339f4d5 100644 --- a/routes/internal.py +++ b/boker/routes/internal.py @@ -9,17 +9,17 @@ from flask import Blueprint, current_app, flash, redirect, render_template, requ from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError -from auth import ( +from boker.auth import ( current_user_id, generate_reset_token, hash_password, normalize_email, site_admin_required, ) -from db import db -from db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now -from league_repositories import delete_league, transfer_league_ownership -from utils import slugify +from boker.db import db +from boker.db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now +from boker.league_repositories import delete_league, transfer_league_ownership +from boker.utils import slugify internal_bp = Blueprint("internal", __name__, url_prefix="/internal") @@ -415,7 +415,7 @@ def send_user_password_reset(user_id: str): flash("Disabled users cannot receive password resets.", "error") return redirect(url_for("internal.user_detail", user_id=user.id)) - from emails import send_password_reset + from boker.emails import send_password_reset token = generate_reset_token(user.id) base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/") @@ -436,7 +436,7 @@ def create_user_temporary_password(user_id: str): flash("Disabled users cannot receive temporary passwords.", "error") return redirect(url_for("internal.user_detail", user_id=user.id)) - from emails import send_temporary_password + from boker.emails import send_temporary_password temporary_password = _temporary_password() user.password_hash = hash_password(temporary_password) diff --git a/routes/leagues.py b/boker/routes/leagues.py similarity index 92% rename from routes/leagues.py rename to boker/routes/leagues.py index 804121f..ad73fb9 100644 --- a/routes/leagues.py +++ b/boker/routes/leagues.py @@ -9,12 +9,12 @@ from datetime import datetime, timezone from flask import Blueprint, abort, flash, make_response, redirect, render_template, request, url_for -from auth import current_user_id, login_required, normalize_email -from charts import cumulative_profit_series, player_session_series -from db import database_extensions_available, db -from models import SessionSummary -from services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events -from utils import cents_to_dollars, session_label, session_sort_key +from boker.auth import current_user_id, login_required, normalize_email +from boker.charts import cumulative_profit_series, player_session_series +from boker.db import database_extensions_available, db +from boker.models import SessionSummary +from boker.services import apply_rank_changes, build_leaderboard, build_session_summaries, session_events +from boker.utils import cents_to_dollars, session_label, session_sort_key leagues_bp = Blueprint("leagues", __name__) @@ -48,7 +48,7 @@ def split_league_ref(league_ref: str) -> tuple[str, str]: def require_league(league_ref: str, allowed_roles: set[str]): - from league_repositories import find_league_by_public_key, user_has_league_role + from boker.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) @@ -64,7 +64,7 @@ def require_league(league_ref: str, allowed_roles: set[str]): def get_league_with_visibility_gate(league_ref: str): """Load league; if private, enforce login + membership. Returns (league, None) or (None, redirect).""" - from league_repositories import find_league_by_public_key, user_has_league_role + from boker.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) @@ -99,8 +99,8 @@ def empty_session_summary(session) -> SessionSummary: def session_ref_map(league_id: str) -> dict[str, str]: - from ledger_repositories import session_event_ref - from league_repositories import list_sessions_for_league + from boker.ledger_repositories import session_event_ref + from boker.league_repositories import list_sessions_for_league return {session_event_ref(session): session.id for session in list_sessions_for_league(league_id)} @@ -112,8 +112,8 @@ def index(): flash("League database is not available.", "error") return render_template("leagues_index.html", leagues=[]) - from league_repositories import list_leagues_for_user - from ledger_repositories import list_event_rows_for_league + from boker.league_repositories import list_leagues_for_user + from boker.ledger_repositories import list_event_rows_for_league memberships = list_leagues_for_user(current_user_id() or "") league_summaries = [] @@ -190,8 +190,8 @@ def new(): } if request.method == "POST": - from db_models import User - from league_repositories import create_league, unique_league_slug + from boker.db_models import User + from boker.league_repositories import create_league, unique_league_slug owner = db.session.get(User, current_user_id()) if owner is None: @@ -221,8 +221,8 @@ def dashboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import league_counts, user_has_league_role - from ledger_repositories import list_event_rows_for_league + from boker.league_repositories import league_counts, user_has_league_role + from boker.ledger_repositories import list_event_rows_for_league league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -289,7 +289,7 @@ def legacy_dashboard_redirect(league_id: str, league_slug: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_league_by_id, user_has_league_role + from boker.league_repositories import find_league_by_id, user_has_league_role league = find_league_by_id(league_id) if league is None: @@ -306,8 +306,8 @@ def leaderboard(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import ( + from boker.ledger_repositories import list_event_rows_for_league + from boker.league_repositories import ( list_players_for_league, list_seasons_for_league, user_has_league_role, @@ -328,10 +328,10 @@ def leaderboard(league_ref: str): selected_season_id = request.args.get("season", "").strip() selected_season = None if selected_season_id: - from league_repositories import find_season, list_sessions_for_league + from boker.league_repositories import find_season, list_sessions_for_league selected_season = find_season(league.id, selected_season_id) if selected_season: - from ledger_repositories import session_event_ref + from boker.ledger_repositories import session_event_ref season_db_sessions = list_sessions_for_league(league.id) season_refs = { session_event_ref(s) @@ -442,12 +442,12 @@ def ledger(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import ( + from boker.ledger_repositories import ( append_ledger_event, list_all_event_rows_for_league, list_event_rows_for_league, ) - from league_repositories import ( + from boker.league_repositories import ( find_player_for_league, find_session_for_league, list_players_for_league, @@ -579,7 +579,7 @@ def void_event(league_ref: str, event_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import void_ledger_event + from boker.ledger_repositories import void_ledger_event league = require_league(league_ref, {"owner"}) reason = request.form.get("void_reason", "").strip() @@ -602,8 +602,8 @@ def player_detail(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from league_repositories import find_player_for_league, user_has_league_role + from boker.ledger_repositories import list_event_rows_for_league + from boker.league_repositories import find_player_for_league, user_has_league_role league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -661,7 +661,7 @@ def players(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from boker.league_repositories import ( create_player, list_players_for_league, player_name_exists, @@ -700,7 +700,7 @@ def players(league_ref: str): flash("Player added.", "success") return redirect(url_for("leagues.players", **league_url_values(league))) - from ledger_repositories import list_event_rows_for_league + from boker.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, league.break_even_cents) @@ -734,7 +734,7 @@ def update_player_status(league_ref: str, player_id: str, status: str, message: flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, set_player_status + from boker.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) @@ -754,7 +754,7 @@ def edit_player(league_ref: str, player_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_player_for_league, player_name_taken, update_player + from boker.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) @@ -782,8 +782,8 @@ def sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import ( + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import ( create_poker_session, list_seasons_for_league, list_sessions_for_league, @@ -816,10 +816,10 @@ def sessions(league_ref: str): status = "closed" if form["status"] == "closed" else "open" season_id = form["season_id"] or None if season_id is None: - from league_repositories import auto_assign_sessions_to_seasons as _auto + from boker.league_repositories import auto_assign_sessions_to_seasons as _auto # try auto-assign: create the session first, then let the # function match it; we pass a temporary session date check inline - from league_repositories import list_seasons_for_league as _ls + from boker.league_repositories import list_seasons_for_league as _ls eligible = [ s for s in _ls(league.id, include_archived=False) if s.start_date and s.end_date @@ -836,7 +836,7 @@ def sessions(league_ref: str): session.label = form["label"] or None session.notes = form["notes"] or None if status == "open": - from league_repositories import set_session_status + from boker.league_repositories import set_session_status set_session_status(session, "open") @@ -854,8 +854,8 @@ def sessions(league_ref: str): flash(f"Created {session.display_label}.", "success") return redirect(url_for("leagues.sessions", **league_url_values(league))) - from db_models import LedgerEvent - from ledger_repositories import list_event_rows_for_league + from boker.db_models import LedgerEvent + from boker.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)) @@ -908,8 +908,8 @@ def prune_empty_sessions(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import LedgerEvent, PokerSession - from league_repositories import list_sessions_for_league + from boker.db_models import LedgerEvent, PokerSession + from boker.league_repositories import list_sessions_for_league league = require_league(league_ref, {"owner", "manager"}) @@ -944,7 +944,7 @@ def delete_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from boker.league_repositories import find_session_for_league league = require_league(league_ref, {"owner"}) session = find_session_for_league(league.id, session_id) @@ -964,7 +964,7 @@ def edit_session(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_session_for_league + from boker.league_repositories import find_session_for_league league = require_league(league_ref, {"owner", "manager"}) session = find_session_for_league(league.id, session_id) @@ -983,7 +983,7 @@ def edit_session(league_ref: str, session_id: str): return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) if new_date != session.session_date: - from db_models import PokerSession as _PS + from boker.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, @@ -993,7 +993,7 @@ def edit_session(league_ref: str, session_id: str): session.session_date = new_date if new_season_id is not None: - from league_repositories import find_season + from boker.league_repositories import find_season valid = find_season(league.id, new_season_id) new_season_id = valid.id if valid else None @@ -1012,8 +1012,8 @@ def session_detail(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session - from league_repositories import ( + from boker.ledger_repositories import append_ledger_event, list_all_event_rows_for_session, list_event_rows_for_session + from boker.league_repositories import ( find_session_for_league, list_players_for_league, list_seasons_for_league, @@ -1101,9 +1101,9 @@ def session_public_view(league_ref: str, session_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from charts import session_breakdown_series - from ledger_repositories import list_event_rows_for_league, list_event_rows_for_session - from league_repositories import find_league_by_public_key, find_season, find_session_for_league, list_sessions_for_league, user_has_league_role + from boker.charts import session_breakdown_series + from boker.ledger_repositories import list_event_rows_for_league, list_event_rows_for_session + from boker.league_repositories import find_league_by_public_key, find_season, 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) @@ -1182,8 +1182,8 @@ def update_session_status(league_ref: str, session_id: str, status: str, message flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import append_ledger_event - from league_repositories import find_session_for_league, set_session_status + from boker.ledger_repositories import append_ledger_event + from boker.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) @@ -1214,7 +1214,7 @@ def seasons(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import ( + from boker.league_repositories import ( create_season, list_seasons_for_league, user_has_league_role, @@ -1283,7 +1283,7 @@ def auto_assign_seasons(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import auto_assign_sessions_to_seasons + from boker.league_repositories import auto_assign_sessions_to_seasons league = require_league(league_ref, {"owner", "manager"}) count = auto_assign_sessions_to_seasons(league.id) @@ -1302,7 +1302,7 @@ def update_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_season, update_season as repo_update_season + from boker.league_repositories import find_season, update_season as repo_update_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1338,7 +1338,7 @@ def archive_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import archive_season as repo_archive, find_season + from boker.league_repositories import archive_season as repo_archive, find_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1359,7 +1359,7 @@ def unarchive_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import find_season, unarchive_season as repo_unarchive + from boker.league_repositories import find_season, unarchive_season as repo_unarchive league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1380,7 +1380,7 @@ def delete_season(league_ref: str, season_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import delete_season as repo_delete, find_season + from boker.league_repositories import delete_season as repo_delete, find_season league = require_league(league_ref, {"owner", "manager"}) season = find_season(league.id, season_id) @@ -1437,7 +1437,7 @@ def league_settings(league_ref: str): 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 + from boker.utils import slugify league.name = form["name"] league.slug = slugify(form["name"]) @@ -1449,7 +1449,7 @@ def league_settings(league_ref: str): flash("League settings saved.", "success") return redirect(url_for("leagues.league_settings", league_ref=league.url_ref)) - from league_repositories import list_members_for_league + from boker.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, can_manage=True, members=members) @@ -1462,11 +1462,11 @@ def invite_member(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from auth import generate_invite_token - from emails import send_league_invite + from boker.auth import generate_invite_token + from boker.emails import send_league_invite from flask import current_app - from db_models import User - from league_repositories import find_membership, find_user_by_email + from boker.db_models import User + from boker.league_repositories import find_membership, find_user_by_email league = require_league(league_ref, {"owner"}) email = normalize_email(request.form.get("email", "")) @@ -1509,7 +1509,7 @@ def remove_member(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import remove_league_member + from boker.league_repositories import remove_league_member league = require_league(league_ref, {"owner"}) @@ -1530,7 +1530,7 @@ def update_member_role(league_ref: str, user_id: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import set_league_member_role + from boker.league_repositories import set_league_member_role league = require_league(league_ref, {"owner"}) role = request.form.get("role", "").strip() @@ -1560,7 +1560,7 @@ def transfer_ownership(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import transfer_league_ownership + from boker.league_repositories import transfer_league_ownership league = require_league(league_ref, {"owner"}) new_owner_user_id = request.form.get("new_owner_user_id", "").strip() @@ -1588,7 +1588,7 @@ def archive_league(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import utc_now + from boker.db_models import utc_now league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1610,7 +1610,7 @@ def delete_league_route(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from league_repositories import delete_league + from boker.league_repositories import delete_league league = require_league(league_ref, {"owner"}) confirm_name = request.form.get("confirm_name", "").strip() @@ -1633,8 +1633,8 @@ def export_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from ledger_repositories import list_event_rows_for_league - from storage import CSV_HEADERS + from boker.ledger_repositories import list_event_rows_for_league + from boker.storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) rows = list_event_rows_for_league(league.id) @@ -1659,14 +1659,14 @@ def import_ledger_csv(league_ref: str): flash("League database is not available.", "error") return redirect(url_for("public.home")) - from db_models import CANONICAL_EVENT_TYPES, canonical_event_type - from ledger_repositories import ( + from boker.db_models import CANONICAL_EVENT_TYPES, canonical_event_type + from boker.ledger_repositories import ( append_ledger_event, list_ledger_events_for_league, session_event_ref, ) - from league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug - from storage import CSV_HEADERS + from boker.league_repositories import create_player, list_players_for_league, list_sessions_for_league, unique_player_slug + from boker.storage import CSV_HEADERS league = require_league(league_ref, {"owner", "manager"}) @@ -1700,7 +1700,7 @@ def import_ledger_csv(league_ref: str): flash(f"CSV header mismatch. Expected: {', '.join(CSV_HEADERS)}", "error") return redirect(url_for("leagues.ledger", **league_url_values(league))) - from db_models import PokerSession, make_session + from boker.db_models import PokerSession, make_session sessions = list_sessions_for_league(league.id) session_by_ref = {session_event_ref(s): s for s in sessions} diff --git a/routes/public.py b/boker/routes/public.py similarity index 93% rename from routes/public.py rename to boker/routes/public.py index 90ef5f8..063b7fb 100644 --- a/routes/public.py +++ b/boker/routes/public.py @@ -4,16 +4,16 @@ from __future__ import annotations from flask import Blueprint, flash, redirect, render_template, request, url_for from flask import current_app -from charts import cumulative_profit_series, player_session_series, session_breakdown_series -from config import ELIGIBLE_MIN_SESSIONS -from services import ( +from boker.charts import cumulative_profit_series, player_session_series, session_breakdown_series +from boker.config import ELIGIBLE_MIN_SESSIONS +from boker.services import ( apply_rank_changes, build_leaderboard, build_session_summaries, session_events, ) -from storage import load_events -from utils import session_label, session_sort_key +from boker.storage import load_events +from boker.utils import session_label, session_sort_key public_bp = Blueprint("public", __name__) @@ -40,9 +40,9 @@ def terms(): @public_bp.get("/explore") def explore(): - from auth import is_logged_in - from db import database_extensions_available - from league_repositories import league_counts, list_public_leagues + from boker.auth import is_logged_in + from boker.db import database_extensions_available + from boker.league_repositories import league_counts, list_public_leagues q = request.args.get("q", "").strip() leagues = list_public_leagues(q) if database_extensions_available() else [] diff --git a/services.py b/boker/services.py similarity index 98% rename from services.py rename to boker/services.py index 13458b0..424116c 100644 --- a/services.py +++ b/boker/services.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections import defaultdict -from models import PlayerStats, SessionEntry, SessionSummary -from storage import EventRow -from utils import entry_sort_key, net_result_bucket, session_sort_key +from boker.models import PlayerStats, SessionEntry, SessionSummary +from boker.storage import EventRow +from boker.utils import entry_sort_key, net_result_bucket, session_sort_key SESSION_MARKER_TYPES = {"session_open", "session_close"} diff --git a/storage.py b/boker/storage.py similarity index 100% rename from storage.py rename to boker/storage.py diff --git a/utils.py b/boker/utils.py similarity index 97% rename from utils.py rename to boker/utils.py index 44094f7..516aa06 100644 --- a/utils.py +++ b/boker/utils.py @@ -7,7 +7,7 @@ import re from markupsafe import Markup -from models import SessionEntry, SessionSummary +from boker.models import SessionEntry, SessionSummary BREAK_EVEN_TOLERANCE_CENTS = 100 diff --git a/tests/test_accounting.py b/tests/test_accounting.py index 4b7c075..1d98330 100644 --- a/tests/test_accounting.py +++ b/tests/test_accounting.py @@ -1,13 +1,13 @@ import unittest -from charts import player_session_series -from services import ( +from boker.charts import player_session_series +from boker.services import ( build_leaderboard, build_session_summaries, pending_payout_carry_items, prunable_empty_session_ids, ) -from utils import session_sort_key +from boker.utils import session_sort_key def cents(amount: float) -> int: diff --git a/tests/test_internal.py b/tests/test_internal.py index e636a2e..2a5ef14 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -4,8 +4,8 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from app import create_app -from auth import hash_password -from db import db +from boker.auth import hash_password +from boker.db import db class InternalAdminAccessTests(unittest.TestCase): @@ -22,7 +22,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.client = self.app.test_client() with self.app.app_context(): - from db_models import League, LeagueMembership, User + from boker.db_models import League, LeagueMembership, User db.create_all() self.user = User( @@ -118,7 +118,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_users_page_is_paginated(self): with self.app.app_context(): - from db_models import User + from boker.db_models import User users = [ User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123")) @@ -136,7 +136,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_leagues_page_is_paginated(self): with self.app.app_context(): - from db_models import League + from boker.db_models import League leagues = [ League( @@ -160,8 +160,8 @@ class InternalAdminAccessTests(unittest.TestCase): def test_session_growth_uses_session_date_not_created_at(self): with self.app.app_context(): - from db_models import PokerSession - from routes.internal import _growth_data + from boker.db_models import PokerSession + from boker.routes.internal import _growth_data now = datetime.now(timezone.utc) old_session = PokerSession( @@ -196,7 +196,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 403) with self.app.app_context(): - from db_models import User + from boker.db_models import User user = db.session.get(User, self.user_id) self.assertEqual(user.email, "viewer@example.com") @@ -212,7 +212,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 200) with self.app.app_context(): - from db_models import User + from boker.db_models import User user = db.session.get(User, self.user_id) self.assertEqual(user.email, "changed@example.com") @@ -220,7 +220,7 @@ class InternalAdminAccessTests(unittest.TestCase): def test_admin_delete_user_blocks_owned_leagues(self): self.login_as(self.admin_id) with self.app.app_context(): - from db_models import League, LeagueMembership + from boker.db_models import League, LeagueMembership owned_league = League( name="Manager Owned Poker", @@ -241,7 +241,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import User + from boker.db_models import User self.assertIsNotNone(db.session.get(User, self.manager_id)) @@ -251,7 +251,7 @@ class InternalAdminAccessTests(unittest.TestCase): archive_response = self.client.post(f"/internal/leagues/{self.league_id}/archive") self.assertEqual(archive_response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertIsNotNone(league.archived_at) @@ -259,7 +259,7 @@ class InternalAdminAccessTests(unittest.TestCase): restore_response = self.client.post(f"/internal/leagues/{self.league_id}/restore") self.assertEqual(restore_response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertIsNone(league.archived_at) @@ -280,7 +280,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import League + from boker.db_models import League league = db.session.get(League, self.league_id) self.assertEqual(league.name, "Saturday Poker") @@ -300,7 +300,7 @@ class InternalAdminAccessTests(unittest.TestCase): self.assertEqual(response.status_code, 302) with self.app.app_context(): - from db_models import League, LeagueMembership + from boker.db_models import League, LeagueMembership league = db.session.get(League, self.league_id) self.assertEqual(league.created_by_user_id, self.manager_id) diff --git a/tests/test_stats.py b/tests/test_stats.py index 940cdda..757b885 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,7 +1,7 @@ import unittest -from models import SessionEntry, SessionSummary -from services import build_leaderboard +from boker.models import SessionEntry, SessionSummary +from boker.services import build_leaderboard class CashInAccountingTest(unittest.TestCase):