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
277 lines
11 KiB
Python
277 lines
11 KiB
Python
#!/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)
|