diff --git a/app.py b/app.py index a660084..529ec5a 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,12 @@ from __future__ import annotations import click from flask import Flask, render_template -from auth import current_user_id, is_logged_in, normalize_email +from auth import current_user_id, is_logged_in, is_site_admin, normalize_email from config import Config 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 @@ -36,10 +37,12 @@ def create_app(config_overrides: dict | None = None) -> Flask: "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) diff --git a/config.py b/config.py index 5a4dfc4..d54a306 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.24" +APP_VERSION = "2.5.28" def load_local_env(env_path: Path) -> None: diff --git a/emails.py b/emails.py index 477b4eb..10391c3 100644 --- a/emails.py +++ b/emails.py @@ -127,6 +127,27 @@ def send_password_reset(to_email: str, reset_url: str) -> None: _send_message(msg) +def send_temporary_password(to_email: str, temporary_password: str) -> None: + body = ( + "A temporary password was created for your myboker.org account.\n\n" + f"Temporary password:\n\n{temporary_password}\n\n" + "Sign in with this password, then change it from account settings." + ) + msg = Message( + subject="Temporary myboker.org password", + recipients=[to_email], + body=body, + html=_html_email( + "Temporary password", + "A site administrator created a temporary password for your myboker.org account.", + code=temporary_password, + note="Sign in with this password, then change it from account settings.", + ), + sender=current_app.config.get("MAIL_DEFAULT_SENDER"), + ) + _send_message(msg) + + def send_email_verification_code(to_email: str, code: str) -> None: body = ( "Verify your myboker.org account with this code:\n\n" diff --git a/routes/internal.py b/routes/internal.py new file mode 100644 index 0000000..f83e941 --- /dev/null +++ b/routes/internal.py @@ -0,0 +1,663 @@ +#!/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 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 + + +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, cash_in_weekly = [], [], [], [] + 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() + ) + cash = ( + db.session.query(db.func.coalesce(db.func.sum(LedgerEvent.amount_cents), 0)) + .filter( + LedgerEvent.event_type.in_(_CASH_IN_TYPES), + LedgerEvent.voided_at.is_(None), + LedgerEvent.created_at >= start, + LedgerEvent.created_at < end, + ) + .scalar() + ) + cash_in_weekly.append(round(int(cash or 0) / 100, 2)) + return { + "labels": labels, + "user_signups": user_signups, + "session_counts": session_counts, + "cash_in_weekly": cash_in_weekly, + } + + +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"] + cash_series = growth["cash_in_weekly"] + 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": "volume", + "label": "Recorded volume", + "value": f"${cash_series[-1]:,.0f}" if cash_series else "$0", + "delta": _trend_delta(cash_series[-1] if cash_series else 0, cash_series[-2] if len(cash_series) > 1 else 0), + "labels": labels, + "series": cash_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/") +@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//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//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 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//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 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//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//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//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/") +@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//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//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//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//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//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")) diff --git a/static/css/style.css b/static/css/style.css index 07e2a5f..1af07e5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -239,6 +239,133 @@ h1, h2, h3, h4 { margin: 0; } .sidebar__back svg { width: 12px; height: 12px; } .sidebar__back:hover { color: var(--text-2); } +/* ---- Admin sidebar variant ---- */ +.sidebar--admin .sidebar__badge--admin { + background: var(--warn-tint); + border: 1px solid var(--warn-tint-bd); + color: var(--warn); + border-radius: var(--r-sm); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.sidebar--admin .sidebar__link.is-active { + background: var(--warn-tint); + color: var(--warn); +} +.sidebar--admin .sidebar__link.is-active svg { opacity: 1; color: var(--warn); } +.sidebar--admin .sidebar__link:hover { color: var(--warn); } + +/* ---- Internal admin dashboard ---- */ +.int-hero-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 14px; +} +.int-page-header { + margin-bottom: 20px; +} +.int-section-grid { + margin-bottom: 14px; +} +.int-section-grid--last { + margin-bottom: 40px; +} +.int-hero-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} +.int-hero-card__icon { + width: 36px; height: 36px; + border-radius: var(--r-sm); + display: flex; align-items: center; justify-content: center; + margin-bottom: 6px; +} +.int-hero-card__icon--users { background: var(--accent-chip); border: 1px solid var(--accent-chip-bd); color: var(--accent); } +.int-hero-card__icon--leagues { background: rgba(111,192,147,.12); border: 1px solid rgba(111,192,147,.25); color: var(--pos); } +.int-hero-card__icon--sessions { background: var(--warn-tint); border: 1px solid var(--warn-tint-bd); color: var(--warn); } +.int-hero-card__icon--health { background: rgba(120,180,240,.1); border: 1px solid rgba(120,180,240,.22); color: #78b4f0; } +.int-hero-card__num { + font-size: 28px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -.02em; + line-height: 1; +} +.int-hero-card__label { font-size: 12px; color: var(--muted); font-weight: 500; } +.int-hero-card__sub { font-size: 11.5px; color: var(--faintest); margin-top: 4px; } +.int-chart-panel { padding: 0; overflow: hidden; } +.int-chart-head { padding: 16px 20px 0; } +.int-chart-wrap { padding: 16px 20px 20px; position: relative; height: 220px; } +.int-trend-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} +.int-trend-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + align-items: center; + min-height: 112px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.int-trend-card__label, +.int-trend-card__delta span { + display: block; + color: var(--muted); + font-size: 11.5px; +} +.int-trend-card__value { + display: block; + margin: 4px 0; + color: var(--text-strong); + font-size: 24px; + font-weight: 700; + line-height: 1; +} +.int-trend-card__delta { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.int-trend-card__delta--up { color: var(--pos); } +.int-trend-card__delta--down { color: var(--neg); } +.int-sparkline-wrap { + height: 54px; + min-width: 0; +} +.int-breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; } +.int-breakdown-row { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 0; border-bottom: 1px solid var(--divider); font-size: 13px; +} +.int-breakdown-row:last-child { border-bottom: none; } +.int-breakdown-row__label { color: var(--muted); } +.int-breakdown-row__val { font-weight: 600; color: var(--text-strong); font-variant-numeric: tabular-nums; } + +@media (max-width: 900px) { + .int-hero-grid { grid-template-columns: repeat(2, 1fr); } + .admin-searchbar { + grid-template-columns: 1fr; + } + .admin-searchbar .btn { + justify-content: center; + } +} + /* ---- App page (paired with sidebar) ---- */ .page--app { flex: 1; @@ -1483,6 +1610,85 @@ td a:hover { color: var(--accent); } .stack { display: grid; gap: 20px; } .inline-action-form { margin: 0; } .button-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.form-stack { display: grid; gap: 14px; } +.inline-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.inline-form .field-input { + flex: 1 1 220px; + min-width: 0; +} +.searchbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 560px; +} +.searchbar .field-input { + min-width: 240px; +} +.tbl__actions { + text-align: right; + white-space: nowrap; +} +.admin-list-tools { + margin: 0 0 14px; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.admin-searchbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + gap: 8px; + align-items: center; + max-width: 720px; +} +.admin-list-panel { + overflow: hidden; +} +.admin-table { + min-width: 760px; +} +.admin-table th { + color: var(--muted); + font-size: 11px; + letter-spacing: .04em; + text-transform: uppercase; +} +.admin-table td { + vertical-align: middle; +} +.admin-table__primary a { + color: var(--text-strong); + font-weight: 700; +} +.admin-table__muted { + color: var(--muted); + font-size: 12px; +} +.pagination-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--border); +} +.pagination-row__label { + color: var(--muted); + font-size: 12px; +} +.btn.is-disabled { + opacity: .4; + pointer-events: none; +} /* Breadcrumb */ .breadcrumb { diff --git a/templates/_internal_sidebar.html b/templates/_internal_sidebar.html new file mode 100644 index 0000000..f66dd42 --- /dev/null +++ b/templates/_internal_sidebar.html @@ -0,0 +1,56 @@ + diff --git a/templates/internal_dashboard.html b/templates/internal_dashboard.html new file mode 100644 index 0000000..112be94 --- /dev/null +++ b/templates/internal_dashboard.html @@ -0,0 +1,374 @@ +{% extends "base.html" %} +{% block title %}Overview · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ Site Admin + {% if stats.open_sessions > 0 %} + {{ stats.open_sessions }} live session{{ 's' if stats.open_sessions != 1 }} + {% endif %} +
+

Overview

+

Signed in as {{ admin_user.email }}. Site health, growth, and recent activity at a glance.

+
+
+ + +
+
+
+ + + + + +
+ {{ stats.users }} + Total users + {{ stats.active_users }} active · +{{ stats.new_users_7d }} this week +
+
+
+ + + + +
+ {{ stats.active_leagues }} + Active leagues + {{ stats.public_leagues }} public · {{ stats.archived_leagues }} archived · {{ '%.1f'|format(stats.avg_sessions_per_active_league) }} avg sessions +
+
+
+ + + + +
+ {{ stats.sessions }} + Total sessions + {{ stats.open_sessions }} open · {{ stats.sessions_7d }} this week · {{ '%.1f'|format(stats.avg_players_per_session) }} avg players +
+
+
+ + + + +
+ {{ stats.active_users_30d }} + Active users · 30d + {{ stats.returning_users_30d }} returning · {{ stats.unverified_users }} unverified +
+
+ +
+ {% for card in trend_cards %} +
+
+ {{ card.label }} + {{ card.value }} + + {% if card.delta.pct is none %} + New + {% else %} + {{ '+' if card.delta.current > card.delta.previous else '' }}{{ '%.1f'|format(card.delta.pct) }}% + {% endif %} + vs previous + +
+
+ +
+
+ {% endfor %} +
+ + +
+
+
+

Growth

+

User signups · 8 weeks

+
+
+ +
+
+
+
+

Activity

+

Sessions per week · 8 weeks

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

Recorded ledger volume

+

Buy-ins and collections · 8 weeks

+
+
+ +
+
+
+
+

Patterns

+

Sessions by day of week

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

Activity breakdown

+ 24h / 7d +
+
+
+
+
New users (24h){{ stats.new_users_24h }}
+
New users (7d){{ stats.new_users_7d }}
+
Active users (24h){{ stats.active_users_24h }}
+
Active users (7d){{ stats.active_users_7d }}
+
Active users (30d){{ stats.active_users_30d }}
+
Returning users (7d){{ stats.returning_users_7d }}
+
Returning users (30d){{ stats.returning_users_30d }}
+
Users in leagues{{ stats.users_in_leagues }}
+
No-league signups{{ stats.orphan_users }}
+
Verified accounts{{ stats.verified_users }}
+
Unverified{{ stats.unverified_users }}
+
Disabled{{ stats.disabled_users }}
+
Site admins{{ stats.site_admins }}
+
+
+
Sessions (24h){{ stats.sessions_24h }}
+
Sessions (7d){{ stats.sessions_7d }}
+
Open sessions{{ stats.open_sessions }}
+
Leagues active (7d){{ stats.leagues_active_7d }}
+
Leagues with no sessions{{ stats.leagues_without_sessions }}
+
Sessions with no ledger records{{ stats.sessions_without_events }}
+
Avg players/session{{ '%.1f'|format(stats.avg_players_per_session) }}
+
Ledger events (24h){{ stats.ledger_events_24h }}
+
Ledger events (7d){{ stats.ledger_events_7d }}
+
Total ledger events{{ stats.ledger_events }}
+
Voided ledger records{{ stats.voided_events }} · {{ '%.1f'|format(stats.void_rate) }}%
+
Avg sessions / league{{ stats.avg_sessions_per_active_league }}
+
+
+
+
+ +
+
+

Top leagues

+ All leagues → +
+
+ + + + {% for league, session_count in top_leagues %} + {% set owner = owners.get(league.id) %} + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerSessions
+ {{ league.name }} + {% if league.visibility == 'public' %}public{% endif %} + {% if owner %}{{ owner.email }}{% else %}{% endif %}{{ session_count }}
No leagues yet.
+
+
+
+ +
+
+

Recorded ledger volume

+ Not platform revenue +
+
+
+
Recorded buy-ins and collections${{ '%.2f'|format(stats.cash_in_cents / 100) }}
+
Recorded payouts${{ '%.2f'|format(stats.cash_out_cents / 100) }}
+
Average recorded volume / session${{ '%.2f'|format(stats.avg_cash_in_per_session_cents / 100) }}
+
Ledger records{{ stats.ledger_events }}
+
+
+
+ + +
+
+
+

Recent users

+ All users → +
+
+ + + + {% for user in recent_users %} + + + + + + {% endfor %} + +
EmailStatusJoined
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% elif not user.email_verified_at %}Unverified{% else %}Active{% endif %}{{ user.created_at.strftime('%b %-d') if user.created_at else '—' }}
+
+
+ +
+
+

