internal statistics & tooling
This commit is contained in:
12 files changed
+2065
-2
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/<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 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 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"))
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<aside class="sidebar sidebar--admin">
|
||||
<div class="sidebar__header">
|
||||
<div class="sidebar__badge sidebar__badge--admin">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="16" height="16">
|
||||
<path d="M8 1L14 4v5c0 3.5-2.5 6-6 6.5C2.5 15 0 12.5 0 9V4l8-3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M5.5 8l2 2 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sidebar__meta">
|
||||
<span class="sidebar__league-name">Admin Panel</span>
|
||||
<span class="sidebar__role">site admin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__nav" aria-label="Admin navigation">
|
||||
<a class="sidebar__link {{ 'is-active' if request.endpoint == 'internal.dashboard' }}"
|
||||
href="{{ url_for('internal.dashboard') }}">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
</svg>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
|
||||
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('internal.users', 'internal.user_detail') }}"
|
||||
href="{{ url_for('internal.users') }}">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="6" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<path d="M1 14c0-2.761 2.239-5 5-5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<circle cx="11.5" cy="5.5" r="2" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M9 14c0-1.657 1.119-3.084 2.5-3.084S14 12.343 14 14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Users</span>
|
||||
</a>
|
||||
|
||||
<a class="sidebar__link {{ 'is-active' if request.endpoint in ('internal.leagues', 'internal.league_detail') }}"
|
||||
href="{{ url_for('internal.leagues') }}">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3 14V6l5-3 5 3v8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 14v-4h4v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Leagues</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<a class="sidebar__back" href="{{ url_for('leagues.index') }}">
|
||||
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L3 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to app
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -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 %}
|
||||
|
||||
<div class="db-header int-page-header">
|
||||
<div>
|
||||
<div class="db-header__chips">
|
||||
<span class="badge badge--paid">Site Admin</span>
|
||||
{% if stats.open_sessions > 0 %}
|
||||
<span class="badge badge--open">{{ stats.open_sessions }} live session{{ 's' if stats.open_sessions != 1 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="db-header__title">Overview</h1>
|
||||
<p class="db-header__sub">Signed in as {{ admin_user.email }}. Site health, growth, and recent activity at a glance.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero metrics -->
|
||||
<div class="int-hero-grid">
|
||||
<div class="int-hero-card">
|
||||
<div class="int-hero-card__icon int-hero-card__icon--users">
|
||||
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||
<path d="M13 18v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 1 16.5V18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<circle cx="7" cy="6.5" r="3.5" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M17 18v-1.5a3.5 3.5 0 0 0-2.5-3.37M13.5 3.13a3.5 3.5 0 0 1 0 6.74" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="int-hero-card__num">{{ stats.users }}</span>
|
||||
<span class="int-hero-card__label">Total users</span>
|
||||
<span class="int-hero-card__sub">{{ stats.active_users }} active · +{{ stats.new_users_7d }} this week</span>
|
||||
</div>
|
||||
<div class="int-hero-card">
|
||||
<div class="int-hero-card__icon int-hero-card__icon--leagues">
|
||||
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||
<path d="M2 18V8l8-5 8 5v10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 18v-6h6v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="int-hero-card__num">{{ stats.active_leagues }}</span>
|
||||
<span class="int-hero-card__label">Active leagues</span>
|
||||
<span class="int-hero-card__sub">{{ stats.public_leagues }} public · {{ stats.archived_leagues }} archived · {{ '%.1f'|format(stats.avg_sessions_per_active_league) }} avg sessions</span>
|
||||
</div>
|
||||
<div class="int-hero-card">
|
||||
<div class="int-hero-card__icon int-hero-card__icon--sessions">
|
||||
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||
<rect x="2" y="3" width="16" height="14" rx="2.5" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M6 1v4M14 1v4M2 8h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="int-hero-card__num">{{ stats.sessions }}</span>
|
||||
<span class="int-hero-card__label">Total sessions</span>
|
||||
<span class="int-hero-card__sub">{{ stats.open_sessions }} open · {{ stats.sessions_7d }} this week · {{ '%.1f'|format(stats.avg_players_per_session) }} avg players</span>
|
||||
</div>
|
||||
<div class="int-hero-card">
|
||||
<div class="int-hero-card__icon int-hero-card__icon--health">
|
||||
<svg viewBox="0 0 20 20" fill="none" width="18" height="18">
|
||||
<path d="M10 2.5l6.5 3v4.7c0 4.1-2.75 7-6.5 7.8-3.75-.8-6.5-3.7-6.5-7.8V5.5l6.5-3z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M7.2 10.1l1.8 1.8 3.9-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="int-hero-card__num">{{ stats.active_users_30d }}</span>
|
||||
<span class="int-hero-card__label">Active users · 30d</span>
|
||||
<span class="int-hero-card__sub">{{ stats.returning_users_30d }} returning · {{ stats.unverified_users }} unverified</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="int-trend-grid int-section-grid">
|
||||
{% for card in trend_cards %}
|
||||
<section class="int-trend-card">
|
||||
<div>
|
||||
<span class="int-trend-card__label">{{ card.label }}</span>
|
||||
<span class="int-trend-card__value">{{ card.value }}</span>
|
||||
<span class="int-trend-card__delta int-trend-card__delta--{{ card.delta.direction }}">
|
||||
{% if card.delta.pct is none %}
|
||||
New
|
||||
{% else %}
|
||||
{{ '+' if card.delta.current > card.delta.previous else '' }}{{ '%.1f'|format(card.delta.pct) }}%
|
||||
{% endif %}
|
||||
<span>vs previous</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="int-sparkline-wrap">
|
||||
<canvas id="trend-{{ card.key }}"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Charts row 1: growth -->
|
||||
<div class="cards-2 int-section-grid">
|
||||
<section class="panel int-chart-panel">
|
||||
<div class="int-chart-head">
|
||||
<p class="kicker" style="margin:0;">Growth</p>
|
||||
<h2 class="panel__title">User signups · 8 weeks</h2>
|
||||
</div>
|
||||
<div class="int-chart-wrap">
|
||||
<canvas id="chart-users"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel int-chart-panel">
|
||||
<div class="int-chart-head">
|
||||
<p class="kicker" style="margin:0;">Activity</p>
|
||||
<h2 class="panel__title">Sessions per week · 8 weeks</h2>
|
||||
</div>
|
||||
<div class="int-chart-wrap">
|
||||
<canvas id="chart-sessions"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Charts row 2: cash + weekday -->
|
||||
<div class="cards-2 int-section-grid">
|
||||
<section class="panel int-chart-panel">
|
||||
<div class="int-chart-head">
|
||||
<p class="kicker" style="margin:0;">Recorded ledger volume</p>
|
||||
<h2 class="panel__title">Buy-ins and collections · 8 weeks</h2>
|
||||
</div>
|
||||
<div class="int-chart-wrap">
|
||||
<canvas id="chart-cash"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel int-chart-panel">
|
||||
<div class="int-chart-head">
|
||||
<p class="kicker" style="margin:0;">Patterns</p>
|
||||
<h2 class="panel__title">Sessions by day of week</h2>
|
||||
</div>
|
||||
<div class="int-chart-wrap">
|
||||
<canvas id="chart-weekday"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown + top leagues -->
|
||||
<div class="cards-2 int-section-grid">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Activity breakdown</h2>
|
||||
<span class="panel__tag">24h / 7d</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<div class="int-breakdown-grid">
|
||||
<div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">New users (24h)</span><span class="int-breakdown-row__val">{{ stats.new_users_24h }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">New users (7d)</span><span class="int-breakdown-row__val">{{ stats.new_users_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Active users (24h)</span><span class="int-breakdown-row__val">{{ stats.active_users_24h }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Active users (7d)</span><span class="int-breakdown-row__val">{{ stats.active_users_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Active users (30d)</span><span class="int-breakdown-row__val">{{ stats.active_users_30d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Returning users (7d)</span><span class="int-breakdown-row__val">{{ stats.returning_users_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Returning users (30d)</span><span class="int-breakdown-row__val">{{ stats.returning_users_30d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Users in leagues</span><span class="int-breakdown-row__val">{{ stats.users_in_leagues }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">No-league signups</span><span class="int-breakdown-row__val">{{ stats.orphan_users }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Verified accounts</span><span class="int-breakdown-row__val">{{ stats.verified_users }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Unverified</span><span class="int-breakdown-row__val">{{ stats.unverified_users }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Disabled</span><span class="int-breakdown-row__val">{{ stats.disabled_users }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Site admins</span><span class="int-breakdown-row__val">{{ stats.site_admins }}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Sessions (24h)</span><span class="int-breakdown-row__val">{{ stats.sessions_24h }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Sessions (7d)</span><span class="int-breakdown-row__val">{{ stats.sessions_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Open sessions</span><span class="int-breakdown-row__val">{{ stats.open_sessions }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Leagues active (7d)</span><span class="int-breakdown-row__val">{{ stats.leagues_active_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Leagues with no sessions</span><span class="int-breakdown-row__val">{{ stats.leagues_without_sessions }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Sessions with no ledger records</span><span class="int-breakdown-row__val">{{ stats.sessions_without_events }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Avg players/session</span><span class="int-breakdown-row__val">{{ '%.1f'|format(stats.avg_players_per_session) }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Ledger events (24h)</span><span class="int-breakdown-row__val">{{ stats.ledger_events_24h }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Ledger events (7d)</span><span class="int-breakdown-row__val">{{ stats.ledger_events_7d }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Total ledger events</span><span class="int-breakdown-row__val">{{ stats.ledger_events }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Voided ledger records</span><span class="int-breakdown-row__val">{{ stats.voided_events }} · {{ '%.1f'|format(stats.void_rate) }}%</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Avg sessions / league</span><span class="int-breakdown-row__val">{{ stats.avg_sessions_per_active_league }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Top leagues</h2>
|
||||
<a class="panel__tag" href="{{ url_for('internal.leagues') }}">All leagues →</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>League</th><th>Owner</th><th style="text-align:right;">Sessions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for league, session_count in top_leagues %}
|
||||
{% set owner = owners.get(league.id) %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a>
|
||||
{% if league.visibility == 'public' %}<span class="badge" style="font-size:10px;margin-left:4px;">public</span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--muted);">{% if owner %}{{ owner.email }}{% else %}<span class="muted-text">—</span>{% endif %}</td>
|
||||
<td style="text-align:right;font-variant-numeric:tabular-nums;font-weight:600;">{{ session_count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted-text">No leagues yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel int-section-grid">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Recorded ledger volume</h2>
|
||||
<span class="panel__tag">Not platform revenue</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<div class="int-breakdown-grid">
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Recorded buy-ins and collections</span><span class="int-breakdown-row__val">${{ '%.2f'|format(stats.cash_in_cents / 100) }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Recorded payouts</span><span class="int-breakdown-row__val">${{ '%.2f'|format(stats.cash_out_cents / 100) }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Average recorded volume / session</span><span class="int-breakdown-row__val">${{ '%.2f'|format(stats.avg_cash_in_per_session_cents / 100) }}</span></div>
|
||||
<div class="int-breakdown-row"><span class="int-breakdown-row__label">Ledger records</span><span class="int-breakdown-row__val">{{ stats.ledger_events }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent users + leagues -->
|
||||
<div class="cards-2 int-section-grid int-section-grid--last">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Recent users</h2>
|
||||
<a class="panel__tag" href="{{ url_for('internal.users') }}">All users →</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Email</th><th>Status</th><th>Joined</th></tr></thead>
|
||||
<tbody>
|
||||
{% for user in recent_users %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('internal.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||
{% if user.is_site_admin %}<span class="badge badge--paid" style="font-size:10px;">Admin</span>{% endif %}
|
||||
</td>
|
||||
<td>{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% elif not user.email_verified_at %}<span class="badge">Unverified</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
<td style="color:var(--muted);font-size:12px;">{{ user.created_at.strftime('%b %-d') if user.created_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Recent leagues</h2>
|
||||
<a class="panel__tag" href="{{ url_for('internal.leagues') }}">All leagues →</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>League</th><th>Visibility</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{% for league in recent_leagues %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||
<td><span class="badge">{{ league.visibility }}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px;">{{ league.created_at.strftime('%b %-d') if league.created_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const labels = {{ growth.labels | tojson }};
|
||||
const userSignups = {{ growth.user_signups | tojson }};
|
||||
const sessionData = {{ growth.session_counts | tojson }};
|
||||
const cashData = {{ growth.cash_in_weekly | tojson }};
|
||||
const wdLabels = {{ weekday_data.labels | tojson }};
|
||||
const wdData = {{ weekday_data.data | tojson }};
|
||||
const trendCards = {{ trend_cards | tojson }};
|
||||
|
||||
const axisStyle = {
|
||||
grid: { color: 'rgba(255,255,255,.05)' },
|
||||
ticks: { color: 'rgba(255,255,255,.38)', font: { size: 11 } },
|
||||
};
|
||||
const tooltipStyle = {
|
||||
backgroundColor: 'rgba(20,20,28,.92)',
|
||||
borderColor: 'rgba(255,255,255,.08)',
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgba(255,255,255,.5)',
|
||||
bodyColor: '#fff',
|
||||
padding: 10,
|
||||
};
|
||||
const baseOpts = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: tooltipStyle },
|
||||
scales: {
|
||||
x: axisStyle,
|
||||
y: { ...axisStyle, beginAtZero: true, ticks: { ...axisStyle.ticks, precision: 0 } },
|
||||
},
|
||||
};
|
||||
|
||||
const lineDataset = (data, color) => ({
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('1)', '.12)'),
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
tension: .35,
|
||||
fill: true,
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('chart-users'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [lineDataset(userSignups, 'rgba(155,140,240,1)')]
|
||||
},
|
||||
options: baseOpts,
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('chart-sessions'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [lineDataset(sessionData, 'rgba(111,192,147,1)')]
|
||||
},
|
||||
options: baseOpts,
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('chart-cash'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data: cashData, backgroundColor: 'rgba(120,180,240,.45)', borderColor: 'rgba(120,180,240,.9)', borderWidth: 1, borderRadius: 4 }]
|
||||
},
|
||||
options: {
|
||||
...baseOpts,
|
||||
scales: {
|
||||
x: axisStyle,
|
||||
y: { ...axisStyle, beginAtZero: true, ticks: { ...axisStyle.ticks, callback: v => '$' + v } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('chart-weekday'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: wdLabels,
|
||||
datasets: [{ data: wdData, backgroundColor: 'rgba(224,177,92,.45)', borderColor: 'rgba(224,177,92,.9)', borderWidth: 1, borderRadius: 4 }]
|
||||
},
|
||||
options: baseOpts,
|
||||
});
|
||||
|
||||
trendCards.forEach((card) => {
|
||||
const canvas = document.getElementById('trend-' + card.key);
|
||||
if (!canvas) return;
|
||||
new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: card.labels,
|
||||
datasets: [lineDataset(card.series, 'rgba(155,140,240,1)')]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
scales: { x: { display: false }, y: { display: false, beginAtZero: true } },
|
||||
elements: { point: { radius: 0 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
<div class="db-header" style="margin-bottom:20px;">
|
||||
<div>
|
||||
<div class="db-header__chips">
|
||||
<a class="badge" href="{{ url_for('internal.leagues') }}">← Leagues</a>
|
||||
{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}
|
||||
<span class="badge">{{ league.visibility }}</span>
|
||||
</div>
|
||||
<h1 class="db-header__title">{{ league.name }}</h1>
|
||||
<p class="db-header__sub">Owner: {% if owner %}<a href="{{ url_for('internal.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>{% else %}<span class="muted-text">missing</span>{% endif %}</p>
|
||||
</div>
|
||||
<a class="btn btn--primary btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Manage league</a>
|
||||
</div>
|
||||
|
||||
<div class="db-stat-grid">
|
||||
<div class="db-stat"><span class="db-stat__num">{{ stats.members }}</span><span class="db-stat__label">Active members</span></div>
|
||||
<div class="db-stat"><span class="db-stat__num">{{ stats.players }}</span><span class="db-stat__label">Players</span></div>
|
||||
<div class="db-stat"><span class="db-stat__num">{{ stats.sessions }}</span><span class="db-stat__label">Sessions</span></div>
|
||||
<div class="db-stat"><span class="db-stat__num">{{ stats.events }}</span><span class="db-stat__label">Ledger events</span></div>
|
||||
</div>
|
||||
|
||||
<div class="cards-2">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Members</h2>
|
||||
<span class="panel__tag">{{ members|length }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Email</th><th>Role</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for membership, member_user in members %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('internal.user_detail', user_id=member_user.id) }}">{{ member_user.email }}</a></td>
|
||||
<td>{{ membership.role }}</td>
|
||||
<td>{% if membership.disabled_at %}<span class="badge badge--closed">Removed</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted-text">No members.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">League settings</h2>
|
||||
<span class="panel__tag">Internal manage</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<form method="post" action="{{ url_for('internal.update_league_settings', league_id=league.id) }}" class="form-card form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="field-label" for="league-name">Name</label>
|
||||
<input class="field-input" id="league-name" name="name" value="{{ league.name }}" required>
|
||||
|
||||
<label class="field-label" for="league-description">Description</label>
|
||||
<textarea class="field-input" id="league-description" name="description" rows="3">{{ league.description or '' }}</textarea>
|
||||
|
||||
<label class="field-label" for="league-visibility">Visibility</label>
|
||||
<select class="field-input" id="league-visibility" name="visibility">
|
||||
<option value="private" {% if league.visibility == 'private' %}selected{% endif %}>Private</option>
|
||||
<option value="public" {% if league.visibility == 'public' %}selected{% endif %}>Public</option>
|
||||
</select>
|
||||
|
||||
<div class="inline-form">
|
||||
<div>
|
||||
<label class="field-label" for="eligible-min">Eligible minimum</label>
|
||||
<input class="field-input" id="eligible-min" type="number" min="1" max="100" name="eligible_min_sessions" value="{{ league.eligible_min_sessions }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="break-even">Break-even dollars</label>
|
||||
<input class="field-input" id="break-even" type="number" min="0" max="100" step="0.01" name="break_even_dollars" value="{{ '%.2f'|format(league.break_even_cents / 100) }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--sm" type="submit">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="cards-2">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">League actions</h2>
|
||||
<span class="panel__tag">Support tools</span>
|
||||
</div>
|
||||
<div class="panel__body form-stack">
|
||||
<form method="post" action="{{ url_for('internal.transfer_league_owner', league_id=league.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="field-label" for="new-owner-email">Transfer ownership to active user</label>
|
||||
<div class="inline-form">
|
||||
<input class="field-input" id="new-owner-email" type="email" name="new_owner_email" placeholder="owner@example.com" required>
|
||||
<button class="btn btn--warning btn--sm" type="submit">Transfer</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if league.archived_at %}
|
||||
<form method="post" action="{{ url_for('internal.restore_league', league_id=league.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Restore league</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('internal.archive_league', league_id=league.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--warning btn--sm" type="submit">Archive league</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('internal.delete_internal_league', league_id=league.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="field-label" for="confirm-delete-league">Type DELETE to permanently delete this league</label>
|
||||
<div class="inline-form">
|
||||
<input class="field-input" id="confirm-delete-league" name="confirm" autocomplete="off">
|
||||
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
<div class="db-header int-page-header">
|
||||
<div>
|
||||
<h1 class="db-header__title">Leagues</h1>
|
||||
<p class="db-header__sub">Search by league name, slug, or owner email.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-list-tools">
|
||||
<form class="admin-searchbar" method="get" action="{{ url_for('internal.leagues') }}">
|
||||
<input class="field-input" type="search" name="q" value="{{ q }}" placeholder="Search league or owner">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Search</button>
|
||||
{% if q %}<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues') }}">Clear</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="panel admin-list-panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Matching leagues</h2>
|
||||
<span class="panel__tag">{{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>League</th>
|
||||
<th>Owner</th>
|
||||
<th>Visibility</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for league in leagues %}
|
||||
{% set owner = owners.get(league.id) %}
|
||||
<tr>
|
||||
<td class="admin-table__primary"><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||
<td>{% if owner %}<a href="{{ url_for('internal.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>{% else %}<span class="muted-text">Missing</span>{% endif %}</td>
|
||||
<td><span class="badge">{{ league.visibility }}</span></td>
|
||||
<td>{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('internal.league_detail', league_id=league.id) }}">View</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted-text">No leagues found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="pagination-row">
|
||||
{% if pagination.has_prev %}
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues', q=q, page=pagination.prev_page) }}">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn--ghost btn--sm is-disabled">Previous</span>
|
||||
{% endif %}
|
||||
<span class="pagination-row__label">Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||
{% if pagination.has_next %}
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.leagues', q=q, page=pagination.next_page) }}">Next</a>
|
||||
{% else %}
|
||||
<span class="btn btn--ghost btn--sm is-disabled">Next</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
<div class="db-header" style="margin-bottom:20px;">
|
||||
<div>
|
||||
<div class="db-header__chips">
|
||||
<a class="badge" href="{{ url_for('internal.users') }}">← Users</a>
|
||||
{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}
|
||||
{% if user.is_site_admin %}<span class="badge badge--paid">Site admin</span>{% endif %}
|
||||
</div>
|
||||
<h1 class="db-header__title">{{ user.email }}</h1>
|
||||
<p class="db-header__sub">Created {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'unknown' }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="db-stat-grid">
|
||||
<div class="db-stat">
|
||||
<span class="db-stat__num">{{ 'Yes' if user.email_verified_at else 'No' }}</span>
|
||||
<span class="db-stat__label">Email verified</span>
|
||||
</div>
|
||||
<div class="db-stat">
|
||||
<span class="db-stat__num">{{ memberships|length }}</span>
|
||||
<span class="db-stat__label">League memberships</span>
|
||||
</div>
|
||||
<div class="db-stat">
|
||||
<span class="db-stat__num">{{ owned_leagues|length }}</span>
|
||||
<span class="db-stat__label">Owned leagues</span>
|
||||
</div>
|
||||
<div class="db-stat">
|
||||
<span class="db-stat__num">{{ event_count }}</span>
|
||||
<span class="db-stat__label">Ledger events created</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-2">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Account</h2>
|
||||
<span class="panel__tag">Support tools</span>
|
||||
</div>
|
||||
<div class="panel__body form-stack">
|
||||
<form method="post" action="{{ url_for('internal.update_user_email', user_id=user.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="field-label" for="email">Email</label>
|
||||
<div class="inline-form">
|
||||
<input class="field-input" id="email" type="email" name="email" value="{{ user.email }}" required>
|
||||
<button class="btn btn--primary btn--sm" type="submit">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="form-card">
|
||||
<p class="muted-text">Last login: {{ user.last_login_at.strftime('%Y-%m-%d %H:%M') if user.last_login_at else 'Never' }}</p>
|
||||
<div class="button-row">
|
||||
<form method="post" action="{{ url_for('internal.send_user_password_reset', user_id=user.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit" {% if user.disabled_at %}disabled{% endif %}>Email reset link</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('internal.create_user_temporary_password', user_id=user.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit" {% if user.disabled_at %}disabled{% endif %}>Email temp password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Danger zone</h2>
|
||||
<span class="panel__tag">Account state</span>
|
||||
</div>
|
||||
<div class="panel__body form-stack">
|
||||
{% if user.disabled_at %}
|
||||
<form method="post" action="{{ url_for('internal.restore_user', user_id=user.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Restore account</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('internal.disable_user', user_id=user.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--warning btn--sm" type="submit" {% if user.id == current_admin_id %}disabled{% endif %}>Disable account</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('internal.delete_user', user_id=user.id) }}" class="form-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="field-label" for="confirm-delete-user">Type DELETE to permanently delete this account</label>
|
||||
<div class="inline-form">
|
||||
<input class="field-input" id="confirm-delete-user" name="confirm" autocomplete="off">
|
||||
<button class="btn btn--danger btn--sm" type="submit" {% if user.id == current_admin_id %}disabled{% endif %}>Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="cards-2">
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Memberships</h2>
|
||||
<span class="panel__tag">{{ memberships|length }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>League</th><th>Role</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for membership, league in memberships %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||
<td>{{ membership.role }}</td>
|
||||
<td>{% if membership.disabled_at %}<span class="badge badge--closed">Removed</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted-text">No memberships.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Owned leagues</h2>
|
||||
<span class="panel__tag">{{ owned_leagues|length }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>League</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for league in owned_leagues %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('internal.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||
<td>{% if league.archived_at %}<span class="badge badge--closed">Archived</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('leagues.league_settings', league_ref=league.url_ref) }}">Manage</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted-text">No owned leagues.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
<div class="db-header int-page-header">
|
||||
<div>
|
||||
<h1 class="db-header__title">Users</h1>
|
||||
<p class="db-header__sub">Search accounts by email and inspect account status.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-list-tools">
|
||||
<form class="admin-searchbar" method="get" action="{{ url_for('internal.users') }}">
|
||||
<input class="field-input" type="search" name="q" value="{{ q }}" placeholder="Search email">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Search</button>
|
||||
{% if q %}<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users') }}">Clear</a>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="panel admin-list-panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Matching users</h2>
|
||||
<span class="panel__tag">{{ pagination.start }}-{{ pagination.end }} of {{ pagination.total }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="tbl admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Verified</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td class="admin-table__primary">
|
||||
<a href="{{ url_for('internal.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||
{% if user.is_site_admin %}<span class="badge badge--paid">Admin</span>{% endif %}
|
||||
</td>
|
||||
<td>{% if user.disabled_at %}<span class="badge badge--closed">Disabled</span>{% else %}<span class="badge badge--open">Active</span>{% endif %}</td>
|
||||
<td>{% if user.email_verified_at %}Yes{% else %}<span class="muted-text">No</span>{% endif %}</td>
|
||||
<td class="admin-table__muted">{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}</td>
|
||||
<td class="tbl__actions"><a class="btn btn--ghost btn--sm" href="{{ url_for('internal.user_detail', user_id=user.id) }}">View</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted-text">No users found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="pagination-row">
|
||||
{% if pagination.has_prev %}
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users', q=q, page=pagination.prev_page) }}">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn--ghost btn--sm is-disabled">Previous</span>
|
||||
{% endif %}
|
||||
<span class="pagination-row__label">Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||
{% if pagination.has_next %}
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('internal.users', q=q, page=pagination.next_page) }}">Next</a>
|
||||
{% else %}
|
||||
<span class="btn btn--ghost btn--sm is-disabled">Next</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user