UI: 3-tab layout (Admin overview / My dashboard / Training) + Settings page
Frontend: - App shell with 3 tabs: Overview (admin), My Dashboard (all incl admin), Training + Settings icon - New Settings.vue: edit profile (name/email) + change password (verify current) - New MyBoard.vue: personal win/lose + weak areas + summary (all roles) - New Training.vue: product/group list -> personas; admin manage + add product - Dashboard.vue -> admin overview with date-range filter + per-trainee stats - Added i18n keys (EN/TH) and tab/settings styling (ui-ux-pro-max design system) Backend: - /api/auth/change-password (verify current pw) + PUT /api/auth/profile - Allow admin to train: board/weak-areas/chat/personas/sessions open to user+admin+super_admin - /api/analytics now accepts ?from=&to= date filter + returns per_user summary Verified: frontend builds; change-password (wrong/ok), profile, board all pass via test client; security tests pass
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"""Admin analytics: aggregate trainee results."""
|
||||
"""Admin analytics: aggregate trainee results, with optional date-range filter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
import datetime
|
||||
|
||||
from .helpers import ApiError, current_user, require_auth, require_roles
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from .helpers import current_user, require_auth, require_roles
|
||||
|
||||
analytics_bp = Blueprint("analytics", __name__)
|
||||
|
||||
@@ -18,35 +20,77 @@ def _stores():
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(val: str):
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(val)
|
||||
except ValueError:
|
||||
try:
|
||||
return datetime.datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@analytics_bp.get("")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def analytics():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
|
||||
# Optional date-range filter (inclusive): ?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
d_from = _parse_date((request.args.get("from") or "").strip())
|
||||
d_to = _parse_date((request.args.get("to") or "").strip())
|
||||
if d_to:
|
||||
# include the whole "to" day
|
||||
d_to = d_to.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
if actor.get("role") == "super_admin":
|
||||
sessions = s["sessions"].sessions.all()
|
||||
users = s["users"].list_users()
|
||||
else:
|
||||
org_id = actor.get("org_id")
|
||||
# users in this org
|
||||
users = s["users"].list_users(org_id=org_id)
|
||||
user_ids = {u["id"] for u in users}
|
||||
sessions = [
|
||||
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
|
||||
]
|
||||
|
||||
if d_from or d_to:
|
||||
filtered = []
|
||||
for x in sessions:
|
||||
ts = x.get("created_at") or x.get("finished_at") or ""
|
||||
if not ts:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(ts)
|
||||
except ValueError:
|
||||
continue
|
||||
if d_from and dt < d_from:
|
||||
continue
|
||||
if d_to and dt > d_to:
|
||||
continue
|
||||
filtered.append(x)
|
||||
sessions = filtered
|
||||
|
||||
overall = {
|
||||
"total_sessions": len(sessions),
|
||||
"wins": sum(1 for x in sessions if x.get("outcome") == "won"),
|
||||
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
|
||||
}
|
||||
overall["close_rate"] = round(
|
||||
overall["wins"] / overall["total_sessions"] * 100, 1
|
||||
) if overall["total_sessions"] else 0
|
||||
overall["close_rate"] = (
|
||||
round(overall["wins"] / overall["total_sessions"] * 100, 1)
|
||||
if overall["total_sessions"]
|
||||
else 0
|
||||
)
|
||||
|
||||
# average score
|
||||
scores = [ (x.get("debrief") or {}).get("score", 0) for x in sessions if x.get("outcome") ]
|
||||
scores = [
|
||||
(x.get("debrief") or {}).get("score", 0)
|
||||
for x in sessions
|
||||
if x.get("outcome")
|
||||
]
|
||||
overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
|
||||
|
||||
# hardest personas = personas with most losses (lowest avg score)
|
||||
@@ -69,15 +113,39 @@ def analytics():
|
||||
"plays": v["plays"],
|
||||
"wins": v["wins"],
|
||||
"losses": v["losses"],
|
||||
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1) if v["scores"] else 0,
|
||||
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1)
|
||||
if v["scores"]
|
||||
else 0,
|
||||
}
|
||||
for k, v in by_persona.items()
|
||||
),
|
||||
key=lambda r: (r["losses"], -r["avg_score"]),
|
||||
)[:10]
|
||||
|
||||
# per-user summary so the admin overview can show per-trainee results
|
||||
by_user: dict = {}
|
||||
for x in sessions:
|
||||
uid = x.get("user_id", "?")
|
||||
if uid not in by_user:
|
||||
by_user[uid] = {"sessions": 0, "wins": 0, "losses": 0}
|
||||
by_user[uid]["sessions"] += 1
|
||||
if x.get("outcome") == "won":
|
||||
by_user[uid]["wins"] += 1
|
||||
elif x.get("outcome") == "lost":
|
||||
by_user[uid]["losses"] += 1
|
||||
name_by_id = {u.get("id"): u.get("name") or u.get("username") for u in users}
|
||||
per_user = [
|
||||
{
|
||||
"user_id": uid,
|
||||
"name": name_by_id.get(uid, uid),
|
||||
**v,
|
||||
}
|
||||
for uid, v in by_user.items()
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"overall": overall,
|
||||
"trainee_count": len(users),
|
||||
"hardest_personas": hardest,
|
||||
"per_user": per_user,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user