Recent leagues

+ All leagues → +
+
+ + + + {% for league in recent_leagues %} + + + + + + {% endfor %} + +
LeagueVisibilityCreated
{{ league.name }}{{ league.visibility }}{{ league.created_at.strftime('%b %-d') if league.created_at else '—' }}
+
+
+
+ + + +{% endblock %} diff --git a/templates/internal_league_detail.html b/templates/internal_league_detail.html new file mode 100644 index 0000000..977b864 --- /dev/null +++ b/templates/internal_league_detail.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}{{ league.name }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Leagues + {% if league.archived_at %}Archived{% else %}Active{% endif %} + {{ league.visibility }} +
+

{{ league.name }}

+

Owner: {% if owner %}{{ owner.email }}{% else %}missing{% endif %}

+
+ Manage league +
+ +
+
{{ stats.members }}Active members
+
{{ stats.players }}Players
+
{{ stats.sessions }}Sessions
+
{{ stats.events }}Ledger events
+
+ +
+
+
+

Members

+ {{ members|length }} +
+
+ + + + {% for membership, member_user in members %} + + + + + + {% else %} + + {% endfor %} + +
EmailRoleStatus
{{ member_user.email }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No members.
+
+
+ +
+
+

League settings

+ Internal manage +
+
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+

League actions

+ Support tools +
+
+
+ + +
+ + +
+
+ + {% if league.archived_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +{% endblock %} diff --git a/templates/internal_leagues.html b/templates/internal_leagues.html new file mode 100644 index 0000000..2347e86 --- /dev/null +++ b/templates/internal_leagues.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Leagues · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Leagues

+

Search by league name, slug, or owner email.

+
+
+ +
+ +
+ +
+
+

Matching leagues

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for league in leagues %} + {% set owner = owners.get(league.id) %} + + + + + + + + {% else %} + + {% endfor %} + +
LeagueOwnerVisibilityStatus
{{ league.name }}{% if owner %}{{ owner.email }}{% else %}Missing{% endif %}{{ league.visibility }}{% if league.archived_at %}Archived{% else %}Active{% endif %}View
No leagues found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/internal_user_detail.html b/templates/internal_user_detail.html new file mode 100644 index 0000000..e1ad739 --- /dev/null +++ b/templates/internal_user_detail.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}{{ user.email }} · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+
+ ← Users + {% if user.disabled_at %}Disabled{% else %}Active{% endif %} + {% if user.is_site_admin %}Site admin{% endif %} +
+

{{ user.email }}

+

Created {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'unknown' }}.

