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,
|
||||
})
|
||||
|
||||
@@ -65,3 +65,49 @@ def setup():
|
||||
"user": _store().public_user(updated),
|
||||
"must_setup": False,
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.post("/change-password")
|
||||
@require_auth
|
||||
def change_password():
|
||||
"""Change own password — requires the current password to be correct."""
|
||||
user = current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
current_pw = data.get("current_password") or ""
|
||||
new_pw = data.get("new_password") or ""
|
||||
if not current_pw or not new_pw:
|
||||
raise ApiError("current and new password are required")
|
||||
# verify the current password first
|
||||
try:
|
||||
_store().verify(user["username"], current_pw)
|
||||
except AuthError:
|
||||
raise ApiError("current password is incorrect", 400)
|
||||
if len(new_pw) < 4:
|
||||
raise ApiError("new password must be at least 4 characters", 400)
|
||||
updated = _store().set_password(user["username"], new_pw)
|
||||
return jsonify({"ok": True, "user": _store().public_user(updated)})
|
||||
|
||||
|
||||
@auth_bp.put("/profile")
|
||||
@require_auth
|
||||
def update_profile():
|
||||
"""Update own profile: display name and/or email."""
|
||||
user = current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
updates = {}
|
||||
if "name" in data:
|
||||
name = (data.get("name") or "").strip()
|
||||
if name:
|
||||
updates["name"] = name
|
||||
_store().users.update(user["username"], name=name)
|
||||
if "email" in data:
|
||||
email = (data.get("email") or "").strip()
|
||||
try:
|
||||
_store().set_email(user["username"], email)
|
||||
except AuthError as exc:
|
||||
raise ApiError(str(exc), 400)
|
||||
updates["email"] = (email or "").lower()
|
||||
if not updates:
|
||||
raise ApiError("nothing to update", 400)
|
||||
updated = _store().get_user(user["username"])
|
||||
return jsonify({"ok": True, "user": _store().public_user(updated)})
|
||||
|
||||
@@ -45,7 +45,7 @@ def _get_ready_group(s, gid: str) -> dict:
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def start_session(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = _get_ready_group(s, gid)
|
||||
@@ -85,7 +85,7 @@ def start_session(gid: str, pid: str):
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def send_message(gid: str, pid: str):
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
@@ -125,7 +125,7 @@ def send_message(gid: str, pid: str):
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def finish_session(gid: str, pid: str):
|
||||
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
|
||||
s = _stores()
|
||||
@@ -168,7 +168,7 @@ def finish_session(gid: str, pid: str):
|
||||
|
||||
@chat_bp.get("/sessions")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def my_sessions():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
@@ -178,7 +178,7 @@ def my_sessions():
|
||||
|
||||
@chat_bp.get("/sessions/<sid>")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def get_session(sid: str):
|
||||
s = _stores()
|
||||
session = s["sessions"].get_or_none(sid)
|
||||
|
||||
@@ -23,15 +23,20 @@ def _stores():
|
||||
|
||||
@me_bp.get("/board")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def win_lose_board():
|
||||
"""Per-persona won/lost/not-tried across all groups the user sees."""
|
||||
"""Per-persona won/lost/not-tried across all groups the user sees.
|
||||
|
||||
Available to trainees AND admins (admins may also train/practice).
|
||||
"""
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
actor = current_user()
|
||||
uid = actor["id"]
|
||||
role = actor.get("role", "user")
|
||||
my_sessions = s["sessions"].list_for_user(uid)
|
||||
outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions}
|
||||
|
||||
groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id"))
|
||||
groups = s["groups"].list_visible_to(role=role, org_id=actor.get("org_id"))
|
||||
# Only the owner sees their personal groups (IDOR defense).
|
||||
groups = [g for g in groups if not g.get("owner_user_id") or g.get("owner_user_id") == uid]
|
||||
items = []
|
||||
@@ -51,7 +56,7 @@ def win_lose_board():
|
||||
|
||||
@me_bp.get("/weak-areas")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def weak_areas():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
@@ -83,7 +88,7 @@ def _personal_group(s, actor) -> dict:
|
||||
|
||||
@me_bp.get("/personas")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def my_personas():
|
||||
s = _stores()
|
||||
uid = current_user()["id"]
|
||||
@@ -93,7 +98,7 @@ def my_personas():
|
||||
|
||||
@me_bp.post("/personas/generate")
|
||||
@require_auth
|
||||
@require_roles("user")
|
||||
@require_roles("user", "admin", "super_admin")
|
||||
def generate_persona():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
|
||||
Reference in New Issue
Block a user