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

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

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

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

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

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

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

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

View File

@@ -24,3 +24,4 @@ from . import tickets
from . import payments
from . import platform_settings
from . import training
from . import reports

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