+
+
+ +
+
+ {{ 'Yes' if user.email_verified_at else 'No' }} + Email verified +
+
+ {{ memberships|length }} + League memberships +
+
+ {{ owned_leagues|length }} + Owned leagues +
+
+ {{ event_count }} + Ledger events created +
+
+ +
+
+
+

Account

+ Support tools +
+
+
+ + +
+ + +
+
+ +
+

Last login: {{ user.last_login_at.strftime('%Y-%m-%d %H:%M') if user.last_login_at else 'Never' }}

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

Danger zone

+ Account state +
+
+ {% if user.disabled_at %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + +
+ + +
+ + +
+
+
+
+
+ +
+
+
+

Memberships

+ {{ memberships|length }} +
+
+ + + + {% for membership, league in memberships %} + + + + + + {% else %} + + {% endfor %} + +
LeagueRoleStatus
{{ league.name }}{{ membership.role }}{% if membership.disabled_at %}Removed{% else %}Active{% endif %}
No memberships.
+
+
+ +
+
+

Owned leagues

+ {{ owned_leagues|length }} +
+
+ + + + {% for league in owned_leagues %} + + + + + + {% else %} + + {% endfor %} + +
LeagueStatus
{{ league.name }}{% if league.archived_at %}Archived{% else %}Active{% endif %}Manage
No owned leagues.
+
+
+
+ +{% endblock %} diff --git a/templates/internal_users.html b/templates/internal_users.html new file mode 100644 index 0000000..bb65776 --- /dev/null +++ b/templates/internal_users.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Users · Admin · myboker.org{% endblock %} +{% block sidebar %}{% include "_internal_sidebar.html" %}{% endblock %} +{% block page_class %}page--app{% endblock %} +{% block content %} + +
+
+

Users

+

Search accounts by email and inspect account status.

+
+
+ +
+ +
+ +
+
+

Matching users

+ {{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }} +
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + {% endfor %} + +
EmailStatusVerifiedCreated
+ {{ user.email }} + {% if user.is_site_admin %}Admin{% endif %} + {% if user.disabled_at %}Disabled{% else %}Active{% endif %}{% if user.email_verified_at %}Yes{% else %}No{% endif %}{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}View
No users found.
+
+ {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + Previous + {% else %} + Previous + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + Next + {% else %} + Next + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..6d2587a --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,320 @@ +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app import create_app +from auth import hash_password +from db import db + + +class InternalAdminAccessTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + "WTF_CSRF_ENABLED": False, + } + ) + self.client = self.app.test_client() + + with self.app.app_context(): + from db_models import League, LeagueMembership, User + + db.create_all() + self.user = User( + email="viewer@example.com", + password_hash=hash_password("password123"), + ) + self.manager = User( + email="manager@example.com", + password_hash=hash_password("password123"), + ) + self.admin = User( + email="admin@example.com", + password_hash=hash_password("password123"), + is_site_admin=True, + ) + db.session.add_all([self.user, self.manager, self.admin]) + db.session.flush() + self.league = League( + name="Friday Poker", + slug="friday-poker", + public_key="abc123", + created_by_user_id=self.admin.id, + visibility="public", + ) + db.session.add(self.league) + db.session.flush() + db.session.add_all( + [ + LeagueMembership(league_id=self.league.id, user_id=self.admin.id, role="owner"), + LeagueMembership(league_id=self.league.id, user_id=self.manager.id, role="manager"), + ] + ) + db.session.commit() + self.user_id = self.user.id + self.manager_id = self.manager.id + self.admin_id = self.admin.id + self.league_id = self.league.id + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def login_as(self, user_id: str, **extra_session): + with self.client.session_transaction() as session: + session["user_id"] = user_id + for key, value in extra_session.items(): + session[key] = value + + def test_internal_dashboard_requires_login(self): + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 302) + self.assertIn("/account/login", response.headers["Location"]) + + def test_internal_dashboard_rejects_regular_users(self): + self.login_as(self.user_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_ignores_client_side_admin_spoof(self): + self.login_as(self.user_id, is_site_admin=True) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 403) + + def test_internal_dashboard_allows_site_admins(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Overview", response.data) + self.assertIn(b"admin@example.com", response.data) + self.assertIn(b"Active users", response.data) + self.assertIn(b"Returning users", response.data) + self.assertIn(b"Recorded ledger volume", response.data) + self.assertIn(b"Not platform revenue", response.data) + + def test_admin_can_search_users(self): + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?q=viewer") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"viewer@example.com", response.data) + self.assertNotIn(b"manager@example.com", response.data) + + def test_admin_users_page_is_paginated(self): + with self.app.app_context(): + from db_models import User + + users = [ + User(email=f"bulk{i:02d}@example.com", password_hash=hash_password("password123")) + for i in range(55) + ] + db.session.add_all(users) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/users?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + def test_admin_leagues_page_is_paginated(self): + with self.app.app_context(): + from db_models import League + + leagues = [ + League( + name=f"Bulk League {i:02d}", + slug=f"bulk-league-{i:02d}", + public_key=f"bk{i:010d}"[:12], + created_by_user_id=self.admin_id, + visibility="private", + ) + for i in range(55) + ] + db.session.add_all(leagues) + db.session.commit() + self.login_as(self.admin_id) + + response = self.client.get("/internal/leagues?page=2") + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Page 2 of 2", response.data) + self.assertIn(b"Previous", response.data) + + 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 + + now = datetime.now(timezone.utc) + old_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(weeks=5)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + current_session = PokerSession( + league_id=self.league_id, + session_date=(now - timedelta(days=2)).date(), + sequence_on_date=1, + status="closed", + created_at=now, + ) + db.session.add_all([old_session, current_session]) + db.session.commit() + + growth = _growth_data() + + self.assertEqual(sum(growth["session_counts"]), 2) + self.assertLess(growth["session_counts"][-1], 2) + + def test_regular_user_cannot_update_user_email(self): + self.login_as(self.user_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + ) + + self.assertEqual(response.status_code, 403) + with self.app.app_context(): + from db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "viewer@example.com") + + def test_admin_can_update_user_email(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/users/{self.user_id}/email", + data={"email": "changed@example.com"}, + follow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + with self.app.app_context(): + from db_models import User + + user = db.session.get(User, self.user_id) + self.assertEqual(user.email, "changed@example.com") + + 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 + + owned_league = League( + name="Manager Owned Poker", + slug="manager-owned-poker", + public_key="mgr123", + created_by_user_id=self.manager_id, + visibility="private", + ) + db.session.add(owned_league) + db.session.flush() + db.session.add(LeagueMembership(league_id=owned_league.id, user_id=self.manager_id, role="owner")) + db.session.commit() + + response = self.client.post( + f"/internal/users/{self.manager_id}/delete", + data={"confirm": "DELETE"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import User + + self.assertIsNotNone(db.session.get(User, self.manager_id)) + + def test_admin_can_archive_and_restore_league(self): + self.login_as(self.admin_id) + + 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 + + league = db.session.get(League, self.league_id) + self.assertIsNotNone(league.archived_at) + + 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 + + league = db.session.get(League, self.league_id) + self.assertIsNone(league.archived_at) + + def test_admin_can_update_league_settings(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/settings", + data={ + "name": "Saturday Poker", + "description": "Updated by support", + "visibility": "private", + "eligible_min_sessions": "5", + "break_even_dollars": "2.50", + }, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import League + + league = db.session.get(League, self.league_id) + self.assertEqual(league.name, "Saturday Poker") + self.assertEqual(league.slug, "saturday-poker") + self.assertEqual(league.description, "Updated by support") + self.assertEqual(league.visibility, "private") + self.assertEqual(league.eligible_min_sessions, 5) + self.assertEqual(league.break_even_cents, 250) + + def test_admin_can_transfer_league_ownership(self): + self.login_as(self.admin_id) + + response = self.client.post( + f"/internal/leagues/{self.league_id}/transfer", + data={"new_owner_email": "manager@example.com"}, + ) + + self.assertEqual(response.status_code, 302) + with self.app.app_context(): + from db_models import League, LeagueMembership + + league = db.session.get(League, self.league_id) + self.assertEqual(league.created_by_user_id, self.manager_id) + old_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.admin_id, + ).one() + new_owner_membership = LeagueMembership.query.filter_by( + league_id=self.league_id, + user_id=self.manager_id, + ).one() + self.assertEqual(old_owner_membership.role, "manager") + self.assertEqual(new_owner_membership.role, "owner") + + +if __name__ == "__main__": + unittest.main()