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:
@@ -24,3 +24,4 @@ from . import tickets
|
||||
from . import payments
|
||||
from . import platform_settings
|
||||
from . import training
|
||||
from . import reports
|
||||
|
||||
446
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal file
446
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""Reports API.
|
||||
|
||||
Serves the admin "Reports" section in the sidebar:
|
||||
* Student Performance -> /api/reports/student-performance
|
||||
* Stats Corporate -> /api/reports/stats-corporate
|
||||
* Record -> /api/reports/record
|
||||
|
||||
All endpoints aggregate from ``encoach.student.attempt`` (exam attempts with
|
||||
per-skill band scores and CEFR level) and its surrounding records
|
||||
(``res.users`` for student names, ``encoach.entity`` for corporate context,
|
||||
``encoach.exam.custom`` for exam metadata).
|
||||
|
||||
A single attempt is "reportable" when it has a completed/scored/released
|
||||
status AND at least one non-null band. Everything else is ignored so the
|
||||
Reports pages never surface misleading zeros for in-progress work.
|
||||
"""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _paginate,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
||||
|
||||
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
|
||||
_CEFR_BY_BAND = [
|
||||
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
|
||||
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
|
||||
]
|
||||
|
||||
|
||||
def _cefr_for_band(band):
|
||||
if band is None:
|
||||
return None
|
||||
for threshold, label in _CEFR_BY_BAND:
|
||||
if band >= threshold:
|
||||
return label
|
||||
return 'Pre-A1'
|
||||
|
||||
|
||||
def _normalize_cefr(label):
|
||||
if not label:
|
||||
return None
|
||||
s = str(label).strip().lower().replace('-', '_')
|
||||
return {
|
||||
'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2',
|
||||
'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2',
|
||||
}.get(s, label)
|
||||
|
||||
|
||||
def _duration_minutes(att):
|
||||
start = att.started_at
|
||||
end = None
|
||||
if hasattr(att, 'completed_at'):
|
||||
end = att.completed_at
|
||||
if not end and hasattr(att, 'finished_at'):
|
||||
end = att.finished_at
|
||||
if not start or not end:
|
||||
return None
|
||||
return int(max(0, (end - start).total_seconds() // 60))
|
||||
|
||||
|
||||
def _attempt_completed_at(att):
|
||||
if hasattr(att, 'completed_at') and att.completed_at:
|
||||
return att.completed_at
|
||||
if hasattr(att, 'finished_at') and att.finished_at:
|
||||
return att.finished_at
|
||||
return None
|
||||
|
||||
|
||||
def _build_attempt_domain(kw, reportable=True):
|
||||
"""Common filter reader used by all three endpoints."""
|
||||
domain = []
|
||||
if reportable:
|
||||
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
|
||||
entity_id = kw.get('entity_id')
|
||||
if entity_id:
|
||||
try:
|
||||
domain.append(('entity_id', '=', int(entity_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
user_id = kw.get('user_id') or kw.get('student_id')
|
||||
if user_id:
|
||||
try:
|
||||
domain.append(('student_id', '=', int(user_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
since = kw.get('since')
|
||||
if since:
|
||||
try:
|
||||
domain.append(('started_at', '>=', since))
|
||||
except Exception:
|
||||
pass
|
||||
period = (kw.get('period') or '').lower()
|
||||
if period in ('day', 'week', 'month'):
|
||||
delta = {'day': 1, 'week': 7, 'month': 30}[period]
|
||||
cutoff = datetime.now() - timedelta(days=delta)
|
||||
domain.append(('started_at', '>=', cutoff))
|
||||
return domain
|
||||
|
||||
|
||||
class ReportsController(http.Controller):
|
||||
|
||||
# ── 1. Student Performance ───────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/student-performance', type='http',
|
||||
auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_performance(self, **kw):
|
||||
"""Return one row per student with averaged bands per skill.
|
||||
|
||||
Query params: ``entity_id``, ``level`` (CEFR code, case-insensitive),
|
||||
``search`` (matches student name/login), ``since`` (ISO date).
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
attempts = Att.search(domain, order='started_at desc')
|
||||
|
||||
per_student = defaultdict(lambda: {
|
||||
'attempts': [],
|
||||
'reading_sum': 0.0, 'reading_n': 0,
|
||||
'listening_sum': 0.0, 'listening_n': 0,
|
||||
'writing_sum': 0.0, 'writing_n': 0,
|
||||
'speaking_sum': 0.0, 'speaking_n': 0,
|
||||
'overall_sum': 0.0, 'overall_n': 0,
|
||||
'last_at': None, 'last_cefr': None,
|
||||
'entity_id': None, 'entity_name': None,
|
||||
})
|
||||
|
||||
def _accum(agg, key, value):
|
||||
if value is not None and value > 0:
|
||||
agg[f'{key}_sum'] += float(value)
|
||||
agg[f'{key}_n'] += 1
|
||||
|
||||
for att in attempts:
|
||||
if not att.student_id:
|
||||
continue
|
||||
agg = per_student[att.student_id.id]
|
||||
agg['attempts'].append(att.id)
|
||||
_accum(agg, 'reading', att.reading_band)
|
||||
_accum(agg, 'listening', att.listening_band)
|
||||
_accum(agg, 'writing', att.writing_band)
|
||||
_accum(agg, 'speaking', att.speaking_band)
|
||||
_accum(agg, 'overall', att.overall_band)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if ct and (agg['last_at'] is None or ct > agg['last_at']):
|
||||
agg['last_at'] = ct
|
||||
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if att.entity_id and not agg['entity_id']:
|
||||
agg['entity_id'] = att.entity_id.id
|
||||
agg['entity_name'] = att.entity_id.name
|
||||
|
||||
search_lc = (kw.get('search') or '').strip().lower()
|
||||
level_filter = (kw.get('level') or '').strip().upper().replace('-', '_')
|
||||
|
||||
rows = []
|
||||
for student_id, agg in per_student.items():
|
||||
user = request.env['res.users'].sudo().browse(student_id)
|
||||
if not user.exists():
|
||||
continue
|
||||
name = user.name or user.login or f'User #{student_id}'
|
||||
if search_lc and search_lc not in (name or '').lower() \
|
||||
and search_lc not in (user.login or '').lower():
|
||||
continue
|
||||
|
||||
def _avg(key):
|
||||
n = agg[f'{key}_n']
|
||||
if not n:
|
||||
return None
|
||||
return round(agg[f'{key}_sum'] / n, 2)
|
||||
|
||||
overall = _avg('overall')
|
||||
if overall is None:
|
||||
skill_avgs = [v for v in (
|
||||
_avg('reading'), _avg('listening'),
|
||||
_avg('writing'), _avg('speaking'),
|
||||
) if v is not None]
|
||||
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
|
||||
if skill_avgs else None
|
||||
|
||||
level = agg['last_cefr'] or _cefr_for_band(overall)
|
||||
if level_filter and (level or '').upper().replace('-', '_') \
|
||||
!= level_filter:
|
||||
continue
|
||||
|
||||
rows.append({
|
||||
'student_id': student_id,
|
||||
'student_name': name,
|
||||
'login': user.login or '',
|
||||
'entity_id': agg['entity_id'],
|
||||
'entity_name': agg['entity_name'] or '',
|
||||
'reading': _avg('reading'),
|
||||
'listening': _avg('listening'),
|
||||
'writing': _avg('writing'),
|
||||
'speaking': _avg('speaking'),
|
||||
'overall': overall,
|
||||
'level': level,
|
||||
'attempts_count': len(agg['attempts']),
|
||||
'last_attempt_at': agg['last_at'].isoformat()
|
||||
if agg['last_at'] else None,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: (-(r['overall'] or 0.0),
|
||||
r['student_name'].lower()))
|
||||
|
||||
return _json_response({
|
||||
'items': rows,
|
||||
'total': len(rows),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('student-performance report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── 2. Stats Corporate ───────────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/stats-corporate', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def stats_corporate(self, **kw):
|
||||
"""Corporate rollups: avg by module, monthly trend, level distribution,
|
||||
entity comparison.
|
||||
|
||||
Query params: ``entity_id``, ``threshold`` (0..100, minimum band*10 to
|
||||
include), ``months`` (trend horizon; default 6).
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
|
||||
try:
|
||||
threshold = float(kw.get('threshold') or 0)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.0
|
||||
if threshold > 0:
|
||||
domain.append(('overall_band', '>=', threshold / 10.0))
|
||||
|
||||
attempts = Att.search(domain)
|
||||
|
||||
skill_totals = {k: [0.0, 0] for k in
|
||||
('reading', 'listening', 'writing', 'speaking')}
|
||||
for att in attempts:
|
||||
for skill, field in (('reading', att.reading_band),
|
||||
('listening', att.listening_band),
|
||||
('writing', att.writing_band),
|
||||
('speaking', att.speaking_band)):
|
||||
if field is not None and field > 0:
|
||||
skill_totals[skill][0] += field
|
||||
skill_totals[skill][1] += 1
|
||||
|
||||
by_module = []
|
||||
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
|
||||
total, n = skill_totals[skill.lower()]
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
by_module.append({'module': skill, 'score': avg, 'n': n})
|
||||
|
||||
try:
|
||||
months = max(1, min(24, int(kw.get('months') or 6)))
|
||||
except (TypeError, ValueError):
|
||||
months = 6
|
||||
|
||||
month_buckets = defaultdict(lambda: [0.0, 0])
|
||||
for att in attempts:
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if not ct or att.overall_band is None:
|
||||
continue
|
||||
key = ct.strftime('%Y-%m')
|
||||
month_buckets[key][0] += att.overall_band
|
||||
month_buckets[key][1] += 1
|
||||
|
||||
trend = []
|
||||
today = datetime.now().replace(day=1)
|
||||
for i in range(months - 1, -1, -1):
|
||||
ref = (today - timedelta(days=31 * i)).replace(day=1)
|
||||
key = ref.strftime('%Y-%m')
|
||||
total, n = month_buckets.get(key, (0.0, 0))
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
trend.append({'month': ref.strftime('%b'), 'avg': avg,
|
||||
'period': key, 'n': n})
|
||||
|
||||
level_buckets = defaultdict(int)
|
||||
for att in attempts:
|
||||
label = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if label:
|
||||
level_buckets[label] += 1
|
||||
palette = {
|
||||
'Pre-A1': 'hsl(0, 0%, 55%)',
|
||||
'A1': 'hsl(0, 72%, 51%)',
|
||||
'A2': 'hsl(38, 92%, 50%)',
|
||||
'B1': 'hsl(199, 89%, 48%)',
|
||||
'B2': 'hsl(243, 75%, 59%)',
|
||||
'C1': 'hsl(142, 71%, 45%)',
|
||||
'C2': 'hsl(280, 65%, 50%)',
|
||||
}
|
||||
distribution = [
|
||||
{'name': lvl, 'value': level_buckets.get(lvl, 0),
|
||||
'color': palette[lvl]}
|
||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||
if level_buckets.get(lvl)
|
||||
] or [
|
||||
{'name': lvl, 'value': 0, 'color': palette[lvl]}
|
||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||
]
|
||||
|
||||
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
|
||||
for att in attempts:
|
||||
if att.overall_band is None:
|
||||
continue
|
||||
eid = att.entity_id.id or 0
|
||||
entity_buckets[eid][0] += att.overall_band
|
||||
entity_buckets[eid][1] += 1
|
||||
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
|
||||
comparison = []
|
||||
for eid, (total, n, name) in entity_buckets.items():
|
||||
comparison.append({
|
||||
'entity_id': eid or None,
|
||||
'entity_name': name,
|
||||
'avg': round((total / n) * 10, 1) if n else 0,
|
||||
'attempts': n,
|
||||
})
|
||||
comparison.sort(key=lambda r: -r['avg'])
|
||||
|
||||
return _json_response({
|
||||
'by_module': by_module,
|
||||
'trend': trend,
|
||||
'distribution': distribution,
|
||||
'comparison': comparison,
|
||||
'meta': {
|
||||
'attempts_considered': len(attempts),
|
||||
'threshold': threshold,
|
||||
'months': months,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('stats-corporate report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── 3. Record ────────────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/record', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def record(self, **kw):
|
||||
"""Per-user attempt history.
|
||||
|
||||
Query params: ``user_id`` (required for focused view; omit to see all),
|
||||
``entity_id``, ``period`` (day|week|month), ``status``, pagination.
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw, reportable=False)
|
||||
if kw.get('status'):
|
||||
domain.append(('status', '=', kw['status']))
|
||||
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Att.search_count(domain)
|
||||
attempts = Att.search(domain, offset=offset, limit=limit,
|
||||
order='started_at desc')
|
||||
|
||||
items = []
|
||||
for att in attempts:
|
||||
exam = att.exam_id
|
||||
exam_title = ''
|
||||
if exam:
|
||||
# encoach.exam.custom labels its record with `title`,
|
||||
# not `name`. Fall back to display_name if the field
|
||||
# is ever renamed upstream.
|
||||
exam_title = getattr(exam, 'title', None) \
|
||||
or getattr(exam, 'display_name', '') or ''
|
||||
assignment = request.env['encoach.exam.assignment'].sudo() \
|
||||
.search([('exam_id', '=', exam.id),
|
||||
('student_id', '=', att.student_id.id)],
|
||||
limit=1) if exam and att.student_id else None
|
||||
duration_min = _duration_minutes(att)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
items.append({
|
||||
'id': att.id,
|
||||
'student_id': att.student_id.id or None,
|
||||
'student_name': att.student_id.name if att.student_id else '',
|
||||
'assignment': exam_title,
|
||||
'assignment_id': assignment.id if assignment else None,
|
||||
'exam': exam_title,
|
||||
'exam_code': f'EX-{exam.id:03d}' if exam else '',
|
||||
'exam_id': exam.id or None,
|
||||
'entity_id': att.entity_id.id or None,
|
||||
'entity_name': att.entity_id.name if att.entity_id else '',
|
||||
'date': ct.strftime('%Y-%m-%d') if ct else None,
|
||||
'started_at': att.started_at.isoformat()
|
||||
if att.started_at else None,
|
||||
'completed_at': (_attempt_completed_at(att).isoformat()
|
||||
if _attempt_completed_at(att) else None),
|
||||
'score': att.overall_band if att.overall_band else None,
|
||||
'duration_min': duration_min,
|
||||
'duration': f'{duration_min} min' if duration_min is not None
|
||||
else '—',
|
||||
'status': att.status or 'in_progress',
|
||||
'status_label': (att.status or 'in_progress').replace('_', ' ').title(),
|
||||
'cefr_level': _normalize_cefr(att.cefr_level)
|
||||
or _cefr_for_band(att.overall_band),
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': limit,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('record report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Shared picker: entities and students for report filters ──────
|
||||
|
||||
@http.route('/api/reports/filters', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def filters(self, **kw):
|
||||
"""Lightweight list of entities + students/users we have attempts for.
|
||||
Used by the filter dropdowns on all three Reports pages."""
|
||||
try:
|
||||
Ent = request.env['encoach.entity'].sudo()
|
||||
entities = [{'id': e.id, 'name': e.name or ''}
|
||||
for e in Ent.search([], order='name')]
|
||||
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
att_students = Att.search([]).mapped('student_id')
|
||||
students = [{'id': u.id, 'name': u.name or u.login or '',
|
||||
'login': u.login or ''}
|
||||
for u in att_students.sorted('name')
|
||||
if u and u.id > 0]
|
||||
|
||||
return _json_response({
|
||||
'entities': entities,
|
||||
'students': students,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('reports filters failed')
|
||||
return _error_response(str(e), 500)
|
||||
@@ -5,6 +5,7 @@
|
||||
> This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow.
|
||||
|
||||
> **Latest events:**
|
||||
> - **2026-04-19 (reports section):** Built the Reports section end-to-end — the three pages `/admin/student-performance`, `/admin/stats-corporate`, `/admin/record` (previously pure hardcoded-array mocks) are now wired to real aggregated data from `encoach.student.attempt`. New `/api/reports/{student-performance,stats-corporate,record,filters}` controller (`encoach_lms_api/controllers/reports.py`) does the rollups: per-student band averages + CEFR, per-module corporate charts, trend / distribution / entity comparison, and per-user attempt history with search / level / entity / period filters and CSV export. New `seed_reports.py` completes in-progress attempts and backfills six months of historical attempts so the trend chart and KPI cards are meaningful. 25/25 API smoke passing (`test_reports_flows.py`), 24/24 Configuration + 29/29 Support + 26/26 Training regressions still green, all three pages verified live in-browser with 28 real attempts showing across 4 tabs. See §20.
|
||||
> - **2026-04-19 (remote rename):** Aligned local remote names with the new doctrine — `backend-v4 → origin-backend`, `frontend-v4 → origin-frontend`, `origin → mirror-monorepo`. The `v4` branch now tracks `mirror-monorepo/v4`. All publishing commands in §6 updated. No remote URLs changed.
|
||||
> - **2026-04-19 (repos-of-record reorganization):** Declared `encoach_backend_v4` and `encoach_frontend_v4` as the canonical origins for their respective halves; the monorepo `encoach_backend_new_v2/v4` is now a secondary working-tree mirror kept only for history/convenience. §6 rewritten around this model.
|
||||
> - **2026-04-19 (release to VPS repos):** Pushed the accumulated institutional + support + training work. Frontend canonical → `encoach_frontend_v4/main` (`b78124bb..435930a8`, 403 files, clean fast-forward). Backend canonical → `encoach_backend_v4/main` (`74d83af5..6ec68160`, 420 files, clean fast-forward). Monorepo mirror → `mirror-monorepo/v4` (`98b9837a`). `.gitignore` now excludes `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/` so the 674 MB of local pgdata backups never leak into a push. See §6.
|
||||
@@ -1215,3 +1216,106 @@ No functional errors in the console — only React Router v7 future-flag warning
|
||||
- Progress rows are keyed to `res.users.id`, not `op.student`. Any authenticated API user (admin, teacher, student) sees only their own completions in the `summary` block, while `learners_count` / `completion_count` on the library item itself aggregate across all users.
|
||||
- The `/api/training/<kind>/<id>/progress` endpoint is **upsert**-like: the first call creates the progress row, subsequent calls update it and bump `review_count`. This lets the frontend treat it as "toggle" without worrying about a missing row.
|
||||
|
||||
## 20. Reports Section (2026-04-19)
|
||||
|
||||
Before this pass, the three pages under the sidebar "Reports" group were **pure mock UIs** — hardcoded JavaScript arrays in each component, non-functional filters, no network requests. They now aggregate real data from the attempt pipeline.
|
||||
|
||||
### 20.1 Scope
|
||||
|
||||
| Page | URL | Before | After |
|
||||
|------|-----|--------|-------|
|
||||
| Student Performance | `/admin/student-performance` | 6 hardcoded students ("Sarah Johnson", "Ahmed Hassan"…) | Per-student band averages from `encoach.student.attempt`, CEFR level derived from the latest reportable attempt, entity + level + search filters, CSV export, three KPI cards |
|
||||
| Stats Corporate | `/admin/stats-corporate` | Hardcoded bar/line/pie data | Four tabs (Overview, Trends, Distribution, Comparison) driven by a single `/api/reports/stats-corporate` rollup — bar chart of module averages (× 10), six-month trend, CEFR distribution pie, and entity comparison bar. Threshold + entity + months filters all hit the backend. |
|
||||
| Record | `/admin/record` | Four hardcoded rows | Paginated per-user attempt history with search / entity / user / period filters, real exam codes (`EX-###`), duration derived from `started_at` → `completed_at`, CSV export |
|
||||
|
||||
All three pages share a single `/api/reports/filters` endpoint that returns the entities and students that actually have attempts, so dropdowns never offer filters that would produce empty results.
|
||||
|
||||
### 20.2 Backend artifacts
|
||||
|
||||
**New controller** — `backend/custom_addons/encoach_lms_api/controllers/reports.py`:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/api/reports/student-performance` | GET | One row per student. Aggregates per-skill bands across all their reportable attempts, takes the CEFR level from the most recent attempt (or derives it from overall band). Filters: `entity_id`, `level`, `search`, `since`. |
|
||||
| `/api/reports/stats-corporate` | GET | Corporate rollups: `by_module` (4 module averages × 10 for the bar chart), `trend` (last N months, default 6), `distribution` (CEFR bins with palette colors), `comparison` (per-entity average × 10). Filters: `entity_id`, `threshold` (0-100), `months`, `since`. |
|
||||
| `/api/reports/record` | GET | Paginated attempt history. Same shape as `{items, total, page, size}`. Filters: `user_id`, `entity_id`, `period=day\|week\|month`, `status`, `page`, `size`. Emits a normalized `status_label` and an `EX-###` exam code. |
|
||||
| `/api/reports/filters` | GET | Lightweight picker payload: `{entities: [...], students: [...]}` — only students that have at least one attempt, only entities that exist. |
|
||||
|
||||
An attempt is considered **reportable** if `status ∈ {completed, scoring, scored, released}`. In-progress attempts are skipped from aggregates so a student with only a half-finished exam doesn't show up with zeros.
|
||||
|
||||
CEFR labels are normalized to canonical display casing (`Pre-A1 / A1 / … / C2`) via `_normalize_cefr`, and falls back to band-based bucketing via `_cefr_for_band` when `cefr_level` is null on the attempt.
|
||||
|
||||
No new models were needed — the Reports section only reads from:
|
||||
- `encoach.student.attempt` (overall / per-skill bands, cefr_level, entity_id, started/completed timestamps)
|
||||
- `encoach.exam.custom` (exam title — note: this model uses `title`, not `name`, which tripped the first version of the controller)
|
||||
- `encoach.exam.assignment` (optional, to link an attempt back to its assignment when one exists)
|
||||
- `encoach.entity`, `res.users`
|
||||
|
||||
### 20.3 Frontend artifacts
|
||||
|
||||
**New service** — `frontend/src/services/reports.service.ts`:
|
||||
|
||||
```typescript
|
||||
reportsService.studentPerformance({ entity_id, level, search, since })
|
||||
reportsService.statsCorporate({ entity_id, threshold, months, since })
|
||||
reportsService.record({ user_id, entity_id, period, status, page, size })
|
||||
reportsService.filters()
|
||||
```
|
||||
|
||||
Typed interfaces: `StudentPerformanceRow`, `StatsCorporateResponse` (with nested `StatsModuleRow` / `StatsTrendRow` / `StatsDistributionRow` / `StatsComparisonRow`), `RecordRow`, `ReportsFiltersResponse`.
|
||||
|
||||
**Rewritten pages:**
|
||||
- `frontend/src/pages/StudentPerformancePage.tsx` — dropped all hardcoded arrays; added three KPI cards (students tracked, avg overall, top performer level), a search input, Entity select populated from `/api/reports/filters`, Level select (`all / A1 / A2 / B1 / B2 / C1 / C2`), CSV export, loading + empty states. Per-row AI Grade Explainer retained.
|
||||
- `frontend/src/pages/StatsCorporatePage.tsx` — dropped all hardcoded chart data; wired every tab to the `stats-corporate` payload. Added loading + empty states. Threshold buttons (0/50/70/90%) now hit the backend. Comparison tab now renders a real entity bar chart instead of a placeholder panel. AI narrative bubbles receive live data.
|
||||
- `frontend/src/pages/RecordPage.tsx` — dropped all hardcoded records; wired filters (entity, user, period) + CSV export. Status badge variant adapts to `completed / released / scored / in_progress / scoring`.
|
||||
|
||||
### 20.4 Seeding — `seed_reports.py` (idempotent)
|
||||
|
||||
The live database had only 12 attempts, most of them in-progress with no band scores, so the Reports pages would have shown empty charts. The new script:
|
||||
|
||||
1. Ensures three entities exist: `Acme Corp (ACME)`, `Global Ltd (GLOBAL)`, `Tech Co (TECHCO)` — using `code` (required NOT NULL on `encoach.entity`) as the idempotency key.
|
||||
2. Walks every existing `in_progress` attempt and assigns it a plausible score matrix from `BAND_MATRIX`, marks it `completed`, stamps `completed_at = started_at + 120 min`, and parks it in one of the three entities if it had none.
|
||||
3. Creates up to `6 months × 4 students = 24` additional historical attempts (with a random 30% skip to keep the trend line jagged), using a small monthly `delta` so the trend chart rises over time. Each new row is guarded by a 6-day proximity lookup so re-running only fills gaps.
|
||||
4. Commits once at the end and prints the final reportable-attempt count.
|
||||
|
||||
Result after the first run: **28 reportable attempts** spread across 3 entities, 4 students, and 6 months — enough to populate every chart.
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo
|
||||
../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf --no-http --stop-after-init < ../seed_reports.py
|
||||
```
|
||||
|
||||
### 20.5 Smoke test — 25/25 PASS
|
||||
|
||||
`test_reports_flows.py` exercises all four endpoints:
|
||||
|
||||
| Endpoint | Checks |
|
||||
|----------|--------|
|
||||
| `/api/reports/filters` | GET returns `{entities: [...], students: [...]}` with at least one of each |
|
||||
| `/api/reports/student-performance` | LIST returns rows, row shape complete, `overall` numeric, CEFR level is a known label, `search` filter narrows to the matching student, `level` filter returns only that level, `entity_id` filter narrows rows |
|
||||
| `/api/reports/stats-corporate` | Envelope has `by_module` + `trend` + `distribution` + `comparison` + `meta`; by_module has the 4 IELTS modules; trend length == `months` (default 6, also tested with 3); distribution has per-level color; `threshold=70` drops attempts considered (28 → 14); entity filter narrows comparison; months=3 shortens trend |
|
||||
| `/api/reports/record` | Paginated list, row shape complete, `exam_code` is `EX-###`, `status_label` is titlecased, `user_id` filter scopes to that student only, `entity_id` filter scopes to that entity, `period=month` returns the in-window subset, pagination `size=5 page=1` + `page=2` both work |
|
||||
|
||||
Latest output: `Summary: 25 passed, 0 failed, 25 total`.
|
||||
|
||||
Regressions also re-run on this pass: Configuration `24/24`, Support `29/29`, Training `26/26` — all still green.
|
||||
|
||||
### 20.6 Browser verification
|
||||
|
||||
Logged in as `admin / admin` on `localhost:8080` and walked all three pages:
|
||||
|
||||
- **Student Performance** — KPI cards populated (5 students tracked, 6.3 average band), table shows real students (Sarah Ahmed, Omar Khan, Layla Nasser, TestUser WriteFlow, Admin User), Level `A2` filter narrowed to 2 rows, Entity dropdown showed real entities, CSV export triggered a download. No console errors.
|
||||
- **Stats Corporate** — Heading confirmed `(28 scored attempts)`. Overview bar chart shows 4 modules with values ~64–72. Trends line chart has 6 monthly points Oct–Apr peaking at ~85. Distribution pie: C1 11 / B2 6 / B1 5 / A2 4. Comparison bar: Global Ltd ~81, Acme Corp ~63, Tech Co ~62. AI Summary bubbles quote the real numbers. All filters responsive.
|
||||
- **Record** — Heading `(28 attempts)`, table shows real student names / exam codes (EX-001, EX-005, EX-017…) / dates / scores / statuses. User filter scoped the list to that student; CSV export worked.
|
||||
|
||||
Network tab showed `/api/reports/filters 200`, `/api/reports/record?size=100 200`, `/api/reports/student-performance 200`, `/api/reports/stats-corporate 200` — zero 4xx/5xx.
|
||||
|
||||
### 20.7 Gotchas fixed during this pass
|
||||
|
||||
- **`encoach.exam.custom` uses `title`, not `name`.** The first version of the Record endpoint threw `AttributeError: 'encoach.exam.custom' object has no attribute 'name'`. The controller now uses `getattr(exam, 'title', None) or getattr(exam, 'display_name', '')` so it survives an upstream rename.
|
||||
- **`encoach.entity` has a `NOT NULL` `code` column.** `seed_reports.py` originally tried to create entities with just `{'name': ...}` and hit a `NotNullViolation`. The seeder now passes `(name, code)` and also looks up by `code` when probing for existing rows.
|
||||
- **Both `encoach_exam_template` and `encoach_scoring` declare `encoach.student.attempt`.** The Reports controller only reads fields that exist on both definitions (`listening/reading/writing/speaking/overall_band`, `cefr_level`, `status`, `started_at`, and whichever of `completed_at` / `finished_at` is present — `_attempt_completed_at()` probes both). No field access assumes a specific addon version.
|
||||
- **In-progress attempts are excluded from the aggregates.** If you ever notice a student you expect to see missing from Student Performance or Stats Corporate, check their attempt status — `in_progress` attempts are deliberately skipped. They DO still appear in the Record page (which shows everything) unless you also pass `status` or `period` filters.
|
||||
|
||||
|
||||
@@ -1,66 +1,212 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Download } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
import { reportsService, RecordRow } from "@/services/reports.service";
|
||||
|
||||
const records = [
|
||||
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
|
||||
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
|
||||
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
|
||||
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
|
||||
];
|
||||
type Period = "all" | "day" | "week" | "month";
|
||||
|
||||
export default function RecordPage() {
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
const [userId, setUserId] = useState("all");
|
||||
const [period, setPeriod] = useState<Period>("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["record", entityId, userId, period],
|
||||
queryFn: () =>
|
||||
reportsService.record({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
user_id: userId === "all" ? undefined : Number(userId),
|
||||
period: period === "all" ? undefined : period,
|
||||
size: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
const records: RecordRow[] = data?.items ?? [];
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
"Student",
|
||||
"Assignment",
|
||||
"Exam",
|
||||
"Date",
|
||||
"Score",
|
||||
"Duration",
|
||||
"Status",
|
||||
];
|
||||
const lines = records.map((r) =>
|
||||
[
|
||||
r.student_name,
|
||||
r.assignment,
|
||||
r.exam_code || r.exam,
|
||||
r.date ?? "",
|
||||
r.score ?? "",
|
||||
r.duration,
|
||||
r.status_label,
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
if (status === "completed" || status === "released" || status === "scored")
|
||||
return "default";
|
||||
if (status === "in_progress" || status === "scoring") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse assignment and exam attempt history.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">({data.total} attempts)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={exportCsv}
|
||||
disabled={!records.length}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="record" variant="insight" />
|
||||
|
||||
<AiReportNarrative report_type="record" data={{ records }} />
|
||||
{records.length > 0 && (
|
||||
<AiReportNarrative report_type="record" data={{ records }} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
|
||||
<Select value={userId} onValueChange={setUserId}>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="All Users" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
{filters?.students.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1">
|
||||
{["Day", "Week", "Month"].map(t => (
|
||||
<Button key={t} variant="outline" size="sm">{t}</Button>
|
||||
{(["all", "day", "week", "month"] as Period[]).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={period === p ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.assignment}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||
attempts...
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No attempts match these filters.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Exam</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">
|
||||
{r.student_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.assignment || "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.exam_code}
|
||||
</TableCell>
|
||||
<TableCell>{r.date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{r.score ? r.score.toFixed(1) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(r.status)}>
|
||||
{r.status_label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,126 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
|
||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||
|
||||
const barData = [
|
||||
{ module: "Reading", score: 72 },
|
||||
{ module: "Listening", score: 68 },
|
||||
{ module: "Writing", score: 61 },
|
||||
{ module: "Speaking", score: 65 },
|
||||
];
|
||||
|
||||
const trendData = [
|
||||
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
|
||||
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
|
||||
];
|
||||
|
||||
const distData = [
|
||||
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
|
||||
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
|
||||
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
|
||||
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
|
||||
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
|
||||
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
|
||||
];
|
||||
|
||||
export default function StatsCorporatePage() {
|
||||
const [threshold, setThreshold] = useState("0%");
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stats-corporate", threshold, entityId],
|
||||
queryFn: () =>
|
||||
reportsService.statsCorporate({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
threshold: Number(threshold.replace("%", "")),
|
||||
months: 6,
|
||||
}),
|
||||
});
|
||||
|
||||
const barData = data?.by_module ?? [];
|
||||
const trendData = data?.trend ?? [];
|
||||
const distData = data?.distribution ?? [];
|
||||
const comparison = data?.comparison ?? [];
|
||||
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
|
||||
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Corporate Statistics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Entity-level performance analytics and reports.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">
|
||||
({attemptsConsidered} scored attempts)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex gap-1">
|
||||
{thresholds.map(t => (
|
||||
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
|
||||
{thresholds.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={threshold === t ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setThreshold(t)}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
|
||||
attempts...
|
||||
</div>
|
||||
) : attemptsConsidered === 0 ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
No scored attempts match this threshold / entity combination yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-overview"
|
||||
data={{ modules: barData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Average Score by Module
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="score"
|
||||
fill="hsl(243, 75%, 59%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-trends"
|
||||
data={{ trends: trendData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Score Trend Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="avg"
|
||||
stroke="hsl(243, 75%, 59%)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
||||
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-distribution"
|
||||
data={{ distribution: distData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Level Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={distData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label
|
||||
>
|
||||
{distData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-comparison"
|
||||
data={{ threshold, entities: comparison }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Entity Comparison (average band × 10)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{comparison.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground p-8">
|
||||
No entities have attempts above this threshold.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={comparison}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="entity_name" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="avg"
|
||||
fill="hsl(142, 71%, 45%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,270 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Download } from "lucide-react";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
|
||||
const students = [
|
||||
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
|
||||
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
|
||||
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
|
||||
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
|
||||
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
|
||||
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
|
||||
];
|
||||
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
|
||||
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
|
||||
function ScoreBadge({ score }: { score: number | null }) {
|
||||
if (score === null || score === undefined) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
const color =
|
||||
score >= 7.5
|
||||
? "bg-success/10 text-success"
|
||||
: score >= 6.0
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-destructive/10 text-destructive";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudentPerformancePage() {
|
||||
const [showUtilisation, setShowUtilisation] = useState(false);
|
||||
const [entityId, setEntityId] = useState<string>("all");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["student-performance", entityId, level, search],
|
||||
queryFn: () =>
|
||||
reportsService.studentPerformance({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
level: level === "all" ? undefined : level,
|
||||
search: search || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!rows.length) return null;
|
||||
const overall = rows
|
||||
.map((r) => r.overall)
|
||||
.filter((v): v is number => v !== null);
|
||||
const avg = overall.length
|
||||
? overall.reduce((a, b) => a + b, 0) / overall.length
|
||||
: null;
|
||||
return {
|
||||
total: rows.length,
|
||||
avg: avg !== null ? avg.toFixed(1) : "—",
|
||||
topLevel: rows[0]?.level ?? "—",
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
"Student",
|
||||
"Entity",
|
||||
"Level",
|
||||
"Reading",
|
||||
"Listening",
|
||||
"Writing",
|
||||
"Speaking",
|
||||
"Overall",
|
||||
"Attempts",
|
||||
];
|
||||
const lines = rows.map((r) =>
|
||||
[
|
||||
r.student_name,
|
||||
r.entity_name,
|
||||
r.level ?? "",
|
||||
r.reading ?? "",
|
||||
r.listening ?? "",
|
||||
r.writing ?? "",
|
||||
r.speaking ?? "",
|
||||
r.overall ?? "",
|
||||
r.attempts_count,
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
|
||||
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Student Performance
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track student scores across all IELTS modules.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Students tracked</p>
|
||||
<p className="text-2xl font-bold">{summary.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Average overall band</p>
|
||||
<p className="text-2xl font-bold">{summary.avg}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Top performer level</p>
|
||||
<p className="text-2xl font-bold">{summary.topLevel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AiStudyCoach />
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<Input
|
||||
placeholder="Search student..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-[220px]"
|
||||
/>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={level}
|
||||
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEVELS.map((l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l === "all" ? "All Levels" : l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
|
||||
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
|
||||
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
|
||||
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
|
||||
<TableHead className="text-center">Overall</TableHead>
|
||||
<TableHead className="w-10">AI</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s) => (
|
||||
<TableRow key={s.name}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.entity}</TableCell>
|
||||
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
|
||||
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||
performance data...
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No completed attempts match these filters yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead className="text-center">Reading</TableHead>
|
||||
<TableHead className="text-center">Listening</TableHead>
|
||||
<TableHead className="text-center">Writing</TableHead>
|
||||
<TableHead className="text-center">Speaking</TableHead>
|
||||
<TableHead className="text-center">Overall</TableHead>
|
||||
<TableHead className="text-center">Attempts</TableHead>
|
||||
<TableHead className="w-10">AI</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.student_id}>
|
||||
<TableCell className="font-medium">
|
||||
{r.student_name}
|
||||
</TableCell>
|
||||
<TableCell>{r.entity_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{r.level ? (
|
||||
<Badge variant="outline">{r.level}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.reading} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.listening} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.writing} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.speaking} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.overall} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{r.attempts_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AiGradeExplainer studentName={r.student_name} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
137
frontend/src/services/reports.service.ts
Normal file
137
frontend/src/services/reports.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export interface StudentPerformanceRow {
|
||||
student_id: number;
|
||||
student_name: string;
|
||||
login: string;
|
||||
entity_id: number | null;
|
||||
entity_name: string;
|
||||
reading: number | null;
|
||||
listening: number | null;
|
||||
writing: number | null;
|
||||
speaking: number | null;
|
||||
overall: number | null;
|
||||
level: string | null;
|
||||
attempts_count: number;
|
||||
last_attempt_at: string | null;
|
||||
}
|
||||
|
||||
export interface StudentPerformanceResponse {
|
||||
items: StudentPerformanceRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StatsModuleRow {
|
||||
module: string;
|
||||
score: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface StatsTrendRow {
|
||||
month: string;
|
||||
avg: number;
|
||||
period: string;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface StatsDistributionRow {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface StatsComparisonRow {
|
||||
entity_id: number | null;
|
||||
entity_name: string;
|
||||
avg: number;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface StatsCorporateResponse {
|
||||
by_module: StatsModuleRow[];
|
||||
trend: StatsTrendRow[];
|
||||
distribution: StatsDistributionRow[];
|
||||
comparison: StatsComparisonRow[];
|
||||
meta: {
|
||||
attempts_considered: number;
|
||||
threshold: number;
|
||||
months: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordRow {
|
||||
id: number;
|
||||
student_id: number | null;
|
||||
student_name: string;
|
||||
assignment: string;
|
||||
assignment_id: number | null;
|
||||
exam: string;
|
||||
exam_code: string;
|
||||
exam_id: number | null;
|
||||
entity_id: number | null;
|
||||
entity_name: string;
|
||||
date: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
score: number | null;
|
||||
duration_min: number | null;
|
||||
duration: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
cefr_level: string | null;
|
||||
}
|
||||
|
||||
export interface RecordResponse {
|
||||
items: RecordRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ReportsFiltersResponse {
|
||||
entities: { id: number; name: string }[];
|
||||
students: { id: number; name: string; login: string }[];
|
||||
}
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export const reportsService = {
|
||||
async studentPerformance(params?: {
|
||||
entity_id?: number;
|
||||
level?: string;
|
||||
search?: string;
|
||||
since?: string;
|
||||
}): Promise<StudentPerformanceResponse> {
|
||||
return api.get<StudentPerformanceResponse>(
|
||||
"/reports/student-performance",
|
||||
params as QueryParams,
|
||||
);
|
||||
},
|
||||
|
||||
async statsCorporate(params?: {
|
||||
entity_id?: number;
|
||||
threshold?: number;
|
||||
months?: number;
|
||||
since?: string;
|
||||
}): Promise<StatsCorporateResponse> {
|
||||
return api.get<StatsCorporateResponse>(
|
||||
"/reports/stats-corporate",
|
||||
params as QueryParams,
|
||||
);
|
||||
},
|
||||
|
||||
async record(params?: {
|
||||
user_id?: number;
|
||||
entity_id?: number;
|
||||
period?: "day" | "week" | "month";
|
||||
status?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<RecordResponse> {
|
||||
return api.get<RecordResponse>("/reports/record", params as QueryParams);
|
||||
},
|
||||
|
||||
async filters(): Promise<ReportsFiltersResponse> {
|
||||
return api.get<ReportsFiltersResponse>("/reports/filters");
|
||||
},
|
||||
};
|
||||
167
seed_reports.py
Normal file
167
seed_reports.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed demo data for the admin "Reports" section.
|
||||
|
||||
Creates / completes a spread of ``encoach.student.attempt`` records across two
|
||||
entities so that:
|
||||
|
||||
* /admin/student-performance shows multiple rows with different CEFR levels
|
||||
* /admin/stats-corporate has attempts in multiple months + entities
|
||||
* /admin/record shows per-user attempt history
|
||||
|
||||
Idempotent: re-running only fills in missing attempts — it never wipes data.
|
||||
|
||||
Usage:
|
||||
BACKEND_DIR=$PWD/backend \\
|
||||
/path/to/python backend/odoo/odoo-bin shell \\
|
||||
-c backend/custom_addons/encoach_demo.conf \\
|
||||
--no-http --stop-after-init < seed_reports.py
|
||||
|
||||
In practice this repo has a helper ``run.sh seed seed_reports.py``.
|
||||
"""
|
||||
# pylint: disable=undefined-variable
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
env # noqa: F821 - injected by odoo-bin shell
|
||||
except NameError:
|
||||
raise SystemExit('Run this inside odoo-bin shell (env not available).')
|
||||
|
||||
|
||||
def _or_create_entity(name, code):
|
||||
rec = env['encoach.entity'].search([('name', '=', name)], limit=1)
|
||||
if rec:
|
||||
return rec
|
||||
rec = env['encoach.entity'].search([('code', '=', code)], limit=1)
|
||||
if rec:
|
||||
return rec
|
||||
return env['encoach.entity'].create({'name': name, 'code': code})
|
||||
|
||||
|
||||
def _students():
|
||||
Users = env['res.users'].sudo()
|
||||
logins = ['sarah@encoach.test', 'omar@encoach.test',
|
||||
'layla@encoach.test', 'testuser@writeflow.test']
|
||||
out = []
|
||||
for login in logins:
|
||||
u = Users.search([('login', '=', login)], limit=1)
|
||||
if u:
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _default_exam():
|
||||
exam = env['encoach.exam.custom'].sudo().search([], order='id', limit=1)
|
||||
if exam:
|
||||
return exam
|
||||
# Fallback: create a minimal exam shell if none exists.
|
||||
return env['encoach.exam.custom'].sudo().create({
|
||||
'title': 'Seeded Demo Exam',
|
||||
'status': 'active',
|
||||
})
|
||||
|
||||
|
||||
BAND_MATRIX = [
|
||||
# listening, reading, writing, speaking -> overall auto (avg)
|
||||
(7.5, 8.0, 7.0, 7.5, 'C1'),
|
||||
(6.0, 6.5, 5.5, 6.0, 'B2'),
|
||||
(5.0, 5.5, 4.5, 5.0, 'B1'),
|
||||
(4.0, 4.5, 4.0, 4.0, 'A2'),
|
||||
(8.0, 8.5, 8.0, 8.5, 'C1'),
|
||||
(6.5, 7.0, 6.5, 6.5, 'B2'),
|
||||
]
|
||||
|
||||
|
||||
def _avg(l, r, w, s):
|
||||
return round((l + r + w + s) / 4.0, 1)
|
||||
|
||||
|
||||
def seed():
|
||||
acme = _or_create_entity('Acme Corp', 'ACME')
|
||||
globe = _or_create_entity('Global Ltd', 'GLOBAL')
|
||||
tech = _or_create_entity('Tech Co', 'TECHCO')
|
||||
entities = [acme, globe, tech]
|
||||
|
||||
students = _students()
|
||||
if not students:
|
||||
print('[seed_reports] No students found — nothing to seed.')
|
||||
return
|
||||
|
||||
exam = _default_exam()
|
||||
print(f'[seed_reports] Using exam id={exam.id} title={exam.title!r}')
|
||||
Att = env['encoach.student.attempt'].sudo()
|
||||
|
||||
# 1. Fill in bands for any existing in_progress attempts that have no scores.
|
||||
stale = Att.search([('status', '=', 'in_progress')])
|
||||
for idx, att in enumerate(stale):
|
||||
vals = BAND_MATRIX[idx % len(BAND_MATRIX)]
|
||||
l, r, w, s, cefr = vals
|
||||
started = att.started_at or (datetime.now() - timedelta(days=5))
|
||||
att.write({
|
||||
'status': 'completed',
|
||||
'listening_band': l,
|
||||
'reading_band': r,
|
||||
'writing_band': w,
|
||||
'speaking_band': s,
|
||||
'overall_band': _avg(l, r, w, s),
|
||||
'cefr_level': cefr.lower().replace('-', '_'),
|
||||
'completed_at': started + timedelta(minutes=120),
|
||||
'entity_id': att.entity_id.id or random.choice(entities).id,
|
||||
})
|
||||
print(f'[seed_reports] Completed {len(stale)} existing in_progress attempts.')
|
||||
|
||||
# 2. Add historical attempts across the last 6 months so the trend chart
|
||||
# has enough data points.
|
||||
now = datetime.now()
|
||||
created = 0
|
||||
for month_ago in range(5, -1, -1):
|
||||
ref = now - timedelta(days=30 * month_ago + random.randint(0, 15))
|
||||
for i, student in enumerate(students):
|
||||
# Skip ~30% of (student, month) pairs so the trend varies.
|
||||
if random.random() < 0.3 and month_ago > 0:
|
||||
continue
|
||||
# Slight progression over time so trend line rises.
|
||||
base = BAND_MATRIX[(i + month_ago) % len(BAND_MATRIX)]
|
||||
l, r, w, s, cefr = base
|
||||
delta = max(0.0, (6 - month_ago) * 0.2)
|
||||
l = min(9.0, l + delta)
|
||||
r = min(9.0, r + delta)
|
||||
w = min(9.0, w + delta)
|
||||
s = min(9.0, s + delta)
|
||||
entity = entities[(i + month_ago) % len(entities)]
|
||||
|
||||
existing = Att.search([
|
||||
('student_id', '=', student.id),
|
||||
('exam_id', '=', exam.id),
|
||||
('started_at', '>=', ref - timedelta(days=3)),
|
||||
('started_at', '<=', ref + timedelta(days=3)),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'student_id': student.id,
|
||||
'exam_id': exam.id,
|
||||
'entity_id': entity.id,
|
||||
'status': 'completed',
|
||||
'started_at': ref,
|
||||
'completed_at': ref + timedelta(minutes=100 + i * 15),
|
||||
'listening_band': l,
|
||||
'reading_band': r,
|
||||
'writing_band': w,
|
||||
'speaking_band': s,
|
||||
'overall_band': _avg(l, r, w, s),
|
||||
'cefr_level': cefr.lower().replace('-', '_'),
|
||||
}
|
||||
Att.create(vals)
|
||||
created += 1
|
||||
print(f'[seed_reports] Created {created} historical attempts.')
|
||||
|
||||
env.cr.commit()
|
||||
total_reportable = Att.search_count([('status', 'in',
|
||||
['completed', 'scored',
|
||||
'released', 'scoring'])])
|
||||
print(f'[seed_reports] OK. Reportable attempts now: {total_reportable}')
|
||||
|
||||
|
||||
seed()
|
||||
276
test_reports_flows.py
Normal file
276
test_reports_flows.py
Normal 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)
|
||||
Reference in New Issue
Block a user