feat(reports): replace mock Reports pages with real backend aggregates

Wire the three admin Reports pages (/admin/student-performance,
/admin/stats-corporate, /admin/record) to a new
encoach_lms_api/controllers/reports.py that aggregates from
encoach.student.attempt.

* /api/reports/student-performance: per-student band averages + CEFR,
  with entity / level / search filters.
* /api/reports/stats-corporate: by_module bar, N-month trend line,
  CEFR distribution pie, entity comparison bar, threshold + entity
  filters and meta.attempts_considered for UI.
* /api/reports/record: paginated attempt history with entity / user /
  period filters, EX-### exam codes, duration derived from start/end.
* /api/reports/filters: shared picker returning only entities /
  students that actually have attempts.

Frontend: new reports.service.ts, all three pages rewritten to hit
these endpoints; Recharts graphs now read live data, CSV export added
on Student Performance and Record.

Seeding: seed_reports.py completes any in_progress attempts and
backfills 6 months of historical attempts across 3 entities so the
trend / distribution / KPI panels have meaningful data. Idempotent.

Tests: test_reports_flows.py (25/25 PASS) covers shape, filters,
pagination. Regressions still green: Configuration 24/24, Support
29/29, Training 26/26. Browser-verified on localhost:8080 with admin
login — all 4 tabs in Stats Corporate render, Student Performance
shows real students + KPIs, Record shows 28 attempts with filter +
CSV export working.

Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts,
seeding, test results, and gotchas (encoach.exam.custom uses `title`
not `name`; encoach.entity requires `code`; in_progress attempts are
excluded from aggregates).

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 11:28:26 +04:00
parent d940db075e
commit c016a52200
9 changed files with 1795 additions and 185 deletions

