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:
Macky
2026-08-07 19:11:11 +07:00
parent 681561c22a
commit fc041b8f37
13 changed files with 707 additions and 97 deletions

View File

@@ -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,
})

View File

@@ -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)})

View File

@@ -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)

View File

@@ -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()