Move application code into package

This commit is contained in:
SowinskiBraeden committed 2026-06-28 17:41:11 -07:00
1 parent 83c9fec489
commit e376b3628a
25 files changed
+285 -269

No files matched your search

View File
Whitespace-only changes.
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit
from flask import Blueprint, flash, redirect, render_template, request, session as flask_session, url_for
from boker.auth import (
current_user_id,
generate_invite_token,
generate_reset_token,
hash_password,
log_user_in,
log_user_out,
login_required,
normalize_email,
verify_invite_token,
verify_password,
verify_reset_token,
)
from boker.db import database_extensions_available, db
from boker.extensions import limiter
account_bp = Blueprint("account", __name__, url_prefix="/account")
EMAIL_VERIFICATION_TTL = timedelta(minutes=15)
def db_ready() -> bool:
return database_extensions_available() and db is not None
def safe_next_url(default: str) -> str:
next_url = request.args.get("next", "").strip()
if not next_url:
return default
parsed = urlsplit(next_url)
if parsed.scheme or parsed.netloc:
return default
if not next_url.startswith("/"):
return default
return next_url
def _verification_next(default: str) -> str:
next_url = flask_session.get("pending_verification_next")
if isinstance(next_url, str) and next_url.startswith("/") and not urlsplit(next_url).netloc:
return next_url
return default
def _verification_sent_at_valid(sent_at) -> bool:
if sent_at is None:
return False
if sent_at.tzinfo is None:
sent_at = sent_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - sent_at <= EMAIL_VERIFICATION_TTL
def _new_verification_code() -> str:
return f"{secrets.randbelow(1000000):06d}"
def _issue_verification_code(user) -> str:
from boker.db_models import utc_now
code = _new_verification_code()
user.email_verification_code_hash = hash_password(code)
user.email_verification_sent_at = utc_now()
return code
def _send_verification_code(user) -> None:
from boker.emails import send_email_verification_code
code = _issue_verification_code(user)
db.session.commit()
send_email_verification_code(user.email, code)
def _start_email_verification(user, next_url: str) -> str:
flask_session["pending_verification_user_id"] = user.id
flask_session["pending_verification_next"] = next_url
try:
_send_verification_code(user)
flash("Check your email for a verification code.", "success")
except Exception:
flash("Account created, but we could not send a verification code. Try resending it.", "error")
return url_for("account.verify_email")
@account_bp.get("/")
@login_required
def home():
return redirect(url_for("leagues.index"))
@account_bp.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def register():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return render_template("account_register.html", form={})
form = {
"email": (request.form.get("email") or request.args.get("email") or "").strip(),
}
if request.method == "POST":
from boker.league_repositories import create_user, find_user_by_email
email = normalize_email(form["email"])
password = request.form.get("password", "")
confirm_password = request.form.get("confirm_password", "")
existing_user = find_user_by_email(email)
if not email or "@" not in email:
flash("Enter a valid email address.", "error")
elif len(password) < 8:
flash("Password must be at least 8 characters.", "error")
elif password != confirm_password:
flash("Passwords do not match.", "error")
elif existing_user and existing_user.disabled_at is None:
flash("An account already exists for that email.", "error")
else:
if existing_user:
user = existing_user
user.password_hash = hash_password(password)
user.disabled_at = None
user.email_verified_at = None
user.email_verification_code_hash = None
user.email_verification_sent_at = None
else:
user = create_user(email, password)
db.session.flush()
return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))
return render_template("account_register.html", form=form)
@account_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("20 per minute")
def login():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return render_template("account_login.html", form={})
form = {
"email": request.form.get("email", "").strip(),
}
if request.method == "POST":
from boker.league_repositories import find_user_by_email
user = find_user_by_email(form["email"])
password = request.form.get("password", "")
if user is None or not verify_password(user.password_hash, password):
flash("Invalid email or password.", "error")
elif user.disabled_at is not None:
flash("That account is disabled.", "error")
elif user.email_verified_at is None:
flask_session["pending_verification_user_id"] = user.id
flask_session["pending_verification_next"] = safe_next_url(url_for("leagues.index"))
try:
_send_verification_code(user)
flash("Verify your email to continue. We sent you a new code.", "success")
except Exception:
flash("Verify your email to continue. We could not send a new code.", "error")
return redirect(url_for("account.verify_email"))
else:
log_user_in(user.id)
next_url = safe_next_url(url_for("leagues.index"))
flash("Logged in.", "success")
return redirect(next_url)
return render_template("account_login.html", form=form)
@account_bp.route("/verify-email", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def verify_email():
if current_user_id():
return redirect(safe_next_url(url_for("leagues.index")))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from boker.db_models import User, utc_now
user_id = flask_session.get("pending_verification_user_id")
user = db.session.get(User, user_id) if user_id else None
if user is None or user.disabled_at is not None:
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
flash("Start again to verify your email.", "error")
return redirect(url_for("account.register"))
if user.email_verified_at is not None:
log_user_in(user.id)
next_url = _verification_next(url_for("leagues.index"))
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
return redirect(next_url)
if request.method == "POST":
code = "".join(ch for ch in request.form.get("code", "") if ch.isdigit())
if len(code) != 6:
flash("Enter the six-digit verification code.", "error")
elif not _verification_sent_at_valid(user.email_verification_sent_at):
flash("That code has expired. Request a new one.", "error")
elif not user.email_verification_code_hash or not verify_password(user.email_verification_code_hash, code):
flash("That verification code is not correct.", "error")
else:
user.email_verified_at = utc_now()
user.email_verification_code_hash = None
user.email_verification_sent_at = None
db.session.commit()
log_user_in(user.id)
next_url = _verification_next(url_for("leagues.index"))
flask_session.pop("pending_verification_user_id", None)
flask_session.pop("pending_verification_next", None)
flash("Email verified.", "success")
return redirect(next_url)
return render_template("account_verify_email.html", email=user.email)
@account_bp.post("/verify-email/resend")
@limiter.limit("3 per minute")
def resend_verification_code():
if current_user_id():
return redirect(url_for("leagues.index"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from boker.db_models import User
user_id = flask_session.get("pending_verification_user_id")
user = db.session.get(User, user_id) if user_id else None
if user is None or user.disabled_at is not None:
flash("Start again to verify your email.", "error")
return redirect(url_for("account.register"))
try:
_send_verification_code(user)
flash("A new verification code has been sent.", "success")
except Exception:
flash("We could not send a new code. Check your mail configuration.", "error")
return redirect(url_for("account.verify_email"))
@account_bp.post("/logout")
def logout():
log_user_out()
flash("Logged out.", "success")
return redirect(url_for("public.home"))
@account_bp.get("/settings")
@login_required
def settings():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("leagues.index"))
from boker.db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("public.home"))
return render_template("account_settings.html", user=user)
@account_bp.post("/settings/email")
@login_required
def update_email():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from boker.db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
new_email = normalize_email(request.form.get("email", ""))
current_password = request.form.get("current_password", "")
if not new_email or "@" not in new_email:
flash("Enter a valid email address.", "error")
elif not verify_password(user.password_hash, current_password):
flash("Current password is incorrect.", "error")
else:
from boker.league_repositories import find_user_by_email
existing = find_user_by_email(new_email)
if existing and existing.id != user.id:
flash("That email is already in use.", "error")
else:
user.email = new_email
db.session.commit()
flash("Email updated.", "success")
return redirect(url_for("account.settings"))
@account_bp.post("/settings/password")
@login_required
def update_password():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from boker.db_models import User
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not verify_password(user.password_hash, current_password):
flash("Current password is incorrect.", "error")
elif len(new_password) < 8:
flash("New password must be at least 8 characters.", "error")
elif new_password != confirm_password:
flash("Passwords do not match.", "error")
else:
user.password_hash = hash_password(new_password)
db.session.commit()
flash("Password changed.", "success")
return redirect(url_for("account.settings"))
@account_bp.post("/disable")
@login_required
def disable_account():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from boker.db_models import League, User, utc_now
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
confirm = request.form.get("confirm", "").strip()
current_password = request.form.get("current_password", "")
if confirm != "DISABLE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("account.settings"))
if not verify_password(user.password_hash, current_password):
flash("Password is incorrect.", "error")
return redirect(url_for("account.settings"))
owned_leagues = League.query.filter_by(created_by_user_id=user.id, archived_at=None).all()
for league in owned_leagues:
league.archived_at = utc_now()
user.disabled_at = utc_now()
db.session.commit()
log_user_out()
flash("Your account has been disabled.", "success")
return redirect(url_for("public.home"))
@account_bp.post("/delete")
@login_required
def delete_account():
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.settings"))
from boker.db_models import League, LeagueMembership, User, utc_now
from boker.league_repositories import delete_league
user = db.session.get(User, current_user_id())
if user is None:
flash("User not found.", "error")
return redirect(url_for("account.settings"))
confirm = request.form.get("confirm", "").strip()
current_password = request.form.get("current_password", "")
if confirm != "DELETE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("account.settings"))
if not verify_password(user.password_hash, current_password):
flash("Password is incorrect.", "error")
return redirect(url_for("account.settings"))
for league in League.query.filter_by(created_by_user_id=user.id).all():
delete_league(league)
LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
db.session.delete(user)
db.session.commit()
log_user_out()
flash("Your account and all data have been permanently deleted.", "success")
return redirect(url_for("public.home"))
@account_bp.route("/forgot-password", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def forgot_password():
if current_user_id():
return redirect(url_for("leagues.index"))
if request.method == "POST":
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.forgot_password"))
from boker.emails import send_password_reset
from flask import current_app
from boker.league_repositories import find_user_by_email
email = normalize_email(request.form.get("email", ""))
user = find_user_by_email(email)
if user and user.disabled_at is None:
token = generate_reset_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
try:
send_password_reset(email, reset_url)
except Exception:
pass
flash("If that email is registered, a reset link has been sent.", "success")
return redirect(url_for("account.login"))
return render_template("forgot_password.html")
@account_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
if current_user_id():
return redirect(url_for("leagues.index"))
user_id = verify_reset_token(token)
if user_id is None:
flash("That reset link is invalid or has expired.", "error")
return redirect(url_for("account.forgot_password"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("account.login"))
from boker.db_models import User
user = db.session.get(User, user_id)
if user is None or user.disabled_at is not None:
flash("That reset link is invalid or has expired.", "error")
return redirect(url_for("account.forgot_password"))
if request.method == "POST":
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if len(new_password) < 8:
flash("Password must be at least 8 characters.", "error")
elif new_password != confirm_password:
flash("Passwords do not match.", "error")
else:
user.password_hash = hash_password(new_password)
db.session.commit()
flash("Password reset. You can now log in.", "success")
return redirect(url_for("account.login"))
return render_template("reset_password.html", token=token)
@account_bp.get("/accept-invite/<token>")
def accept_invite(token):
data = verify_invite_token(token)
if data is None:
flash("That invitation link is invalid or has expired.", "error")
return redirect(url_for("public.home"))
if not db_ready():
flash("Account database is not available.", "error")
return redirect(url_for("leagues.index"))
from boker.db_models import User
from boker.league_repositories import add_league_member, find_league_by_id, find_membership, find_user_by_email
invite_email = normalize_email(data.get("email", ""))
invite_next = url_for("account.accept_invite", token=token)
if not current_user_id():
if find_user_by_email(invite_email):
flash("Sign in to accept your invitation.", "info")
return redirect(url_for("account.login", next=invite_next))
flash("Create an account to accept your invitation.", "info")
return redirect(url_for("account.register", next=invite_next, email=invite_email))
user = db.session.get(User, current_user_id())
if user is None or user.email != invite_email:
flash("This invitation was sent to a different email address.", "error")
return redirect(url_for("leagues.index"))
league = find_league_by_id(data["league_id"])
if league is None:
flash("That league no longer exists.", "error")
return redirect(url_for("leagues.index"))
existing = find_membership(league.id, user.id)
if existing:
flash(f"You are already a member of {league.name}.", "info")
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
add_league_member(league.id, user.id, data["role"], data.get("invited_by", ""))
db.session.commit()
flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
+653
View File
@@ -0,0 +1,653 @@
#!/usr/bin/env python3
from __future__ import annotations
import secrets
import string
from datetime import datetime, timedelta, timezone
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from boker.auth import (
current_user_id,
generate_reset_token,
hash_password,
normalize_email,
site_admin_required,
)
from boker.db import db
from boker.db_models import LedgerEvent, League, LeagueMembership, Player, PokerSession, User, utc_now
from boker.league_repositories import delete_league, transfer_league_ownership
from boker.utils import slugify
internal_bp = Blueprint("internal", __name__, url_prefix="/internal")
def _current_admin() -> User:
return User.query.filter_by(id=current_user_id()).one()
def _user_or_404(user_id: str) -> User:
return User.query.filter_by(id=user_id).one_or_404()
def _league_or_404(league_id: str) -> League:
return League.query.filter_by(id=league_id).one_or_404()
def _owner_for_league(league_id: str) -> User | None:
row = (
db.session.query(User)
.join(LeagueMembership, LeagueMembership.user_id == User.id)
.filter(
LeagueMembership.league_id == league_id,
LeagueMembership.role == "owner",
LeagueMembership.disabled_at.is_(None),
)
.one_or_none()
)
return row
def _temporary_password(length: int = 14) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
_CASH_IN_TYPES = ("buyin", "debt_repayment", "front_collected")
_PAGE_SIZE = 50
def _page_number() -> int:
try:
return max(1, int(request.args.get("page", "1")))
except (TypeError, ValueError):
return 1
def _paginate(query, page: int, per_page: int = _PAGE_SIZE) -> dict:
total = query.order_by(None).count()
pages = max(1, (total + per_page - 1) // per_page)
page = min(page, pages)
items = query.limit(per_page).offset((page - 1) * per_page).all()
return {
"items": items,
"page": page,
"pages": pages,
"per_page": per_page,
"total": total,
"has_prev": page > 1,
"has_next": page < pages,
"prev_page": page - 1,
"next_page": page + 1,
"start": ((page - 1) * per_page + 1) if total else 0,
"end": min(page * per_page, total),
}
def _site_stats() -> dict:
now = datetime.now(timezone.utc)
today = now.date()
day_ago = now - timedelta(days=1)
week_ago = now - timedelta(days=7)
week_ago_date = week_ago.date()
month_ago = now - timedelta(days=30)
cash_in_cents = (
db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
.filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type.in_(_CASH_IN_TYPES))
.scalar()
)
cash_out_cents = (
db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0))
.filter(LedgerEvent.voided_at.is_(None), LedgerEvent.event_type == "paid_out")
.scalar()
)
# avg distinct players per session (via buyin events)
session_player_counts = (
db.session.query(
LedgerEvent.session_id,
db.func.count(db.func.distinct(LedgerEvent.player_id)).label("n"),
)
.filter(LedgerEvent.event_type == "buyin", LedgerEvent.voided_at.is_(None))
.group_by(LedgerEvent.session_id)
.subquery()
)
avg_players = db.session.query(
db.func.coalesce(db.func.avg(session_player_counts.c.n), 0)
).scalar()
users_in_leagues = (
db.session.query(db.func.count(db.func.distinct(LeagueMembership.user_id)))
.filter(LeagueMembership.disabled_at.is_(None))
.scalar()
)
leagues_with_sessions = (
db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
.scalar()
)
leagues_active_7d = (
db.session.query(db.func.count(db.func.distinct(PokerSession.league_id)))
.filter(PokerSession.session_date >= week_ago_date)
.scalar()
)
sessions_with_events = (
db.session.query(db.func.count(db.func.distinct(LedgerEvent.session_id)))
.filter(LedgerEvent.voided_at.is_(None))
.scalar()
)
session_count = PokerSession.query.count()
active_league_count = League.query.filter_by(archived_at=None).count()
active_users_24h = User.query.filter(User.last_login_at >= day_ago).count()
active_users_7d = User.query.filter(User.last_login_at >= week_ago).count()
active_users_30d = User.query.filter(User.last_login_at >= month_ago).count()
returning_users_7d = User.query.filter(User.created_at < week_ago, User.last_login_at >= week_ago).count()
returning_users_30d = User.query.filter(User.created_at < month_ago, User.last_login_at >= month_ago).count()
total_users = User.query.count()
voided_events = LedgerEvent.query.filter(LedgerEvent.voided_at.is_not(None)).count()
ledger_events = LedgerEvent.query.filter_by(voided_at=None).count()
return {
"users": total_users,
"active_users": User.query.filter_by(disabled_at=None).count(),
"disabled_users": User.query.filter(User.disabled_at.is_not(None)).count(),
"verified_users": User.query.filter(User.email_verified_at.is_not(None)).count(),
"unverified_users": User.query.filter(User.email_verified_at.is_(None)).count(),
"users_in_leagues": int(users_in_leagues or 0),
"orphan_users": total_users - int(users_in_leagues or 0),
"site_admins": User.query.filter_by(is_site_admin=True, disabled_at=None).count(),
"new_users_24h": User.query.filter(User.created_at >= day_ago).count(),
"new_users_7d": User.query.filter(User.created_at >= week_ago).count(),
"active_users_24h": active_users_24h,
"active_users_7d": active_users_7d,
"active_users_30d": active_users_30d,
"returning_users_7d": returning_users_7d,
"returning_users_30d": returning_users_30d,
"leagues": League.query.count(),
"active_leagues": active_league_count,
"archived_leagues": League.query.filter(League.archived_at.is_not(None)).count(),
"public_leagues": League.query.filter_by(visibility="public", archived_at=None).count(),
"leagues_with_sessions": int(leagues_with_sessions or 0),
"leagues_without_sessions": max(active_league_count - int(leagues_with_sessions or 0), 0),
"leagues_active_7d": int(leagues_active_7d or 0),
"sessions": session_count,
"open_sessions": PokerSession.query.filter_by(status="open").count(),
"sessions_24h": PokerSession.query.filter(PokerSession.session_date == today).count(),
"sessions_7d": PokerSession.query.filter(PokerSession.session_date >= week_ago_date).count(),
"sessions_without_events": max(session_count - int(sessions_with_events or 0), 0),
"ledger_events": ledger_events,
"ledger_events_24h": LedgerEvent.query.filter(LedgerEvent.created_at >= day_ago).count(),
"ledger_events_7d": LedgerEvent.query.filter(LedgerEvent.created_at >= week_ago).count(),
"voided_events": voided_events,
"void_rate": round((voided_events / (ledger_events + voided_events)) * 100, 1) if ledger_events or voided_events else 0,
"cash_in_cents": int(cash_in_cents or 0),
"cash_out_cents": int(cash_out_cents or 0),
"avg_cash_in_per_session_cents": int((cash_in_cents or 0) / session_count) if session_count else 0,
"avg_sessions_per_active_league": round(session_count / active_league_count, 1) if active_league_count else 0,
"avg_players_per_session": round(float(avg_players or 0), 1),
}
def _top_leagues(limit: int = 8):
rows = (
db.session.query(League, db.func.count(PokerSession.id).label("n"))
.outerjoin(PokerSession, PokerSession.league_id == League.id)
.filter(League.archived_at.is_(None))
.group_by(League.id)
.order_by(db.func.count(PokerSession.id).desc())
.limit(limit)
.all()
)
return [(league, count) for league, count in rows]
def _sessions_by_weekday() -> dict:
from sqlalchemy import func as f
rows = (
db.session.query(
f.strftime("%w", PokerSession.session_date).label("dow"),
f.count(PokerSession.id).label("cnt"),
)
.group_by("dow")
.all()
)
counts = {str(i): 0 for i in range(7)}
for row in rows:
counts[str(row.dow)] = row.cnt
return {
"labels": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"data": [counts[str(i)] for i in range(7)],
}
def _growth_data(weeks: int = 8) -> dict:
now = datetime.now(timezone.utc)
labels, user_signups, session_counts, league_counts = [], [], [], []
for i in range(weeks - 1, -1, -1):
start = now - timedelta(weeks=i + 1)
end = now - timedelta(weeks=i)
start_date = start.date()
end_date = end.date()
labels.append(start.strftime("%b %-d"))
user_signups.append(User.query.filter(User.created_at >= start, User.created_at < end).count())
session_counts.append(
PokerSession.query.filter(
PokerSession.session_date >= start_date,
PokerSession.session_date < end_date,
).count()
)
league_counts.append(League.query.filter(League.created_at >= start, League.created_at < end).count())
return {
"labels": labels,
"user_signups": user_signups,
"session_counts": session_counts,
"league_counts": league_counts,
}
def _trend_delta(current: int | float, previous: int | float) -> dict:
if previous == 0:
pct = None if current else 0
else:
pct = round(((current - previous) / previous) * 100, 1)
direction = "flat"
if current > previous:
direction = "up"
elif current < previous:
direction = "down"
return {"current": current, "previous": previous, "pct": pct, "direction": direction}
def _trend_cards(growth: dict) -> list[dict]:
labels = growth["labels"]
user_series = growth["user_signups"]
session_series = growth["session_counts"]
league_series = growth["league_counts"]
current_start = datetime.now(timezone.utc) - timedelta(days=7)
previous_start = current_start - timedelta(days=7)
active_current = User.query.filter(User.last_login_at >= current_start).count()
active_previous = User.query.filter(
User.last_login_at >= previous_start,
User.last_login_at < current_start,
).count()
event_current = LedgerEvent.query.filter(
LedgerEvent.voided_at.is_(None),
LedgerEvent.created_at >= current_start,
).count()
event_previous = LedgerEvent.query.filter(
LedgerEvent.voided_at.is_(None),
LedgerEvent.created_at >= previous_start,
LedgerEvent.created_at < current_start,
).count()
return [
{
"key": "signups",
"label": "New users",
"value": user_series[-1] if user_series else 0,
"delta": _trend_delta(user_series[-1] if user_series else 0, user_series[-2] if len(user_series) > 1 else 0),
"labels": labels,
"series": user_series,
},
{
"key": "active",
"label": "Active users",
"value": active_current,
"delta": _trend_delta(active_current, active_previous),
"labels": ["Previous", "Current"],
"series": [active_previous, active_current],
},
{
"key": "sessions",
"label": "Sessions",
"value": session_series[-1] if session_series else 0,
"delta": _trend_delta(session_series[-1] if session_series else 0, session_series[-2] if len(session_series) > 1 else 0),
"labels": labels,
"series": session_series,
},
{
"key": "ledger",
"label": "Ledger records",
"value": event_current,
"delta": _trend_delta(event_current, event_previous),
"labels": ["Previous", "Current"],
"series": [event_previous, event_current],
},
{
"key": "leagues",
"label": "New leagues",
"value": league_series[-1] if league_series else 0,
"delta": _trend_delta(league_series[-1] if league_series else 0, league_series[-2] if len(league_series) > 1 else 0),
"labels": labels,
"series": league_series,
},
]
@internal_bp.get("/")
@site_admin_required
def dashboard():
recent_users = User.query.order_by(User.created_at.desc()).limit(10).all()
recent_leagues = League.query.order_by(League.created_at.desc()).limit(8).all()
top_leagues = _top_leagues()
owners = {league.id: _owner_for_league(league.id) for league in recent_leagues + [l for l, _ in top_leagues]}
growth = _growth_data()
return render_template(
"internal_dashboard.html",
admin_user=_current_admin(),
stats=_site_stats(),
growth=growth,
trend_cards=_trend_cards(growth),
weekday_data=_sessions_by_weekday(),
top_leagues=top_leagues,
recent_users=recent_users,
recent_leagues=recent_leagues,
owners=owners,
)
@internal_bp.get("/users")
@site_admin_required
def users():
q = request.args.get("q", "").strip()
page = _page_number()
query = User.query
if q:
query = query.filter(User.email.ilike(f"%{normalize_email(q)}%"))
pagination = _paginate(query.order_by(User.created_at.desc()), page)
return render_template("internal_users.html", users=pagination["items"], pagination=pagination, q=q)
@internal_bp.get("/users/<user_id>")
@site_admin_required
def user_detail(user_id: str):
user = _user_or_404(user_id)
memberships = (
db.session.query(LeagueMembership, League)
.join(League, League.id == LeagueMembership.league_id)
.filter(LeagueMembership.user_id == user.id)
.order_by(LeagueMembership.created_at.desc())
.all()
)
owned_leagues = League.query.filter_by(created_by_user_id=user.id).order_by(League.created_at.desc()).all()
event_count = LedgerEvent.query.filter_by(created_by_user_id=user.id).count()
return render_template(
"internal_user_detail.html",
user=user,
memberships=memberships,
owned_leagues=owned_leagues,
event_count=event_count,
current_admin_id=current_user_id(),
)
@internal_bp.post("/users/<user_id>/email")
@site_admin_required
def update_user_email(user_id: str):
user = _user_or_404(user_id)
new_email = normalize_email(request.form.get("email", ""))
if not new_email or "@" not in new_email:
flash("Enter a valid email address.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
existing = User.query.filter(User.email == new_email, User.id != user.id).one_or_none()
if existing:
flash("That email is already in use.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
user.email = new_email
db.session.commit()
flash("User email updated.", "success")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.post("/users/<user_id>/password-reset")
@site_admin_required
def send_user_password_reset(user_id: str):
user = _user_or_404(user_id)
if user.disabled_at is not None:
flash("Disabled users cannot receive password resets.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
from boker.emails import send_password_reset
token = generate_reset_token(user.id)
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
reset_url = f"{base_url}{url_for('account.reset_password', token=token)}"
try:
send_password_reset(user.email, reset_url)
flash("Password reset email sent.", "success")
except Exception:
flash("Password reset email could not be sent. Check mail configuration.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.post("/users/<user_id>/temporary-password")
@site_admin_required
def create_user_temporary_password(user_id: str):
user = _user_or_404(user_id)
if user.disabled_at is not None:
flash("Disabled users cannot receive temporary passwords.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
from boker.emails import send_temporary_password
temporary_password = _temporary_password()
user.password_hash = hash_password(temporary_password)
db.session.commit()
try:
send_temporary_password(user.email, temporary_password)
flash("Temporary password created and emailed.", "success")
except Exception:
flash(f"Temporary password created but email failed: {temporary_password}", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.post("/users/<user_id>/disable")
@site_admin_required
def disable_user(user_id: str):
user = _user_or_404(user_id)
if user.id == current_user_id():
flash("You cannot disable your own admin account.", "error")
elif user.disabled_at is None:
user.disabled_at = utc_now()
db.session.commit()
flash("User disabled.", "success")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.post("/users/<user_id>/restore")
@site_admin_required
def restore_user(user_id: str):
user = _user_or_404(user_id)
user.disabled_at = None
db.session.commit()
flash("User restored.", "success")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.post("/users/<user_id>/delete")
@site_admin_required
def delete_user(user_id: str):
user = _user_or_404(user_id)
confirm = request.form.get("confirm", "").strip()
if user.id == current_user_id():
flash("You cannot delete your own admin account.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
if confirm != "DELETE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
owned_leagues = League.query.filter_by(created_by_user_id=user.id).count()
if owned_leagues:
flash("Transfer or delete this user's owned leagues before deleting the account.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
LeagueMembership.query.filter_by(user_id=user.id).delete(synchronize_session=False)
try:
db.session.delete(user)
db.session.commit()
flash("User deleted.", "success")
return redirect(url_for("internal.users"))
except IntegrityError:
db.session.rollback()
flash("User has audit history and cannot be deleted safely. Disable the account instead.", "error")
return redirect(url_for("internal.user_detail", user_id=user.id))
@internal_bp.get("/leagues")
@site_admin_required
def leagues():
q = request.args.get("q", "").strip()
page = _page_number()
query = db.session.query(League)
if q:
term = f"%{q.strip()}%"
query = (
query.outerjoin(LeagueMembership, LeagueMembership.league_id == League.id)
.outerjoin(User, User.id == LeagueMembership.user_id)
.filter(
or_(
League.name.ilike(term),
League.slug.ilike(term),
User.email.ilike(term),
)
)
.distinct()
)
pagination = _paginate(query.order_by(League.created_at.desc()), page)
leagues = pagination["items"]
owners = {league.id: _owner_for_league(league.id) for league in leagues}
return render_template("internal_leagues.html", leagues=leagues, owners=owners, pagination=pagination, q=q)
@internal_bp.get("/leagues/<league_id>")
@site_admin_required
def league_detail(league_id: str):
league = _league_or_404(league_id)
members = (
db.session.query(LeagueMembership, User)
.join(User, User.id == LeagueMembership.user_id)
.filter(LeagueMembership.league_id == league.id)
.order_by(LeagueMembership.created_at.asc())
.all()
)
stats = {
"members": len([m for m, _u in members if m.disabled_at is None]),
"players": Player.query.filter_by(league_id=league.id).count(),
"sessions": PokerSession.query.filter_by(league_id=league.id).count(),
"events": LedgerEvent.query.filter_by(league_id=league.id).count(),
"voided_events": LedgerEvent.query.filter(LedgerEvent.league_id == league.id, LedgerEvent.voided_at.is_not(None)).count(),
}
owner = _owner_for_league(league.id)
return render_template("internal_league_detail.html", league=league, owner=owner, members=members, stats=stats)
@internal_bp.post("/leagues/<league_id>/archive")
@site_admin_required
def archive_league(league_id: str):
league = _league_or_404(league_id)
league.archived_at = utc_now()
db.session.commit()
flash("League archived.", "success")
return redirect(url_for("internal.league_detail", league_id=league.id))
@internal_bp.post("/leagues/<league_id>/settings")
@site_admin_required
def update_league_settings(league_id: str):
league = _league_or_404(league_id)
raw_eligible = request.form.get("eligible_min_sessions", "3").strip()
raw_break_even = request.form.get("break_even_dollars", "1.00").strip()
name = request.form.get("name", "").strip()
description = request.form.get("description", "").strip()
visibility = request.form.get("visibility", "private").strip()
try:
eligible_min = int(raw_eligible)
break_even_cents = round(float(raw_break_even) * 100)
except (ValueError, TypeError):
flash("Eligible sessions and break-even threshold must be valid numbers.", "error")
return redirect(url_for("internal.league_detail", league_id=league.id))
if len(name) < 2:
flash("League name must be at least 2 characters.", "error")
elif visibility not in ("private", "public"):
flash("Invalid visibility value.", "error")
elif eligible_min < 1 or eligible_min > 100:
flash("Eligible minimum must be between 1 and 100.", "error")
elif break_even_cents < 0 or break_even_cents > 10000:
flash("Break-even threshold must be between $0.00 and $100.00.", "error")
else:
league.name = name
league.slug = slugify(name)
league.description = description or None
league.visibility = visibility
league.eligible_min_sessions = eligible_min
league.break_even_cents = break_even_cents
db.session.commit()
flash("League settings updated.", "success")
return redirect(url_for("internal.league_detail", league_id=league.id))
@internal_bp.post("/leagues/<league_id>/restore")
@site_admin_required
def restore_league(league_id: str):
league = _league_or_404(league_id)
league.archived_at = None
db.session.commit()
flash("League restored.", "success")
return redirect(url_for("internal.league_detail", league_id=league.id))
@internal_bp.post("/leagues/<league_id>/transfer")
@site_admin_required
def transfer_league_owner(league_id: str):
league = _league_or_404(league_id)
new_owner_email = normalize_email(request.form.get("new_owner_email", ""))
new_owner = User.query.filter_by(email=new_owner_email, disabled_at=None).one_or_none()
current_owner = _owner_for_league(league.id)
if current_owner is None:
flash("Current owner could not be found.", "error")
elif new_owner is None:
flash("New owner must be an active user.", "error")
else:
membership = LeagueMembership.query.filter_by(league_id=league.id, user_id=new_owner.id).one_or_none()
if membership is None:
membership = LeagueMembership(league_id=league.id, user_id=new_owner.id, role="manager")
db.session.add(membership)
db.session.flush()
membership.disabled_at = None
result = transfer_league_ownership(league, current_owner.id, new_owner.id)
if result is None:
flash("Ownership transfer failed.", "error")
else:
db.session.commit()
flash("League ownership transferred.", "success")
return redirect(url_for("internal.league_detail", league_id=league.id))
db.session.rollback()
return redirect(url_for("internal.league_detail", league_id=league.id))
@internal_bp.post("/leagues/<league_id>/delete")
@site_admin_required
def delete_internal_league(league_id: str):
league = _league_or_404(league_id)
confirm = request.form.get("confirm", "").strip()
if confirm != "DELETE":
flash("Confirmation text did not match.", "error")
return redirect(url_for("internal.league_detail", league_id=league.id))
delete_league(league)
db.session.commit()
flash("League deleted.", "success")
return redirect(url_for("internal.leagues"))
File diff suppressed because it is too large. Load diff
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask import current_app
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 boker.storage import load_events
from boker.utils import session_label, session_sort_key
public_bp = Blueprint("public", __name__)
@public_bp.get("/")
def home():
return render_template("landing.html")
@public_bp.get("/help")
def help():
return render_template("support.html")
@public_bp.get("/privacy")
def privacy():
return render_template("privacy.html")
@public_bp.get("/terms")
def terms():
return render_template("terms.html")
@public_bp.get("/explore")
def explore():
from boker.auth import is_logged_in
from boker.db import database_extensions_available
from boker.league_repositories import league_counts, list_public_leagues
q = request.args.get("q", "").strip()
leagues = list_public_leagues(q) if database_extensions_available() else []
counts = {league.id: league_counts(league.id) for league in leagues}
return render_template("explore.html", leagues=leagues, counts=counts, q=q)
@public_bp.get("/leaderboard")
def leaderboard():
events = load_events(current_app.config["DATA_PATH"])
all_sessions = build_session_summaries(events)
ordered_sessions = sorted(all_sessions, key=session_sort_key)
session_ids = [s.session_id for s 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 = build_leaderboard(filtered_sessions)
previous_board = build_leaderboard(previous_sessions)
board = apply_rank_changes(board, previous_board)
eligible_count = sum(1 for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS)
all_count = len(board)
recent_sessions_slice = filtered_sessions[-5:]
recent_count = len({
entry.player_name
for s in recent_sessions_slice
for entry in s.entries
})
if mode == "recent":
mode_board = build_leaderboard(recent_sessions_slice)
main_board = mode_board
provisional_board = []
elif mode == "eligible":
main_board = [p for p in board if p.sessions_played >= ELIGIBLE_MIN_SESSIONS]
provisional_board = [p for p in board if p.sessions_played < ELIGIBLE_MIN_SESSIONS]
else:
main_board = board
provisional_board = []
chart_data = cumulative_profit_series(filtered_sessions)
cash_paid_out_cents = sum(s.total_paid_out_cents for s in all_sessions)
return render_template(
"leaderboard.html",
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,
)
@public_bp.get("/sessions")
def sessions():
events = load_events(current_app.config["DATA_PATH"])
all_sessions = build_session_summaries(events)
return render_template(
"sessions.html",
sessions=all_sessions,
session_label=session_label,
)
@public_bp.get("/sessions/<session_id>")
def session_detail(session_id: str):
events = load_events(current_app.config["DATA_PATH"])
all_sessions = build_session_summaries(events)
target_session = next(
(s for s in all_sessions if s.session_id == session_id),
None,
)
if target_session is None:
flash("That session was not found.", "error")
return redirect(url_for("public.sessions"))
chronological_sessions = sorted(all_sessions, key=session_sort_key)
chronological_index = next(
index
for index, s in enumerate(chronological_sessions)
if s.session_id == session_id
)
target_session = chronological_sessions[chronological_index]
session_number = chronological_index + 1
prev_session = (
chronological_sessions[chronological_index - 1]
if chronological_index > 0
else None
)
next_session = (
chronological_sessions[chronological_index + 1]
if chronological_index < len(chronological_sessions) - 1
else None
)
return render_template(
"session_detail.html",
session=target_session,
next_session=next_session,
prev_session=prev_session,
session_number=session_number,
raw_events=session_events(events, session_id),
chart_data=session_breakdown_series(target_session),
session_label=session_label,
)
@public_bp.get("/players/<player_name>")
def player_detail(player_name: str):
events = load_events(current_app.config["DATA_PATH"])
all_sessions = build_session_summaries(events)
board = build_leaderboard(all_sessions)
player_stats = next(
(player for player in board if player.player_name == player_name), None
)
if player_stats is None:
flash("That player was not found.", "error")
return redirect(url_for("public.leaderboard"))
player_rank = next(
(
index
for index, ranked_player in enumerate(board, start=1)
if ranked_player.player_name == player_name
),
None,
)
chart_data = player_session_series(all_sessions, player_name)
return render_template(
"player_detail.html",
player=player_stats,
player_rank=player_rank,
chart_data=chart_data,
)