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 payments
|
||||||
from . import platform_settings
|
from . import platform_settings
|
||||||
from . import training
|
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.
|
> 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:**
|
> **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 (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 (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.
|
> - **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.
|
- 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.
|
- 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, Download } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { reportsService, RecordRow } from "@/services/reports.service";
|
||||||
|
|
||||||
const records = [
|
type Period = "all" | "day" | "week" | "month";
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function RecordPage() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="record" variant="insight" />
|
<AiTipBanner context="record" variant="insight" />
|
||||||
|
|
||||||
|
{records.length > 0 && (
|
||||||
<AiReportNarrative report_type="record" data={{ records }} />
|
<AiReportNarrative report_type="record" data={{ records }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
<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>
|
||||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
|
<Select value={userId} onValueChange={setUserId}>
|
||||||
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
|
<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>
|
</Select>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{["Day", "Week", "Month"].map(t => (
|
{(["all", "day", "week", "month"] as Period[]).map((p) => (
|
||||||
<Button key={t} variant="outline" size="sm">{t}</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
{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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
|
<TableHead>Assignment</TableHead>
|
||||||
|
<TableHead>Exam</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Duration</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{records.map((r) => (
|
{records.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow key={r.id}>
|
||||||
<TableCell className="font-medium">{r.assignment}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
|
{r.student_name || "—"}
|
||||||
<TableCell>{r.date}</TableCell>
|
</TableCell>
|
||||||
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</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>{r.duration}</TableCell>
|
||||||
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(r.status)}>
|
||||||
|
{r.status_label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +1,121 @@
|
|||||||
import { useState } from "react";
|
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 { 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 { 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 AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { reportsService } from "@/services/reports.service";
|
||||||
|
|
||||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
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() {
|
export default function StatsCorporatePage() {
|
||||||
const [threshold, setThreshold] = useState("0%");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
|
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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{thresholds.map(t => (
|
{thresholds.map((t) => (
|
||||||
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
|
<Button
|
||||||
|
key={t}
|
||||||
|
variant={threshold === t ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setThreshold(t)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
|
<SelectTrigger className="w-[180px]">
|
||||||
</Select>
|
<SelectValue placeholder="All Entities" />
|
||||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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">
|
<Tabs defaultValue="overview">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
@@ -62,17 +125,31 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-4">
|
||||||
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
<AiReportNarrative
|
||||||
|
report_type="corporate-overview"
|
||||||
|
data={{ modules: barData }}
|
||||||
|
/>
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Average Score by Module
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={barData}>
|
<BarChart data={barData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(220, 16%, 90%)"
|
||||||
|
/>
|
||||||
<XAxis dataKey="module" />
|
<XAxis dataKey="module" />
|
||||||
<YAxis domain={[0, 100]} />
|
<YAxis domain={[0, 100]} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
|
<Bar
|
||||||
|
dataKey="score"
|
||||||
|
fill="hsl(243, 75%, 59%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -80,17 +157,33 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="trends" className="mt-4">
|
<TabsContent value="trends" className="mt-4">
|
||||||
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
<AiReportNarrative
|
||||||
|
report_type="corporate-trends"
|
||||||
|
data={{ trends: trendData }}
|
||||||
|
/>
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Score Trend Over Time
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={trendData}>
|
<LineChart data={trendData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(220, 16%, 90%)"
|
||||||
|
/>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
<YAxis domain={[0, 100]} />
|
<YAxis domain={[0, 100]} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="hsl(243, 75%, 59%)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -98,14 +191,29 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="distribution" className="mt-4">
|
<TabsContent value="distribution" className="mt-4">
|
||||||
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
<AiReportNarrative
|
||||||
|
report_type="corporate-distribution"
|
||||||
|
data={{ distribution: distData }}
|
||||||
|
/>
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Level Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center">
|
<CardContent className="flex justify-center">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
<Pie
|
||||||
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
data={distData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{distData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
@@ -115,12 +223,44 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comparison" className="mt-4">
|
<TabsContent value="comparison" className="mt-4">
|
||||||
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
<AiReportNarrative
|
||||||
|
report_type="corporate-comparison"
|
||||||
|
data={{ threshold, entities: comparison }}
|
||||||
|
/>
|
||||||
<Card className="border-0 shadow-sm">
|
<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>
|
<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>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,270 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
Select,
|
||||||
import { Switch } from "@/components/ui/switch";
|
SelectContent,
|
||||||
import { Label } from "@/components/ui/label";
|
SelectItem,
|
||||||
import { useState } from "react";
|
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 AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||||
|
import { reportsService } from "@/services/reports.service";
|
||||||
|
|
||||||
const students = [
|
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ScoreBadge({ score }: { score: number }) {
|
function ScoreBadge({ score }: { score: number | null }) {
|
||||||
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
|
if (score === null || score === undefined) {
|
||||||
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
|
Student Performance
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Track student scores across all IELTS modules.
|
||||||
|
</p>
|
||||||
</div>
|
</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 />
|
<AiStudyCoach />
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
<Input
|
||||||
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
{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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
|
<TableHead>Entity</TableHead>
|
||||||
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</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">Overall</TableHead>
|
||||||
|
<TableHead className="text-center">Attempts</TableHead>
|
||||||
<TableHead className="w-10">AI</TableHead>
|
<TableHead className="w-10">AI</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{students.map((s) => (
|
{rows.map((r) => (
|
||||||
<TableRow key={s.name}>
|
<TableRow key={r.student_id}>
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
<TableCell className="font-medium">
|
||||||
<TableCell>{s.entity}</TableCell>
|
{r.student_name}
|
||||||
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
|
<TableCell>{r.entity_name || "—"}</TableCell>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
|
{r.level ? (
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
|
<Badge variant="outline">{r.level}</Badge>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
|
) : (
|
||||||
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
|
"—"
|
||||||
|
)}
|
||||||
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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