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
152 lines
4.6 KiB
Python
152 lines
4.6 KiB
Python
"""Admin analytics: aggregate trainee results, with optional date-range filter."""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from .helpers import current_user, require_auth, require_roles
|
|
|
|
analytics_bp = Blueprint("analytics", __name__)
|
|
|
|
|
|
def _stores():
|
|
from flask import current_app
|
|
|
|
return {
|
|
"sessions": current_app.extensions["session_store"],
|
|
"groups": current_app.extensions["group_store"],
|
|
"users": current_app.extensions["user_store"],
|
|
}
|
|
|
|
|
|
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 = 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
|
|
)
|
|
|
|
# average score
|
|
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)
|
|
by_persona: dict = {}
|
|
for x in sessions:
|
|
key = (x.get("group_id"), x.get("persona_id"), x.get("persona_name", "?"))
|
|
if key not in by_persona:
|
|
by_persona[key] = {"plays": 0, "losses": 0, "wins": 0, "scores": []}
|
|
rec = by_persona[key]
|
|
rec["plays"] += 1
|
|
rec["scores"].append((x.get("debrief") or {}).get("score", 0))
|
|
if x.get("outcome") == "won":
|
|
rec["wins"] += 1
|
|
elif x.get("outcome") == "lost":
|
|
rec["losses"] += 1
|
|
hardest = sorted(
|
|
(
|
|
{
|
|
"persona_name": k[2],
|
|
"plays": v["plays"],
|
|
"wins": v["wins"],
|
|
"losses": v["losses"],
|
|
"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,
|
|
})
|