admin improvements
This commit is contained in:
1 parent
f078034789
commit
5c7a498af6
18 files changed
+2261
-4
No files matched your search
@@ -13,6 +13,7 @@ from .extensions import csrf, limiter, mail
|
|||||||
from .routes.account import account_bp
|
from .routes.account import account_bp
|
||||||
from .routes.leagues import leagues_bp
|
from .routes.leagues import leagues_bp
|
||||||
from .routes.public import public_bp
|
from .routes.public import public_bp
|
||||||
|
from .routes.site_admin import site_admin_bp
|
||||||
from .storage import ensure_data_file
|
from .storage import ensure_data_file
|
||||||
from .utils import cents_to_dollars, safe_date_label
|
from .utils import cents_to_dollars, safe_date_label
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ def create_app(config_overrides: dict | None = None) -> Flask:
|
|||||||
app.register_blueprint(public_bp)
|
app.register_blueprint(public_bp)
|
||||||
app.register_blueprint(account_bp)
|
app.register_blueprint(account_bp)
|
||||||
app.register_blueprint(leagues_bp)
|
app.register_blueprint(leagues_bp)
|
||||||
|
app.register_blueprint(site_admin_bp)
|
||||||
|
|
||||||
@app.cli.command("init-db")
|
@app.cli.command("init-db")
|
||||||
def init_db_command() -> None:
|
def init_db_command() -> None:
|
||||||
|
|||||||
@@ -77,3 +77,28 @@ def login_required(view):
|
|||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def current_user_is_site_admin() -> bool:
|
||||||
|
user_id = current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
from .db import db
|
||||||
|
from .db_models import User
|
||||||
|
if db is None:
|
||||||
|
return False
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
return bool(user and user.is_site_admin and user.disabled_at is None)
|
||||||
|
|
||||||
|
|
||||||
|
def site_admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
return redirect(url_for("account.login", next=request.full_path))
|
||||||
|
if not current_user_is_site_admin():
|
||||||
|
from flask import abort
|
||||||
|
abort(403)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
+1
-1
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
|
|||||||
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
|
||||||
|
|
||||||
ELIGIBLE_MIN_SESSIONS = 3
|
ELIGIBLE_MIN_SESSIONS = 3
|
||||||
APP_VERSION = "2.3.1"
|
APP_VERSION = "2.4.3"
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(env_path: Path) -> None:
|
def load_local_env(env_path: Path) -> None:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class User(TimestampMixin, db.Model):
|
|||||||
email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
email_verified_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||||
|
is_site_admin = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class League(TimestampMixin, db.Model):
|
class League(TimestampMixin, db.Model):
|
||||||
|
|||||||
@@ -322,3 +322,23 @@ def remove_league_member(league_id: str, user_id: str) -> None:
|
|||||||
).one_or_none()
|
).one_or_none()
|
||||||
if membership and membership.role != "owner":
|
if membership and membership.role != "owner":
|
||||||
membership.disabled_at = utc_now()
|
membership.disabled_at = utc_now()
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_league_ownership(league_id: str, new_owner_user_id: str) -> None:
|
||||||
|
current_owner = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
role="owner",
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
new_owner_membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=new_owner_user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
if current_owner is None or new_owner_membership is None:
|
||||||
|
raise ValueError("Invalid transfer: owner or target not found.")
|
||||||
|
league = db.session.get(League, league_id)
|
||||||
|
if league:
|
||||||
|
league.created_by_user_id = new_owner_user_id
|
||||||
|
current_owner.role = "manager"
|
||||||
|
new_owner_membership.role = "owner"
|
||||||
+33
-1
@@ -1127,7 +1127,8 @@ def league_settings(league_ref: str):
|
|||||||
from ..repositories.leagues import list_members_for_league
|
from ..repositories.leagues import list_members_for_league
|
||||||
|
|
||||||
members = list_members_for_league(league.id)
|
members = list_members_for_league(league.id)
|
||||||
return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members)
|
has_managers = any(m.role == "manager" for m, u in members)
|
||||||
|
return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members, has_managers=has_managers)
|
||||||
|
|
||||||
|
|
||||||
@leagues_bp.post("/l/<league_ref>/settings/invite")
|
@leagues_bp.post("/l/<league_ref>/settings/invite")
|
||||||
@@ -1196,6 +1197,37 @@ def remove_member(league_ref: str, user_id: str):
|
|||||||
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
||||||
|
|
||||||
|
|
||||||
|
@leagues_bp.post("/l/<league_ref>/settings/transfer")
|
||||||
|
@login_required
|
||||||
|
def transfer_ownership(league_ref: str):
|
||||||
|
if not db_ready():
|
||||||
|
flash("League database is not available.", "error")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
from ..repositories.leagues import find_membership, transfer_league_ownership
|
||||||
|
|
||||||
|
league = require_league(league_ref, {"owner"})
|
||||||
|
new_owner_id = request.form.get("new_owner_user_id", "").strip()
|
||||||
|
|
||||||
|
if not new_owner_id:
|
||||||
|
flash("Select a manager to transfer ownership to.", "error")
|
||||||
|
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
||||||
|
|
||||||
|
membership = find_membership(league.id, new_owner_id)
|
||||||
|
if membership is None or membership.role != "manager":
|
||||||
|
flash("Target user must be a manager of this league.", "error")
|
||||||
|
return redirect(url_for("leagues.league_settings", league_ref=league.url_ref))
|
||||||
|
|
||||||
|
try:
|
||||||
|
transfer_league_ownership(league.id, new_owner_id)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Ownership transferred. You are now a manager of this league.", "success")
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
|
||||||
|
return redirect(url_for("leagues.dashboard", **league_url_values(league)))
|
||||||
|
|
||||||
|
|
||||||
@leagues_bp.post("/l/<league_ref>/archive")
|
@leagues_bp.post("/l/<league_ref>/archive")
|
||||||
@login_required
|
@login_required
|
||||||
def archive_league(league_ref: str):
|
def archive_league(league_ref: str):
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
from ..auth import (
|
||||||
|
current_user_id,
|
||||||
|
generate_reset_token,
|
||||||
|
hash_password,
|
||||||
|
normalize_email,
|
||||||
|
site_admin_required,
|
||||||
|
)
|
||||||
|
from ..db import db
|
||||||
|
|
||||||
|
site_admin_bp = Blueprint("site_admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/")
|
||||||
|
def index():
|
||||||
|
return redirect(url_for("site_admin.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/dashboard")
|
||||||
|
@site_admin_required
|
||||||
|
def dashboard():
|
||||||
|
from ..db_models import League, LedgerEvent, LeagueMembership, PokerSession, User
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
day_ago = now - timedelta(days=1)
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
month_ago = now - timedelta(days=30)
|
||||||
|
|
||||||
|
total_users = db.session.query(db.func.count(User.id)).scalar() or 0
|
||||||
|
active_users = db.session.query(db.func.count(User.id)).filter(User.disabled_at.is_(None)).scalar() or 0
|
||||||
|
disabled_users = total_users - active_users
|
||||||
|
new_users_7d = db.session.query(db.func.count(User.id)).filter(User.created_at >= week_ago).scalar() or 0
|
||||||
|
new_users_30d = db.session.query(db.func.count(User.id)).filter(User.created_at >= month_ago).scalar() or 0
|
||||||
|
admin_count = db.session.query(db.func.count(User.id)).filter(User.is_site_admin.is_(True)).scalar() or 0
|
||||||
|
|
||||||
|
dau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= day_ago).scalar() or 0
|
||||||
|
wau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= week_ago).scalar() or 0
|
||||||
|
mau = db.session.query(db.func.count(User.id)).filter(User.last_login_at >= month_ago).scalar() or 0
|
||||||
|
|
||||||
|
total_leagues = db.session.query(db.func.count(League.id)).scalar() or 0
|
||||||
|
active_leagues = db.session.query(db.func.count(League.id)).filter(League.archived_at.is_(None)).scalar() or 0
|
||||||
|
public_leagues = db.session.query(db.func.count(League.id)).filter(
|
||||||
|
League.visibility == "public", League.archived_at.is_(None)
|
||||||
|
).scalar() or 0
|
||||||
|
new_leagues_30d = db.session.query(db.func.count(League.id)).filter(League.created_at >= month_ago).scalar() or 0
|
||||||
|
|
||||||
|
total_sessions = db.session.query(db.func.count(PokerSession.id)).scalar() or 0
|
||||||
|
open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(PokerSession.status == "open").scalar() or 0
|
||||||
|
|
||||||
|
total_events = db.session.query(db.func.count(LedgerEvent.id)).filter(LedgerEvent.voided_at.is_(None)).scalar() or 0
|
||||||
|
|
||||||
|
league_session_rows = db.session.query(
|
||||||
|
PokerSession.league_id, db.func.count(PokerSession.id)
|
||||||
|
).group_by(PokerSession.league_id).all()
|
||||||
|
avg_sessions_per_league = round(
|
||||||
|
sum(r[1] for r in league_session_rows) / len(league_session_rows), 1
|
||||||
|
) if league_session_rows else 0
|
||||||
|
|
||||||
|
league_buyin_rows = db.session.query(
|
||||||
|
LedgerEvent.league_id, db.func.sum(LedgerEvent.amount_cents)
|
||||||
|
).filter(
|
||||||
|
LedgerEvent.event_type == "buyin",
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
).group_by(LedgerEvent.league_id).all()
|
||||||
|
avg_buyin_cents = int(
|
||||||
|
sum(r[1] for r in league_buyin_rows) / len(league_buyin_rows)
|
||||||
|
) if league_buyin_rows else 0
|
||||||
|
|
||||||
|
today = now.date()
|
||||||
|
chart_days = [today - timedelta(days=i) for i in range(29, -1, -1)]
|
||||||
|
chart_labels = [d.strftime("%b %d") for d in chart_days]
|
||||||
|
chart_iso = [d.isoformat() for d in chart_days]
|
||||||
|
|
||||||
|
signup_raw = db.session.query(
|
||||||
|
db.func.date(User.created_at),
|
||||||
|
db.func.count(User.id),
|
||||||
|
).filter(User.created_at >= month_ago).group_by(db.func.date(User.created_at)).all()
|
||||||
|
signup_by_day = {
|
||||||
|
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
|
||||||
|
for r in signup_raw
|
||||||
|
}
|
||||||
|
chart_signups = [signup_by_day.get(d, 0) for d in chart_iso]
|
||||||
|
|
||||||
|
login_raw = db.session.query(
|
||||||
|
db.func.date(User.last_login_at),
|
||||||
|
db.func.count(User.id),
|
||||||
|
).filter(User.last_login_at >= month_ago).group_by(db.func.date(User.last_login_at)).all()
|
||||||
|
login_by_day = {
|
||||||
|
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
|
||||||
|
for r in login_raw
|
||||||
|
}
|
||||||
|
chart_logins = [login_by_day.get(d, 0) for d in chart_iso]
|
||||||
|
|
||||||
|
session_raw = db.session.query(
|
||||||
|
PokerSession.session_date,
|
||||||
|
db.func.count(PokerSession.id),
|
||||||
|
).filter(PokerSession.session_date >= chart_days[0]).group_by(PokerSession.session_date).all()
|
||||||
|
session_by_day = {
|
||||||
|
(r[0].isoformat() if hasattr(r[0], "isoformat") else r[0]): r[1]
|
||||||
|
for r in session_raw
|
||||||
|
}
|
||||||
|
chart_sessions = [session_by_day.get(d, 0) for d in chart_iso]
|
||||||
|
|
||||||
|
chart_data = json.dumps({
|
||||||
|
"labels": chart_labels,
|
||||||
|
"signups": chart_signups,
|
||||||
|
"logins": chart_logins,
|
||||||
|
"sessions": chart_sessions,
|
||||||
|
})
|
||||||
|
|
||||||
|
recent_users = (
|
||||||
|
db.session.query(User)
|
||||||
|
.order_by(User.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
recent_leagues = (
|
||||||
|
db.session.query(League)
|
||||||
|
.order_by(League.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
league_owner_ids = {league.created_by_user_id for league in recent_leagues}
|
||||||
|
owners_by_id = {
|
||||||
|
u.id: u
|
||||||
|
for u in db.session.query(User).filter(User.id.in_(league_owner_ids)).all()
|
||||||
|
} if league_owner_ids else {}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"disabled_users": disabled_users,
|
||||||
|
"new_users_7d": new_users_7d,
|
||||||
|
"new_users_30d": new_users_30d,
|
||||||
|
"admin_count": admin_count,
|
||||||
|
"dau": dau,
|
||||||
|
"wau": wau,
|
||||||
|
"mau": mau,
|
||||||
|
"total_leagues": total_leagues,
|
||||||
|
"active_leagues": active_leagues,
|
||||||
|
"public_leagues": public_leagues,
|
||||||
|
"new_leagues_30d": new_leagues_30d,
|
||||||
|
"total_sessions": total_sessions,
|
||||||
|
"open_sessions": open_sessions,
|
||||||
|
"total_events": total_events,
|
||||||
|
"avg_sessions_per_league": avg_sessions_per_league,
|
||||||
|
"avg_buyin_cents": avg_buyin_cents,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/dashboard.html",
|
||||||
|
stats=stats,
|
||||||
|
recent_users=recent_users,
|
||||||
|
recent_leagues=recent_leagues,
|
||||||
|
owners_by_id=owners_by_id,
|
||||||
|
chart_data=chart_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/users")
|
||||||
|
@site_admin_required
|
||||||
|
def users():
|
||||||
|
from ..db_models import League, LeagueMembership, User
|
||||||
|
|
||||||
|
search = request.args.get("q", "").strip()
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
query = db.session.query(User)
|
||||||
|
if search:
|
||||||
|
query = query.filter(User.email.ilike(f"%{search}%"))
|
||||||
|
query = query.order_by(User.created_at.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
user_list = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
user_ids = [u.id for u in user_list]
|
||||||
|
league_counts = {}
|
||||||
|
if user_ids:
|
||||||
|
rows = (
|
||||||
|
db.session.query(
|
||||||
|
LeagueMembership.user_id,
|
||||||
|
db.func.count(LeagueMembership.id),
|
||||||
|
)
|
||||||
|
.filter(LeagueMembership.user_id.in_(user_ids))
|
||||||
|
.group_by(LeagueMembership.user_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
league_counts = {row[0]: row[1] for row in rows}
|
||||||
|
|
||||||
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/users.html",
|
||||||
|
users=user_list,
|
||||||
|
league_counts=league_counts,
|
||||||
|
search=search,
|
||||||
|
page=page,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/users/<user_id>")
|
||||||
|
@site_admin_required
|
||||||
|
def user_detail(user_id: str):
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from ..db_models import League, LeagueMembership, User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
reset_url = None
|
||||||
|
if request.args.get("show_reset") == "1":
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/user_detail.html",
|
||||||
|
user=user,
|
||||||
|
memberships=memberships,
|
||||||
|
reset_url=reset_url,
|
||||||
|
viewing_self=user_id == current_user_id(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/send-reset")
|
||||||
|
@site_admin_required
|
||||||
|
def send_reset(user_id: str):
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from ..db_models import User
|
||||||
|
from ..emails import send_password_reset
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
flash("Cannot send a reset link to a disabled account.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
email_sent = False
|
||||||
|
try:
|
||||||
|
send_password_reset(user.email, reset_url)
|
||||||
|
email_sent = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if email_sent:
|
||||||
|
flash(f"Password reset email sent to {user.email}.", "success")
|
||||||
|
else:
|
||||||
|
flash("Email not sent (mail not configured). Copy the link below.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1"))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/disable")
|
||||||
|
@site_admin_required
|
||||||
|
def disable_user(user_id: str):
|
||||||
|
from ..db_models import User
|
||||||
|
|
||||||
|
if user_id == current_user_id():
|
||||||
|
flash("You cannot disable your own account.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
if user.disabled_at is None:
|
||||||
|
user.disabled_at = utc_now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"{user.email} has been disabled.", "success")
|
||||||
|
else:
|
||||||
|
flash("Account is already disabled.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/enable")
|
||||||
|
@site_admin_required
|
||||||
|
def enable_user(user_id: str):
|
||||||
|
from ..db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
user.disabled_at = None
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"{user.email} has been re-enabled.", "success")
|
||||||
|
else:
|
||||||
|
flash("Account is not disabled.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/grant-admin")
|
||||||
|
@site_admin_required
|
||||||
|
def grant_admin(user_id: str):
|
||||||
|
from ..db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
user.is_site_admin = True
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"{user.email} is now a site admin.", "success")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/revoke-admin")
|
||||||
|
@site_admin_required
|
||||||
|
def revoke_admin(user_id: str):
|
||||||
|
from ..db_models import User
|
||||||
|
|
||||||
|
if user_id == current_user_id():
|
||||||
|
flash("You cannot revoke your own admin access.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
user.is_site_admin = False
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Admin access removed from {user.email}.", "success")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/reset-password")
|
||||||
|
@site_admin_required
|
||||||
|
def admin_reset_password(user_id: str):
|
||||||
|
from ..db_models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
if len(new_password) < 8:
|
||||||
|
flash("Password must be at least 8 characters.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Password updated for {user.email}.", "success")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/leagues")
|
||||||
|
@site_admin_required
|
||||||
|
def leagues():
|
||||||
|
from ..db_models import League, LeagueMembership, PokerSession, User
|
||||||
|
|
||||||
|
search = request.args.get("q", "").strip()
|
||||||
|
visibility = request.args.get("visibility", "").strip()
|
||||||
|
show_archived = request.args.get("archived", "0") == "1"
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.session.query(League)
|
||||||
|
.outerjoin(User, User.id == League.created_by_user_id)
|
||||||
|
)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
League.name.ilike(f"%{search}%"),
|
||||||
|
User.email.ilike(f"%{search}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if visibility in ("public", "private"):
|
||||||
|
query = query.filter(League.visibility == visibility)
|
||||||
|
if not show_archived:
|
||||||
|
query = query.filter(League.archived_at.is_(None))
|
||||||
|
query = query.order_by(League.created_at.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
league_list = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
league_ids = [lg.id for lg in league_list]
|
||||||
|
owner_ids = [lg.created_by_user_id for lg in league_list]
|
||||||
|
|
||||||
|
owners_by_id = {}
|
||||||
|
if owner_ids:
|
||||||
|
owners_by_id = {
|
||||||
|
u.id: u
|
||||||
|
for u in db.session.query(User).filter(User.id.in_(owner_ids)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
member_counts = {}
|
||||||
|
session_counts = {}
|
||||||
|
if league_ids:
|
||||||
|
for row in (
|
||||||
|
db.session.query(LeagueMembership.league_id, db.func.count(LeagueMembership.id))
|
||||||
|
.filter(LeagueMembership.league_id.in_(league_ids))
|
||||||
|
.group_by(LeagueMembership.league_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
member_counts[row[0]] = row[1]
|
||||||
|
|
||||||
|
for row in (
|
||||||
|
db.session.query(PokerSession.league_id, db.func.count(PokerSession.id))
|
||||||
|
.filter(PokerSession.league_id.in_(league_ids))
|
||||||
|
.group_by(PokerSession.league_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
session_counts[row[0]] = row[1]
|
||||||
|
|
||||||
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/leagues.html",
|
||||||
|
leagues=league_list,
|
||||||
|
owners_by_id=owners_by_id,
|
||||||
|
member_counts=member_counts,
|
||||||
|
session_counts=session_counts,
|
||||||
|
search=search,
|
||||||
|
visibility=visibility,
|
||||||
|
show_archived=show_archived,
|
||||||
|
page=page,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.get("/leagues/<league_id>")
|
||||||
|
@site_admin_required
|
||||||
|
def league_detail(league_id: str):
|
||||||
|
from ..db_models import League, LedgerEvent, LeagueMembership, Player, PokerSession, User
|
||||||
|
|
||||||
|
league = db.session.get(League, league_id)
|
||||||
|
if league is None:
|
||||||
|
flash("League not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.leagues"))
|
||||||
|
|
||||||
|
memberships = (
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = db.session.get(User, league.created_by_user_id)
|
||||||
|
|
||||||
|
session_count = db.session.query(db.func.count(PokerSession.id)).filter(
|
||||||
|
PokerSession.league_id == league_id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
open_sessions = db.session.query(db.func.count(PokerSession.id)).filter(
|
||||||
|
PokerSession.league_id == league_id,
|
||||||
|
PokerSession.status == "open",
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
event_count = db.session.query(db.func.count(LedgerEvent.id)).filter(
|
||||||
|
LedgerEvent.league_id == league_id,
|
||||||
|
LedgerEvent.voided_at.is_(None),
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
player_count = db.session.query(db.func.count(Player.id)).filter(
|
||||||
|
Player.league_id == league_id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/league_detail.html",
|
||||||
|
league=league,
|
||||||
|
memberships=memberships,
|
||||||
|
owner=owner,
|
||||||
|
session_count=session_count,
|
||||||
|
open_sessions=open_sessions,
|
||||||
|
event_count=event_count,
|
||||||
|
player_count=player_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/leagues/<league_id>/invite")
|
||||||
|
@site_admin_required
|
||||||
|
def admin_invite_member(league_id: str):
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from ..auth import generate_invite_token
|
||||||
|
from ..db_models import League
|
||||||
|
from ..emails import send_league_invite
|
||||||
|
from ..repositories.leagues import find_membership, find_user_by_email
|
||||||
|
|
||||||
|
league = db.session.get(League, league_id)
|
||||||
|
if league is None:
|
||||||
|
flash("League not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.leagues"))
|
||||||
|
|
||||||
|
email = normalize_email(request.form.get("email", ""))
|
||||||
|
role = request.form.get("role", "manager").strip()
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
flash("Enter a valid email address.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
if role not in ("manager", "viewer"):
|
||||||
|
flash("Invalid role.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
existing_user = find_user_by_email(email)
|
||||||
|
if existing_user:
|
||||||
|
existing_membership = find_membership(league.id, existing_user.id)
|
||||||
|
if existing_membership:
|
||||||
|
flash(f"{email} is already a member of this league.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
token = generate_invite_token(league.id, email, role, current_user_id() or "")
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
invite_url = f"{base_url}{url_for('account.accept_invite', token=token)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_league_invite(email, league.name, invite_url, current_user_id() or "")
|
||||||
|
flash(f"Invitation sent to {email}.", "success")
|
||||||
|
except Exception:
|
||||||
|
flash(f"Email not configured. Invite link (copy manually): {invite_url}", "info")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/leagues/<league_id>/members/<user_id>/remove")
|
||||||
|
@site_admin_required
|
||||||
|
def admin_remove_member(league_id: str, user_id: str):
|
||||||
|
from ..db_models import League, LeagueMembership
|
||||||
|
from ..repositories.leagues import remove_league_member
|
||||||
|
|
||||||
|
league = db.session.get(League, league_id)
|
||||||
|
if league is None:
|
||||||
|
flash("League not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.leagues"))
|
||||||
|
|
||||||
|
membership = LeagueMembership.query.filter_by(
|
||||||
|
league_id=league_id,
|
||||||
|
user_id=user_id,
|
||||||
|
disabled_at=None,
|
||||||
|
).one_or_none()
|
||||||
|
|
||||||
|
if membership is None:
|
||||||
|
flash("Member not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
if membership.role == "owner":
|
||||||
|
flash("Cannot remove the league owner. Transfer ownership first.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
remove_league_member(league_id, user_id)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Member removed.", "success")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/leagues/<league_id>/transfer")
|
||||||
|
@site_admin_required
|
||||||
|
def admin_transfer_ownership(league_id: str):
|
||||||
|
from ..db_models import League
|
||||||
|
from ..repositories.leagues import transfer_league_ownership
|
||||||
|
|
||||||
|
league = db.session.get(League, league_id)
|
||||||
|
if league is None:
|
||||||
|
flash("League not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.leagues"))
|
||||||
|
|
||||||
|
new_owner_id = request.form.get("new_owner_user_id", "").strip()
|
||||||
|
if not new_owner_id:
|
||||||
|
flash("Select a member to transfer ownership to.", "error")
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
transfer_league_ownership(league_id, new_owner_id)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Ownership transferred.", "success")
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.league_detail", league_id=league_id))
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
/* ================================================================
|
||||||
|
ADMIN SHELL — left sidebar layout
|
||||||
|
================================================================ */
|
||||||
|
.admin-html, .admin-body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.admin-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-body);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- sidebar ---- */
|
||||||
|
.adm-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.adm-sidebar__logo {
|
||||||
|
padding: 20px 16px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.adm-sidebar__wordmark {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
.adm-sidebar__tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-chip);
|
||||||
|
border: 1px solid var(--accent-chip-bd);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.adm-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.adm-nav__section {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faintest);
|
||||||
|
padding: 12px 8px 4px;
|
||||||
|
}
|
||||||
|
.adm-nav__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.adm-nav__link:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.adm-nav__link.is-active {
|
||||||
|
background: var(--accent-chip);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.adm-nav__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
.adm-nav__link.is-active .adm-nav__icon { opacity: 1; }
|
||||||
|
/* ---- main content ---- */
|
||||||
|
.adm-main {
|
||||||
|
margin-left: 220px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- header ---- */
|
||||||
|
.adm-header {
|
||||||
|
height: 60px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 28px;
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.adm-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.adm-header__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.adm-header__breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.adm-header__breadcrumb a {
|
||||||
|
color: var(--faintest);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color .12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-header__breadcrumb a:hover { color: var(--accent); }
|
||||||
|
.adm-header__sep { color: var(--border-strong); flex-shrink: 0; }
|
||||||
|
.adm-header__title.adm-header__breadcrumb-end {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* breadcrumb leaf node */
|
||||||
|
.adm-header__breadcrumb .adm-header__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.adm-header__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.adm-header__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--faintest);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-header__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 4px 10px;
|
||||||
|
transition: border-color .12s, color .12s, background .12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-header__back:hover {
|
||||||
|
color: var(--text-strong);
|
||||||
|
border-color: var(--border-hi);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
.adm-content {
|
||||||
|
padding: 28px;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- flash messages ---- */
|
||||||
|
.adm-flash-stack {
|
||||||
|
padding: 0 28px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.adm-flash {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.adm-flash.success { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
|
||||||
|
.adm-flash.error { background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); }
|
||||||
|
.adm-flash.info { background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
|
||||||
|
|
||||||
|
/* ---- stat sections ---- */
|
||||||
|
.adm-stat-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.adm-stat-section__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faintest);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
.adm-stat-section__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- stat grid (legacy, still used on other pages) ---- */
|
||||||
|
.adm-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.adm-stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.adm-stat--accent {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 15px;
|
||||||
|
background: var(--accent-tint);
|
||||||
|
}
|
||||||
|
.adm-stat--pos {
|
||||||
|
border-left: 3px solid var(--pos);
|
||||||
|
padding-left: 15px;
|
||||||
|
background: var(--pos-tint);
|
||||||
|
}
|
||||||
|
.adm-stat--warn {
|
||||||
|
border-left: 3px solid var(--warn);
|
||||||
|
padding-left: 15px;
|
||||||
|
background: var(--warn-tint);
|
||||||
|
}
|
||||||
|
.adm-stat__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faintest);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.adm-stat__val {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.adm-stat__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--faintest);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.adm-stat__sub b { color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ---- section heading ---- */
|
||||||
|
.adm-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.adm-section__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.adm-section__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- table ---- */
|
||||||
|
.adm-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.adm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.adm-table th {
|
||||||
|
padding: 9px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--faintest);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--surface-head);
|
||||||
|
}
|
||||||
|
.adm-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
color: var(--text-2);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.adm-table tr:last-child td { border-bottom: none; }
|
||||||
|
.adm-table tr:hover td { background: var(--surface-raised); }
|
||||||
|
.adm-table a { color: var(--accent); text-decoration: none; }
|
||||||
|
.adm-table a:hover { text-decoration: underline; }
|
||||||
|
.adm-table .adm-mono { font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.adm-table .cell-muted { color: var(--faintest); }
|
||||||
|
|
||||||
|
/* ---- pill badges ---- */
|
||||||
|
.adm-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 99px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-pill--active { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
|
||||||
|
.adm-pill--disabled{ background: var(--neg-tint); color: var(--neg); border: 1px solid var(--neg-tint-bd); }
|
||||||
|
.adm-pill--admin { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
|
||||||
|
.adm-pill--open { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
|
||||||
|
.adm-pill--public { background: var(--pos-tint); color: var(--pos); border: 1px solid var(--pos-tint-bd); }
|
||||||
|
.adm-pill--private { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
|
||||||
|
.adm-pill--owner { background: var(--accent-chip); color: var(--accent); border: 1px solid var(--accent-chip-bd); }
|
||||||
|
.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
|
||||||
|
.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
|
||||||
|
.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ---- search bar ---- */
|
||||||
|
.adm-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.adm-search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--field);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.adm-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- buttons ---- */
|
||||||
|
.adm-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity .12s, background .12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-btn:hover { opacity: .85; }
|
||||||
|
.adm-btn--primary { background: var(--accent); color: var(--accent-ink); }
|
||||||
|
.adm-btn--ghost { background: transparent; color: var(--text-2); border-color: var(--border-strong); }
|
||||||
|
.adm-btn--ghost:hover { background: var(--surface-raised); opacity: 1; }
|
||||||
|
.adm-btn--danger { background: var(--neg-tint); color: var(--neg); border-color: var(--neg-tint-bd); }
|
||||||
|
.adm-btn--warn { background: var(--warn-tint); color: var(--warn); border-color: var(--warn-tint-bd); }
|
||||||
|
.adm-btn--sm { padding: 4px 10px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ---- card ---- */
|
||||||
|
.adm-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.adm-card__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.adm-kv { display: grid; gap: 8px; }
|
||||||
|
.adm-kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.adm-kv-row dt { color: var(--faintest); font-size: 12px; }
|
||||||
|
.adm-kv-row dd { margin: 0; color: var(--text-2); word-break: break-all; }
|
||||||
|
.adm-kv-row dd b { color: var(--text-strong); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- action group ---- */
|
||||||
|
.adm-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- reset link box ---- */
|
||||||
|
.adm-reset-box {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--warn-tint);
|
||||||
|
border: 1px solid var(--warn-tint-bd);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.adm-reset-box__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--warn);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.adm-reset-box__url {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- pagination ---- */
|
||||||
|
.adm-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--faintest);
|
||||||
|
}
|
||||||
|
.adm-pagination a {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.adm-pagination a:hover { background: var(--surface-raised); }
|
||||||
|
.adm-pagination span { padding: 4px 10px; color: var(--faintest); }
|
||||||
|
|
||||||
|
/* ---- inline form ---- */
|
||||||
|
.adm-inline-form { display: inline; }
|
||||||
|
.adm-pass-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.adm-pass-form input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--field);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.adm-pass-form input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- chart layout ---- */
|
||||||
|
.adm-chart-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.adm-chart-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.adm-chart-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
}
|
||||||
|
.adm-chart-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.adm-chart-card__title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.adm-chart-card__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--faintest);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.adm-chart-card__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--faintest);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.adm-chart-card__meta b { color: var(--text-2); }
|
||||||
@@ -597,6 +597,13 @@ h3 {
|
|||||||
outline: none;
|
outline: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
.form-card select {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1l4.5 4.5L10 1' stroke='%2366646f' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.form-card input:focus, .form-card select:focus, .form-card textarea:focus {
|
.form-card input:focus, .form-card select:focus, .form-card textarea:focus {
|
||||||
border-color: var(--accent-chip-bd);
|
border-color: var(--accent-chip-bd);
|
||||||
box-shadow: 0 0 0 3px var(--accent-a22);
|
box-shadow: 0 0 0 3px var(--accent-a22);
|
||||||
|
|||||||
@@ -78,6 +78,12 @@
|
|||||||
--w-admin:1580px;
|
--w-admin:1580px;
|
||||||
--gutter: clamp(16px, 4vw, 40px);
|
--gutter: clamp(16px, 4vw, 40px);
|
||||||
|
|
||||||
|
/* ---- typography ---- */
|
||||||
|
--font-ui: Aptos, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-display: var(--font-ui);
|
||||||
|
--font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Roboto Mono", "Liberation Mono", monospace;
|
||||||
|
--font-money: var(--font-ui);
|
||||||
|
|
||||||
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
|
/* ---- Chart.js backward compat (read as raw strings by JS) ---- */
|
||||||
--text-muted: #bcbac6;
|
--text-muted: #bcbac6;
|
||||||
--line: #26262f;
|
--line: #26262f;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="admin-html">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Admin{% endblock %} · boker admin</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
</head>
|
||||||
|
<body class="admin-body">
|
||||||
|
|
||||||
|
<aside class="adm-sidebar">
|
||||||
|
<div class="adm-sidebar__logo">
|
||||||
|
<span class="adm-sidebar__wordmark">myboker.org</span>
|
||||||
|
<span class="adm-sidebar__tag">Admin</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="adm-nav">
|
||||||
|
<span class="adm-nav__section">Overview</span>
|
||||||
|
<a class="adm-nav__link {{ 'is-active' if request.endpoint == 'site_admin.dashboard' }}"
|
||||||
|
href="{{ url_for('site_admin.dashboard') }}">
|
||||||
|
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="1" width="6" height="6" rx="1.5"/>
|
||||||
|
<rect x="9" y="1" width="6" height="6" rx="1.5"/>
|
||||||
|
<rect x="1" y="9" width="6" height="6" rx="1.5"/>
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1.5"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="adm-nav__section">Manage</span>
|
||||||
|
<a class="adm-nav__link {{ 'is-active' if request.endpoint and request.endpoint.startswith('site_admin.user') }}"
|
||||||
|
href="{{ url_for('site_admin.users') }}">
|
||||||
|
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="8" cy="5" r="3"/>
|
||||||
|
<path d="M2 14c0-3.314 2.686-5 6-5s6 1.686 6 5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
<a class="adm-nav__link {{ 'is-active' if request.endpoint and request.endpoint.startswith('site_admin.league') }}"
|
||||||
|
href="{{ url_for('site_admin.leagues') }}">
|
||||||
|
<svg class="adm-nav__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="8" cy="8" r="6.5"/>
|
||||||
|
<path d="M5 8h6M8 5v6"/>
|
||||||
|
</svg>
|
||||||
|
Leagues
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="adm-main">
|
||||||
|
<header class="adm-header">
|
||||||
|
<div class="adm-header__left">
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<h1 class="adm-header__title">{% block page_title %}{% endblock %}</h1>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="adm-header__right">
|
||||||
|
<span class="adm-header__user">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.5">
|
||||||
|
<circle cx="8" cy="5" r="3"/>
|
||||||
|
<path d="M2 14c0-3.314 2.686-5 6-5s6 1.686 6 5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
{{ current_user_id }}
|
||||||
|
</span>
|
||||||
|
<a class="adm-header__back" href="{{ url_for('public.home') }}">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M10 3L5 8l5 5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Back to site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="adm-flash-stack">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="adm-flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="adm-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block page_title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="adm-stat-section">
|
||||||
|
<div class="adm-stat-section__label">Users</div>
|
||||||
|
<div class="adm-stat-section__grid">
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Total users</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.total_users }}</div>
|
||||||
|
<div class="adm-stat__sub"><b>{{ stats.new_users_7d }}</b> new this week · <b>{{ stats.new_users_30d }}</b> this month</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Active accounts</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.active_users }}</div>
|
||||||
|
<div class="adm-stat__sub"><b>{{ stats.disabled_users }}</b> disabled · <b>{{ stats.admin_count }}</b> admin{% if stats.admin_count != 1 %}s{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--accent">
|
||||||
|
<div class="adm-stat__label">Daily active</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.dau }}</div>
|
||||||
|
<div class="adm-stat__sub">logged in today</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--accent">
|
||||||
|
<div class="adm-stat__label">Weekly active</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.wau }}</div>
|
||||||
|
<div class="adm-stat__sub">past 7 days</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--accent">
|
||||||
|
<div class="adm-stat__label">Monthly active</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.mau }}</div>
|
||||||
|
<div class="adm-stat__sub">past 30 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-stat-section">
|
||||||
|
<div class="adm-stat-section__label">Leagues</div>
|
||||||
|
<div class="adm-stat-section__grid">
|
||||||
|
<div class="adm-stat adm-stat--pos">
|
||||||
|
<div class="adm-stat__label">Active leagues</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.active_leagues }}</div>
|
||||||
|
<div class="adm-stat__sub"><b>{{ stats.public_leagues }}</b> public · <b>{{ stats.new_leagues_30d }}</b> new this month</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--pos">
|
||||||
|
<div class="adm-stat__label">Avg sessions / league</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.avg_sessions_per_league }}</div>
|
||||||
|
<div class="adm-stat__sub">across all leagues</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--pos">
|
||||||
|
<div class="adm-stat__label">Avg buyin / league</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.avg_buyin_cents | money }}</div>
|
||||||
|
<div class="adm-stat__sub">total buyins per league</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-stat-section">
|
||||||
|
<div class="adm-stat-section__label">Activity</div>
|
||||||
|
<div class="adm-stat-section__grid">
|
||||||
|
<div class="adm-stat adm-stat--warn">
|
||||||
|
<div class="adm-stat__label">Sessions</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.total_sessions }}</div>
|
||||||
|
<div class="adm-stat__sub"><b>{{ stats.open_sessions }}</b> currently open</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat adm-stat--warn">
|
||||||
|
<div class="adm-stat__label">Ledger events</div>
|
||||||
|
<div class="adm-stat__val">{{ stats.total_events }}</div>
|
||||||
|
<div class="adm-stat__sub">across all leagues</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-chart-row">
|
||||||
|
<div class="adm-chart-card">
|
||||||
|
<div class="adm-chart-card__head">
|
||||||
|
<div>
|
||||||
|
<div class="adm-chart-card__title">User activity</div>
|
||||||
|
<div class="adm-chart-card__sub">Logins & new signups — last 30 days</div>
|
||||||
|
</div>
|
||||||
|
<span class="adm-chart-card__meta">DAU <b>{{ stats.dau }}</b> · MAU <b>{{ stats.mau }}</b></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="userActivityChart" height="110"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="adm-chart-card">
|
||||||
|
<div class="adm-chart-card__head">
|
||||||
|
<div>
|
||||||
|
<div class="adm-chart-card__title">Session frequency</div>
|
||||||
|
<div class="adm-chart-card__sub">Sessions played per day — last 30 days</div>
|
||||||
|
</div>
|
||||||
|
<span class="adm-chart-card__meta">Total <b>{{ stats.total_sessions }}</b></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="sessionActivityChart" height="110"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-section">
|
||||||
|
<div class="adm-section__head">
|
||||||
|
<span class="adm-section__title">Recent sign-ups</span>
|
||||||
|
<a class="adm-btn adm-btn--ghost adm-btn--sm" href="{{ url_for('site_admin.users') }}">View all users</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_users %}
|
||||||
|
<div class="adm-table-wrap">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in recent_users %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>{% if user.is_site_admin %} <span class="adm-pill adm-pill--admin">admin</span>{% endif %}</td>
|
||||||
|
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.disabled_at %}
|
||||||
|
<span class="adm-pill adm-pill--disabled">Disabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--active">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--faintest);font-size:13px;">No users yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-section">
|
||||||
|
<div class="adm-section__head">
|
||||||
|
<span class="adm-section__title">Recently created leagues</span>
|
||||||
|
<a class="adm-btn adm-btn--ghost adm-btn--sm" href="{{ url_for('site_admin.leagues') }}">View all leagues</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_leagues %}
|
||||||
|
<div class="adm-table-wrap">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league in recent_leagues %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a>{% if league.archived_at %} <span class="adm-pill adm-pill--archived">archived</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% set owner = owners_by_id.get(league.created_by_user_id) %}
|
||||||
|
{% if owner %}
|
||||||
|
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="cell-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<span class="adm-pill adm-pill--public">Public</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--private">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cell-muted">{{ league.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--faintest);font-size:13px;">No leagues yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var d = {{ chart_data | safe }};
|
||||||
|
var style = getComputedStyle(document.documentElement);
|
||||||
|
var accent = style.getPropertyValue('--accent').trim() || '#9b8cf0';
|
||||||
|
var pos = style.getPropertyValue('--pos').trim() || '#6fc093';
|
||||||
|
var warn = style.getPropertyValue('--warn').trim() || '#e0b15c';
|
||||||
|
var faint = style.getPropertyValue('--faintest').trim() || '#66646f';
|
||||||
|
var border = style.getPropertyValue('--border').trim() || '#26262f';
|
||||||
|
|
||||||
|
Chart.defaults.color = faint;
|
||||||
|
Chart.defaults.font.family = 'inherit';
|
||||||
|
Chart.defaults.font.size = 11;
|
||||||
|
|
||||||
|
new Chart(document.getElementById('userActivityChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: d.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Active users',
|
||||||
|
data: d.logins,
|
||||||
|
borderColor: accent,
|
||||||
|
backgroundColor: accent + '18',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New signups',
|
||||||
|
data: d.signups,
|
||||||
|
borderColor: pos,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderDash: [5, 3],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 7, color: faint } },
|
||||||
|
y: { beginAtZero: true, grid: { color: border }, ticks: { precision: 0, color: faint } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('sessionActivityChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: d.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Sessions',
|
||||||
|
data: d.sessions,
|
||||||
|
backgroundColor: warn + '55',
|
||||||
|
borderColor: warn + 'bb',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
borderSkipped: false,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 7, color: faint } },
|
||||||
|
y: { beginAtZero: true, grid: { color: border }, ticks: { precision: 0, color: faint } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}{{ league.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<div class="adm-header__breadcrumb">
|
||||||
|
<a href="{{ url_for('site_admin.leagues') }}">Leagues</a>
|
||||||
|
<span class="adm-header__sep">/</span>
|
||||||
|
<span class="adm-header__title">{{ league.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">League info</div>
|
||||||
|
<dl class="adm-kv">
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd><b>{{ league.name }}</b></dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>League ID</dt>
|
||||||
|
<dd class="adm-mono">{{ league.id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Slug / key</dt>
|
||||||
|
<dd class="adm-mono">{{ league.slug }}-{{ league.public_key }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if league.description %}
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd>{{ league.description }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Visibility</dt>
|
||||||
|
<dd>
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<span class="adm-pill adm-pill--public">Public</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--private">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Owner</dt>
|
||||||
|
<dd>
|
||||||
|
{% if owner %}
|
||||||
|
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faintest)">Unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{{ league.created_at.strftime('%b %d, %Y at %H:%M UTC') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
{% if league.archived_at %}
|
||||||
|
<span class="adm-pill adm-pill--archived">Archived</span>
|
||||||
|
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">{{ league.archived_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--active">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-stat-grid" style="margin-bottom:20px;">
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Members</div>
|
||||||
|
<div class="adm-stat__val">{{ memberships | length }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Players</div>
|
||||||
|
<div class="adm-stat__val">{{ player_count }}</div>
|
||||||
|
<div class="adm-stat__sub">added to this league</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Sessions</div>
|
||||||
|
<div class="adm-stat__val">{{ session_count }}</div>
|
||||||
|
<div class="adm-stat__sub"><b>{{ open_sessions }}</b> open</div>
|
||||||
|
</div>
|
||||||
|
<div class="adm-stat">
|
||||||
|
<div class="adm-stat__label">Ledger events</div>
|
||||||
|
<div class="adm-stat__val">{{ event_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not league.archived_at %}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<a class="adm-btn adm-btn--ghost" href="{{ url_for('leagues.dashboard', league_ref=league.url_ref) }}" target="_blank">
|
||||||
|
View league →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if memberships %}
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">Members ({{ memberships | length }})</div>
|
||||||
|
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0 -20px -20px;">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Joined league</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership, user in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||||
|
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
|
||||||
|
{% if user.disabled_at %}<span class="adm-pill adm-pill--disabled" style="margin-left:4px;">disabled</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
|
||||||
|
<td class="cell-muted">{{ membership.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">Admin actions</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Send invite</div>
|
||||||
|
<form method="post" action="{{ url_for('site_admin.admin_invite_member', league_id=league.id) }}" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:180px;">
|
||||||
|
<label style="font-size:11px;color:var(--faintest);">Email</label>
|
||||||
|
<input type="email" name="email" placeholder="user@example.com" required
|
||||||
|
style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<label style="font-size:11px;color:var(--faintest);">Role</label>
|
||||||
|
<select name="role" style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="adm-btn adm-btn--primary adm-btn--sm" type="submit">Send invite</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set has_non_owners = [] %}
|
||||||
|
{% for membership, user in memberships %}{% if membership.role != 'owner' %}{% set _ = has_non_owners.append(1) %}{% endif %}{% endfor %}
|
||||||
|
{% if has_non_owners %}
|
||||||
|
<div style="margin-bottom:20px;border-top:1px solid var(--border);padding-top:16px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Remove member</div>
|
||||||
|
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0;">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>User</th><th>Role</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership, user in memberships %}
|
||||||
|
{% if membership.role != 'owner' %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a></td>
|
||||||
|
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('site_admin.admin_remove_member', league_id=league.id, user_id=user.id) }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--danger adm-btn--sm" type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set has_managers = [] %}
|
||||||
|
{% for membership, user in memberships %}{% if membership.role == 'manager' %}{% set _ = has_managers.append((membership, user)) %}{% endif %}{% endfor %}
|
||||||
|
{% if has_managers %}
|
||||||
|
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:var(--faintest);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Transfer ownership</div>
|
||||||
|
<form method="post" action="{{ url_for('site_admin.admin_transfer_ownership', league_id=league.id) }}" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:180px;">
|
||||||
|
<label style="font-size:11px;color:var(--faintest);">New owner</label>
|
||||||
|
<select name="new_owner_user_id" style="padding:6px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:12px;font-family:inherit;">
|
||||||
|
{% for membership, user in memberships %}
|
||||||
|
{% if membership.role == 'manager' %}
|
||||||
|
<option value="{{ user.id }}">{{ user.email }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="adm-btn adm-btn--warn adm-btn--sm" type="submit">Transfer ownership</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}Leagues{% endblock %}
|
||||||
|
{% block page_title %}Leagues <span style="color:var(--faintest);font-weight:400;font-size:12px;margin-left:6px;">{{ total }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form class="adm-search" method="get">
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="Search by name or owner email…" autofocus>
|
||||||
|
<select name="visibility" style="padding:8px 10px;background:var(--field);border:1px solid var(--border-strong);border-radius:var(--r-sm);color:var(--text);font-size:13px;font-family:inherit;">
|
||||||
|
<option value="" {% if not visibility %}selected{% endif %}>All visibility</option>
|
||||||
|
<option value="public" {% if visibility == 'public' %}selected{% endif %}>Public</option>
|
||||||
|
<option value="private" {% if visibility == 'private' %}selected{% endif %}>Private</option>
|
||||||
|
</select>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-2);white-space:nowrap;">
|
||||||
|
<input type="checkbox" name="archived" value="1" {% if show_archived %}checked{% endif %}> Show archived
|
||||||
|
</label>
|
||||||
|
<button class="adm-btn adm-btn--primary" type="submit">Filter</button>
|
||||||
|
{% if search or visibility or show_archived %}
|
||||||
|
<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.leagues') }}">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if leagues %}
|
||||||
|
<div class="adm-table-wrap">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Members</th>
|
||||||
|
<th>Sessions</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for league in leagues %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% set owner = owners_by_id.get(league.created_by_user_id) %}
|
||||||
|
{% if owner %}
|
||||||
|
<a href="{{ url_for('site_admin.user_detail', user_id=owner.id) }}">{{ owner.email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="cell-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<span class="adm-pill adm-pill--public">Public</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--private">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cell-muted">{{ member_counts.get(league.id, 0) }}</td>
|
||||||
|
<td class="cell-muted">{{ session_counts.get(league.id, 0) }}</td>
|
||||||
|
<td class="cell-muted">{{ league.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if league.archived_at %}
|
||||||
|
<span class="adm-pill adm-pill--archived">Archived</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--active">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="adm-pagination">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('site_admin.leagues', q=search, visibility=visibility, archived='1' if show_archived else '', page=page-1) }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="{{ url_for('site_admin.leagues', q=search, visibility=visibility, archived='1' if show_archived else '', page=page+1) }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--faintest);font-size:13px;">
|
||||||
|
{% if search or visibility %}No leagues matching those filters.{% else %}No leagues yet.{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}{{ user.email }}{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<div class="adm-header__breadcrumb">
|
||||||
|
<a href="{{ url_for('site_admin.users') }}">Users</a>
|
||||||
|
<span class="adm-header__sep">/</span>
|
||||||
|
<span class="adm-header__title">{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">Account</div>
|
||||||
|
<dl class="adm-kv">
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd><b>{{ user.email }}</b></dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="adm-mono">{{ user.id }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Joined</dt>
|
||||||
|
<dd>{{ user.created_at.strftime('%b %d, %Y at %H:%M UTC') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Last login</dt>
|
||||||
|
<dd>{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}<span style="color:var(--faintest)">Never</span>{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
{% if user.disabled_at %}
|
||||||
|
<span class="adm-pill adm-pill--disabled">Disabled</span>
|
||||||
|
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">since {{ user.disabled_at.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--active">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Site admin</dt>
|
||||||
|
<dd>
|
||||||
|
{% if user.is_site_admin %}
|
||||||
|
<span class="adm-pill adm-pill--admin">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--faintest)">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Leagues</dt>
|
||||||
|
<dd>{{ memberships | length }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">Actions</div>
|
||||||
|
|
||||||
|
<div class="adm-section" style="margin-bottom:20px;">
|
||||||
|
<div class="adm-section__title" style="margin-bottom:8px;">Password reset</div>
|
||||||
|
<div class="adm-actions">
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.send_reset', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--ghost" type="submit"{% if user.disabled_at %} disabled title="Account is disabled"{% endif %}>
|
||||||
|
Send reset email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.user_detail', user_id=user.id, show_reset=1) }}">
|
||||||
|
Generate link (no email)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if reset_url %}
|
||||||
|
<div class="adm-reset-box">
|
||||||
|
<div class="adm-reset-box__label">Reset link — valid 1 hour</div>
|
||||||
|
<div class="adm-reset-box__url">{{ reset_url }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-section" style="margin-bottom:20px;">
|
||||||
|
<div class="adm-section__title" style="margin-bottom:8px;">Set password directly</div>
|
||||||
|
<form class="adm-pass-form" method="post" action="{{ url_for('site_admin.admin_reset_password', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="password" name="new_password" placeholder="New password (min 8 chars)" autocomplete="new-password">
|
||||||
|
<button class="adm-btn adm-btn--warn" type="submit">Set password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not viewing_self %}
|
||||||
|
<div class="adm-section" style="margin-bottom:20px;">
|
||||||
|
<div class="adm-section__title" style="margin-bottom:8px;">Account status</div>
|
||||||
|
<div class="adm-actions">
|
||||||
|
{% if user.disabled_at %}
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.enable_user', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--ghost" type="submit">Enable account</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.disable_user', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--danger" type="submit">Disable account</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-section">
|
||||||
|
<div class="adm-section__title" style="margin-bottom:8px;">Admin access</div>
|
||||||
|
<div class="adm-actions">
|
||||||
|
{% if user.is_site_admin %}
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.revoke_admin', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--danger" type="submit">Revoke admin</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.grant_admin', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--ghost" type="submit">Grant admin</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if memberships %}
|
||||||
|
<div class="adm-card">
|
||||||
|
<div class="adm-card__title">League memberships ({{ memberships | length }})</div>
|
||||||
|
<div class="adm-table-wrap" style="border:none;border-radius:0;margin:0 -20px -20px;">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>League</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership, league in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('site_admin.league_detail', league_id=league.id) }}">{{ league.name }}</a>
|
||||||
|
{% if league.archived_at %}<span class="adm-pill adm-pill--archived" style="margin-left:4px;">archived</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="adm-pill adm-pill--{{ membership.role }}">{{ membership.role }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if league.visibility == 'public' %}
|
||||||
|
<span class="adm-pill adm-pill--public">Public</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--private">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cell-muted">{{ membership.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}Users{% endblock %}
|
||||||
|
{% block page_title %}Users <span style="color:var(--faintest);font-weight:400;font-size:12px;margin-left:6px;">{{ total }}</span>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<form class="adm-search" method="get">
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="Search by email…" autofocus>
|
||||||
|
<button class="adm-btn adm-btn--primary" type="submit">Search</button>
|
||||||
|
{% if search %}<a class="adm-btn adm-btn--ghost" href="{{ url_for('site_admin.users') }}">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if users %}
|
||||||
|
<div class="adm-table-wrap">
|
||||||
|
<table class="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Last login</th>
|
||||||
|
<th>Leagues</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||||
|
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td class="cell-muted">
|
||||||
|
{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y') }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="cell-muted">{{ league_counts.get(user.id, 0) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.disabled_at %}
|
||||||
|
<span class="adm-pill adm-pill--disabled">Disabled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--active">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="adm-pagination">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('site_admin.users', q=search, page=page-1) }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="{{ url_for('site_admin.users', q=search, page=page+1) }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--faintest);font-size:13px;">
|
||||||
|
{% if search %}No users matching "{{ search }}".{% else %}No users yet.{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -64,18 +64,33 @@
|
|||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<input type="email" name="email" placeholder="friend@example.com" required>
|
<input type="email" name="email" placeholder="friend@example.com" required>
|
||||||
</label>
|
</label>
|
||||||
<label style="flex:0 0 130px;">
|
<label style="flex:0 0 140px;">
|
||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<select name="role">
|
<select name="role">
|
||||||
<option value="manager">Manager</option>
|
<option value="manager">Manager</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn--primary btn--sm" type="submit" style="flex:0 0 auto;margin-bottom:1px;">Send invite</button>
|
<button class="btn btn--primary" type="submit" style="flex:0 0 auto;">Send invite</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_managers %}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker" style="margin:0;">Ownership</p>
|
||||||
|
<h2 class="panel__title">Transfer league ownership</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body">
|
||||||
|
<p class="muted-text">Transfer ownership of this league to one of your managers. You will become a manager and lose owner privileges.</p>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-modal="modal-transfer" style="margin-top:8px;">Transfer ownership</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel__head">
|
<div class="panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -96,6 +111,48 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_managers %}
|
||||||
|
<div class="modal-backdrop" id="modal-transfer" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-card__head">
|
||||||
|
<h2 style="font-size:18px;margin:0;">Transfer ownership</h2>
|
||||||
|
</div>
|
||||||
|
<p class="modal-card__text">
|
||||||
|
Select a manager to become the new owner. You will become a manager and lose owner privileges. This cannot be undone without their help.<br><br>
|
||||||
|
Type the league name to confirm:
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('leagues.transfer_ownership', league_ref=league.url_ref) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div style="padding:0 20px 12px;">
|
||||||
|
<label style="font-size:12px;color:var(--text-2);display:block;margin-bottom:4px;">New owner</label>
|
||||||
|
<select name="new_owner_user_id" style="width:100%;padding:8px 10px;border:1px solid var(--border-strong);border-radius:var(--r-sm);background:var(--field);color:var(--text);font-family:inherit;font-size:13px;">
|
||||||
|
{% for membership, member_user in members %}
|
||||||
|
{% if membership.role == 'manager' %}
|
||||||
|
<option value="{{ member_user.id }}">{{ member_user.email }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 20px 16px;">
|
||||||
|
<input
|
||||||
|
class="modal-confirm-input"
|
||||||
|
type="text"
|
||||||
|
name="confirm_name"
|
||||||
|
placeholder="{{ league.name }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-match="{{ league.name }}"
|
||||||
|
data-target="transfer-confirm-btn"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="modal-card__actions">
|
||||||
|
<button class="btn btn--ghost btn--sm" type="button" data-close-modal>Cancel</button>
|
||||||
|
<button class="btn btn--sm btn--danger" id="transfer-confirm-btn" type="submit" disabled>Transfer ownership</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="modal-backdrop" id="modal-archive" hidden>
|
<div class="modal-backdrop" id="modal-archive" hidden>
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-card__head">
|
<div class="modal-card__head">
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add is_site_admin to users
|
||||||
|
|
||||||
|
Revision ID: 0003_site_admin
|
||||||
|
Revises: 0002_league_public_key
|
||||||
|
Create Date: 2026-06-24
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0003_site_admin"
|
||||||
|
down_revision = "0002_league_public_key"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"is_site_admin",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("users") as batch_op:
|
||||||
|
batch_op.drop_column("is_site_admin")
|
||||||
Reference in new issue
Block a user