276
test_reports_flows.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""API smoke tests for the admin Reports section.
Covers the three pages in `AdminLmsLayout.tsx` under "Reports":
* /admin/student-performance -> /api/reports/student-performance
* /admin/stats-corporate -> /api/reports/stats-corporate
* /admin/record -> /api/reports/record
* (shared filter picker) -> /api/reports/filters
Usage:
python3 test_reports_flows.py
"""
import json
import sys
from urllib import request as urlreq
from urllib.error import HTTPError
BASE = "http://localhost:8069/api"
TOKEN = None
GREEN = "\033[32m"; RED = "\033[31m"; RESET = "\033[0m"; BOLD = "\033[1m"
results = []
def log(page, action, ok_, note=""):
results.append((page, action, ok_, note))
tag = f"{GREEN}PASS{RESET}" if ok_ else f"{RED}FAIL{RESET}"
print(f" [{tag}] {page:22s} {action:42s} {note}")
def _req(method, path, body=None, params=None):
url = BASE + path
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
url += ("?" + qs) if qs else ""
data = None
headers = {"Accept": "application/json"}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urlreq.Request(url, data=data, method=method, headers=headers)
try:
with urlreq.urlopen(req, timeout=20) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else {})
except HTTPError as e:
raw = e.read().decode() if e.fp else ""
try:
payload = json.loads(raw)
except Exception:
payload = {"error": raw[:200]}
return e.code, payload
except Exception as e:
return 0, {"error": str(e)}
def GET(path, **params): return _req("GET", path, params=params)
def POST(path, body=None): return _req("POST", path, body=body or {})
def ok(s): return 200 <= s < 300
def login():
global TOKEN
s, b = POST("/login", {"email": "admin", "password": "admin"})
TOKEN = (b or {}).get("token")
return bool(TOKEN)
# ── Filters picker ──────────────────────────────────────────────────────
def test_filters():
print(f"{BOLD}== /api/reports/filters =={RESET}")
s, b = GET("/reports/filters")
entities = (b or {}).get("entities") or []
students = (b or {}).get("students") or []
log("reports-filters", "GET", ok(s) and isinstance(entities, list) and isinstance(students, list),
f"status={s} entities={len(entities)} students={len(students)}")
return entities, students
# ── 1. Student Performance ─────────────────────────────────────────────
def test_student_performance(entities):
print(f"{BOLD}== /admin/student-performance =={RESET}")
s, b = GET("/reports/student-performance")
rows = (b or {}).get("items") or []
log("student-performance", "LIST returns rows",
ok(s) and isinstance(rows, list) and len(rows) > 0,
f"status={s} rows={len(rows)}")
if rows:
r0 = rows[0]
expected_keys = {"student_id", "student_name", "entity_name",
"reading", "listening", "writing", "speaking",
"overall", "level", "attempts_count"}
log("student-performance", "row shape complete",
expected_keys.issubset(r0.keys()),
f"missing={sorted(expected_keys - set(r0.keys()))}")
log("student-performance", "overall is numeric/None",
r0["overall"] is None or isinstance(r0["overall"], (int, float)),
f"overall={r0['overall']}")
log("student-performance", "CEFR level assigned",
r0["level"] in {"Pre-A1","A1","A2","B1","B2","C1","C2", None},
f"level={r0['level']}")
s2, b2 = GET("/reports/student-performance",
search=(r0["student_name"] or "").split()[0])
rows2 = (b2 or {}).get("items") or []
log("student-performance", "search filter",
ok(s2) and any(r["student_id"] == r0["student_id"] for r in rows2),
f"status={s2} matched={len(rows2)}")
if r0["level"]:
s3, b3 = GET("/reports/student-performance", level=r0["level"])
rows3 = (b3 or {}).get("items") or []
log("student-performance", "level filter",
ok(s3) and all(r["level"] == r0["level"] for r in rows3),
f"status={s3} filtered={len(rows3)}")
if entities:
eid = entities[0]["id"]
s4, b4 = GET("/reports/student-performance", entity_id=eid)
rows4 = (b4 or {}).get("items") or []
log("student-performance", "entity filter",
ok(s4) and all(r["entity_id"] in (None, eid) or r["entity_id"] == eid for r in rows4),
f"status={s4} entity_id={eid} rows={len(rows4)}")
# ── 2. Stats Corporate ─────────────────────────────────────────────────
def test_stats_corporate(entities):
print(f"{BOLD}== /admin/stats-corporate =={RESET}")
s, b = GET("/reports/stats-corporate")
by_module = (b or {}).get("by_module") or []
trend = (b or {}).get("trend") or []
dist = (b or {}).get("distribution") or []
comp = (b or {}).get("comparison") or []
meta = (b or {}).get("meta") or {}
log("stats-corporate", "LIST returns envelope",
ok(s) and all(k in (b or {}) for k in
("by_module", "trend", "distribution",
"comparison", "meta")),
f"status={s} keys={sorted((b or {}).keys()) if b else 'n/a'}")
log("stats-corporate", "by_module has 4 modules",
len(by_module) == 4 and {m["module"] for m in by_module} ==
{"Reading","Listening","Writing","Speaking"},
f"modules={[m['module'] for m in by_module]}")
log("stats-corporate", "trend length == 6 (default months)",
len(trend) == 6,
f"trend={len(trend)}")
log("stats-corporate", "distribution has CEFR levels",
isinstance(dist, list) and len(dist) > 0 and
all("name" in d and "value" in d and "color" in d for d in dist),
f"bins={len(dist)}")
log("stats-corporate", "comparison is entity-level",
isinstance(comp, list) and all("entity_name" in c for c in comp),
f"entities={len(comp)}")
log("stats-corporate", "meta.attempts_considered > 0",
meta.get("attempts_considered", 0) > 0,
f"meta={meta}")
# Threshold filter -> should narrow attempts considered.
s2, b2 = GET("/reports/stats-corporate", threshold=70)
log("stats-corporate", "threshold=70 filter",
ok(s2) and (b2 or {}).get("meta", {}).get("threshold") == 70.0,
f"status={s2} meta={(b2 or {}).get('meta')}")
# Entity filter
if entities:
eid = entities[0]["id"]
s3, b3 = GET("/reports/stats-corporate", entity_id=eid)
cmp3 = (b3 or {}).get("comparison") or []
log("stats-corporate", "entity filter narrows comparison",
ok(s3) and all(c["entity_id"] in (None, eid) or c["entity_id"] == eid
for c in cmp3),
f"status={s3} entities={len(cmp3)}")
# Months horizon
s4, b4 = GET("/reports/stats-corporate", months=3)
log("stats-corporate", "months=3 trend len",
ok(s4) and len((b4 or {}).get("trend") or []) == 3,
f"status={s4} trend={len((b4 or {}).get('trend') or [])}")
# ── 3. Record ──────────────────────────────────────────────────────────
def test_record(entities, students):
print(f"{BOLD}== /admin/record =={RESET}")
s, b = GET("/reports/record", size=10)
items = (b or {}).get("items") or []
log("record", "LIST (paginated)", ok(s) and isinstance(items, list),
f"status={s} got={len(items)} total={(b or {}).get('total')}")
if items:
r0 = items[0]
keys = {"id", "student_name", "assignment", "exam", "exam_code",
"date", "score", "duration_min", "status", "status_label"}
log("record", "row shape complete",
keys.issubset(r0.keys()),
f"missing={sorted(keys - set(r0.keys()))}")
log("record", "exam_code is EX-###",
r0["exam_code"].startswith("EX-") if r0["exam_code"] else True,
f"exam_code={r0['exam_code']}")
log("record", "status_label is titlecase",
isinstance(r0["status_label"], str) and r0["status_label"][:1].isupper(),
f"status_label={r0['status_label']}")
if students:
sid = students[0]["id"]
s2, b2 = GET("/reports/record", user_id=sid, size=50)
items2 = (b2 or {}).get("items") or []
log("record", "user_id filter",
ok(s2) and all(i["student_id"] == sid for i in items2),
f"status={s2} user_id={sid} rows={len(items2)}")
if entities:
eid = entities[0]["id"]
s3, b3 = GET("/reports/record", entity_id=eid, size=50)
items3 = (b3 or {}).get("items") or []
log("record", "entity_id filter",
ok(s3) and all(i["entity_id"] in (None, eid) or i["entity_id"] == eid
for i in items3),
f"status={s3} entity_id={eid} rows={len(items3)}")
# Period filter (month should return most data)
s4, b4 = GET("/reports/record", period="month", size=50)
log("record", "period=month filter",
ok(s4) and isinstance((b4 or {}).get("items"), list),
f"status={s4} rows={len((b4 or {}).get('items') or [])}")
# Pagination
s5, b5 = GET("/reports/record", size=5, page=1)
s6, b6 = GET("/reports/record", size=5, page=2)
log("record", "pagination size=5",
ok(s5) and ok(s6)
and len((b5 or {}).get("items") or []) <= 5
and len((b6 or {}).get("items") or []) <= 5,
f"p1={len((b5 or {}).get('items') or [])} p2={len((b6 or {}).get('items') or [])}")
# ── Main ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"{BOLD}Reports Section API Smoke Tests{RESET}")
if not login():
print(f"{RED}Login failed. Is Odoo running on :8069 with admin/admin?{RESET}")
sys.exit(1)
print(f" logged in, token={TOKEN[:12] if TOKEN else 'none'}...\n")
entities, students = test_filters()
print()
test_student_performance(entities)
print()
test_stats_corporate(entities)
print()
test_record(entities, students)
total = len(results)
passed = sum(1 for r in results if r[2])
failed = total - passed
print("\n" + "=" * 70)
print(f"{BOLD}Summary: {GREEN}{passed} passed{RESET}, "
f"{RED}{failed} failed{RESET}, {total} total")
if failed:
print(f"\n{RED}Failing:{RESET}")
for page, action, ok_, note in results:
if not ok_:
print(f" - {page:22s} {action:42s} {note}")
sys.exit(2)