feat(reports): replace mock Reports pages with real backend aggregates
Wire the three admin Reports pages (/admin/student-performance, /admin/stats-corporate, /admin/record) to a new encoach_lms_api/controllers/reports.py that aggregates from encoach.student.attempt. * /api/reports/student-performance: per-student band averages + CEFR, with entity / level / search filters. * /api/reports/stats-corporate: by_module bar, N-month trend line, CEFR distribution pie, entity comparison bar, threshold + entity filters and meta.attempts_considered for UI. * /api/reports/record: paginated attempt history with entity / user / period filters, EX-### exam codes, duration derived from start/end. * /api/reports/filters: shared picker returning only entities / students that actually have attempts. Frontend: new reports.service.ts, all three pages rewritten to hit these endpoints; Recharts graphs now read live data, CSV export added on Student Performance and Record. Seeding: seed_reports.py completes any in_progress attempts and backfills 6 months of historical attempts across 3 entities so the trend / distribution / KPI panels have meaningful data. Idempotent. Tests: test_reports_flows.py (25/25 PASS) covers shape, filters, pagination. Regressions still green: Configuration 24/24, Support 29/29, Training 26/26. Browser-verified on localhost:8080 with admin login — all 4 tabs in Stats Corporate render, Student Performance shows real students + KPIs, Record shows 28 attempts with filter + CSV export working. Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts, seeding, test results, and gotchas (encoach.exam.custom uses `title` not `name`; encoach.entity requires `code`; in_progress attempts are excluded from aggregates). Made-with: Cursor
This commit is contained in:
@@ -24,3 +24,4 @@ from . import tickets
|
||||
from . import payments
|
||||
from . import platform_settings
|
||||
from . import training
|
||||
from . import reports
|
||||
|
||||
446
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal file
446
backend/custom_addons/encoach_lms_api/controllers/reports.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""Reports API.
|
||||
|
||||
Serves the admin "Reports" section in the sidebar:
|
||||
* Student Performance -> /api/reports/student-performance
|
||||
* Stats Corporate -> /api/reports/stats-corporate
|
||||
* Record -> /api/reports/record
|
||||
|
||||
All endpoints aggregate from ``encoach.student.attempt`` (exam attempts with
|
||||
per-skill band scores and CEFR level) and its surrounding records
|
||||
(``res.users`` for student names, ``encoach.entity`` for corporate context,
|
||||
``encoach.exam.custom`` for exam metadata).
|
||||
|
||||
A single attempt is "reportable" when it has a completed/scored/released
|
||||
status AND at least one non-null band. Everything else is ignored so the
|
||||
Reports pages never surface misleading zeros for in-progress work.
|
||||
"""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _paginate,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
|
||||
|
||||
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
|
||||
_CEFR_BY_BAND = [
|
||||
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
|
||||
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
|
||||
]
|
||||
|
||||
|
||||
def _cefr_for_band(band):
|
||||
if band is None:
|
||||
return None
|
||||
for threshold, label in _CEFR_BY_BAND:
|
||||
if band >= threshold:
|
||||
return label
|
||||
return 'Pre-A1'
|
||||
|
||||
|
||||
def _normalize_cefr(label):
|
||||
if not label:
|
||||
return None
|
||||
s = str(label).strip().lower().replace('-', '_')
|
||||
return {
|
||||
'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2',
|
||||
'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2',
|
||||
}.get(s, label)
|
||||
|
||||
|
||||
def _duration_minutes(att):
|
||||
start = att.started_at
|
||||
end = None
|
||||
if hasattr(att, 'completed_at'):
|
||||
end = att.completed_at
|
||||
if not end and hasattr(att, 'finished_at'):
|
||||
end = att.finished_at
|
||||
if not start or not end:
|
||||
return None
|
||||
return int(max(0, (end - start).total_seconds() // 60))
|
||||
|
||||
|
||||
def _attempt_completed_at(att):
|
||||
if hasattr(att, 'completed_at') and att.completed_at:
|
||||
return att.completed_at
|
||||
if hasattr(att, 'finished_at') and att.finished_at:
|
||||
return att.finished_at
|
||||
return None
|
||||
|
||||
|
||||
def _build_attempt_domain(kw, reportable=True):
|
||||
"""Common filter reader used by all three endpoints."""
|
||||
domain = []
|
||||
if reportable:
|
||||
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
|
||||
entity_id = kw.get('entity_id')
|
||||
if entity_id:
|
||||
try:
|
||||
domain.append(('entity_id', '=', int(entity_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
user_id = kw.get('user_id') or kw.get('student_id')
|
||||
if user_id:
|
||||
try:
|
||||
domain.append(('student_id', '=', int(user_id)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
since = kw.get('since')
|
||||
if since:
|
||||
try:
|
||||
domain.append(('started_at', '>=', since))
|
||||
except Exception:
|
||||
pass
|
||||
period = (kw.get('period') or '').lower()
|
||||
if period in ('day', 'week', 'month'):
|
||||
delta = {'day': 1, 'week': 7, 'month': 30}[period]
|
||||
cutoff = datetime.now() - timedelta(days=delta)
|
||||
domain.append(('started_at', '>=', cutoff))
|
||||
return domain
|
||||
|
||||
|
||||
class ReportsController(http.Controller):
|
||||
|
||||
# ── 1. Student Performance ───────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/student-performance', type='http',
|
||||
auth='public', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def student_performance(self, **kw):
|
||||
"""Return one row per student with averaged bands per skill.
|
||||
|
||||
Query params: ``entity_id``, ``level`` (CEFR code, case-insensitive),
|
||||
``search`` (matches student name/login), ``since`` (ISO date).
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
attempts = Att.search(domain, order='started_at desc')
|
||||
|
||||
per_student = defaultdict(lambda: {
|
||||
'attempts': [],
|
||||
'reading_sum': 0.0, 'reading_n': 0,
|
||||
'listening_sum': 0.0, 'listening_n': 0,
|
||||
'writing_sum': 0.0, 'writing_n': 0,
|
||||
'speaking_sum': 0.0, 'speaking_n': 0,
|
||||
'overall_sum': 0.0, 'overall_n': 0,
|
||||
'last_at': None, 'last_cefr': None,
|
||||
'entity_id': None, 'entity_name': None,
|
||||
})
|
||||
|
||||
def _accum(agg, key, value):
|
||||
if value is not None and value > 0:
|
||||
agg[f'{key}_sum'] += float(value)
|
||||
agg[f'{key}_n'] += 1
|
||||
|
||||
for att in attempts:
|
||||
if not att.student_id:
|
||||
continue
|
||||
agg = per_student[att.student_id.id]
|
||||
agg['attempts'].append(att.id)
|
||||
_accum(agg, 'reading', att.reading_band)
|
||||
_accum(agg, 'listening', att.listening_band)
|
||||
_accum(agg, 'writing', att.writing_band)
|
||||
_accum(agg, 'speaking', att.speaking_band)
|
||||
_accum(agg, 'overall', att.overall_band)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if ct and (agg['last_at'] is None or ct > agg['last_at']):
|
||||
agg['last_at'] = ct
|
||||
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if att.entity_id and not agg['entity_id']:
|
||||
agg['entity_id'] = att.entity_id.id
|
||||
agg['entity_name'] = att.entity_id.name
|
||||
|
||||
search_lc = (kw.get('search') or '').strip().lower()
|
||||
level_filter = (kw.get('level') or '').strip().upper().replace('-', '_')
|
||||
|
||||
rows = []
|
||||
for student_id, agg in per_student.items():
|
||||
user = request.env['res.users'].sudo().browse(student_id)
|
||||
if not user.exists():
|
||||
continue
|
||||
name = user.name or user.login or f'User #{student_id}'
|
||||
if search_lc and search_lc not in (name or '').lower() \
|
||||
and search_lc not in (user.login or '').lower():
|
||||
continue
|
||||
|
||||
def _avg(key):
|
||||
n = agg[f'{key}_n']
|
||||
if not n:
|
||||
return None
|
||||
return round(agg[f'{key}_sum'] / n, 2)
|
||||
|
||||
overall = _avg('overall')
|
||||
if overall is None:
|
||||
skill_avgs = [v for v in (
|
||||
_avg('reading'), _avg('listening'),
|
||||
_avg('writing'), _avg('speaking'),
|
||||
) if v is not None]
|
||||
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
|
||||
if skill_avgs else None
|
||||
|
||||
level = agg['last_cefr'] or _cefr_for_band(overall)
|
||||
if level_filter and (level or '').upper().replace('-', '_') \
|
||||
!= level_filter:
|
||||
continue
|
||||
|
||||
rows.append({
|
||||
'student_id': student_id,
|
||||
'student_name': name,
|
||||
'login': user.login or '',
|
||||
'entity_id': agg['entity_id'],
|
||||
'entity_name': agg['entity_name'] or '',
|
||||
'reading': _avg('reading'),
|
||||
'listening': _avg('listening'),
|
||||
'writing': _avg('writing'),
|
||||
'speaking': _avg('speaking'),
|
||||
'overall': overall,
|
||||
'level': level,
|
||||
'attempts_count': len(agg['attempts']),
|
||||
'last_attempt_at': agg['last_at'].isoformat()
|
||||
if agg['last_at'] else None,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: (-(r['overall'] or 0.0),
|
||||
r['student_name'].lower()))
|
||||
|
||||
return _json_response({
|
||||
'items': rows,
|
||||
'total': len(rows),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('student-performance report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── 2. Stats Corporate ───────────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/stats-corporate', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def stats_corporate(self, **kw):
|
||||
"""Corporate rollups: avg by module, monthly trend, level distribution,
|
||||
entity comparison.
|
||||
|
||||
Query params: ``entity_id``, ``threshold`` (0..100, minimum band*10 to
|
||||
include), ``months`` (trend horizon; default 6).
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw)
|
||||
|
||||
try:
|
||||
threshold = float(kw.get('threshold') or 0)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.0
|
||||
if threshold > 0:
|
||||
domain.append(('overall_band', '>=', threshold / 10.0))
|
||||
|
||||
attempts = Att.search(domain)
|
||||
|
||||
skill_totals = {k: [0.0, 0] for k in
|
||||
('reading', 'listening', 'writing', 'speaking')}
|
||||
for att in attempts:
|
||||
for skill, field in (('reading', att.reading_band),
|
||||
('listening', att.listening_band),
|
||||
('writing', att.writing_band),
|
||||
('speaking', att.speaking_band)):
|
||||
if field is not None and field > 0:
|
||||
skill_totals[skill][0] += field
|
||||
skill_totals[skill][1] += 1
|
||||
|
||||
by_module = []
|
||||
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
|
||||
total, n = skill_totals[skill.lower()]
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
by_module.append({'module': skill, 'score': avg, 'n': n})
|
||||
|
||||
try:
|
||||
months = max(1, min(24, int(kw.get('months') or 6)))
|
||||
except (TypeError, ValueError):
|
||||
months = 6
|
||||
|
||||
month_buckets = defaultdict(lambda: [0.0, 0])
|
||||
for att in attempts:
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
if not ct or att.overall_band is None:
|
||||
continue
|
||||
key = ct.strftime('%Y-%m')
|
||||
month_buckets[key][0] += att.overall_band
|
||||
month_buckets[key][1] += 1
|
||||
|
||||
trend = []
|
||||
today = datetime.now().replace(day=1)
|
||||
for i in range(months - 1, -1, -1):
|
||||
ref = (today - timedelta(days=31 * i)).replace(day=1)
|
||||
key = ref.strftime('%Y-%m')
|
||||
total, n = month_buckets.get(key, (0.0, 0))
|
||||
avg = round((total / n) * 10, 1) if n else 0
|
||||
trend.append({'month': ref.strftime('%b'), 'avg': avg,
|
||||
'period': key, 'n': n})
|
||||
|
||||
level_buckets = defaultdict(int)
|
||||
for att in attempts:
|
||||
label = _normalize_cefr(att.cefr_level) \
|
||||
or _cefr_for_band(att.overall_band)
|
||||
if label:
|
||||
level_buckets[label] += 1
|
||||
palette = {
|
||||
'Pre-A1': 'hsl(0, 0%, 55%)',
|
||||
'A1': 'hsl(0, 72%, 51%)',
|
||||
'A2': 'hsl(38, 92%, 50%)',
|
||||
'B1': 'hsl(199, 89%, 48%)',
|
||||
'B2': 'hsl(243, 75%, 59%)',
|
||||
'C1': 'hsl(142, 71%, 45%)',
|
||||
'C2': 'hsl(280, 65%, 50%)',
|
||||
}
|
||||
distribution = [
|
||||
{'name': lvl, 'value': level_buckets.get(lvl, 0),
|
||||
'color': palette[lvl]}
|
||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||
if level_buckets.get(lvl)
|
||||
] or [
|
||||
{'name': lvl, 'value': 0, 'color': palette[lvl]}
|
||||
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
|
||||
]
|
||||
|
||||
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
|
||||
for att in attempts:
|
||||
if att.overall_band is None:
|
||||
continue
|
||||
eid = att.entity_id.id or 0
|
||||
entity_buckets[eid][0] += att.overall_band
|
||||
entity_buckets[eid][1] += 1
|
||||
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
|
||||
comparison = []
|
||||
for eid, (total, n, name) in entity_buckets.items():
|
||||
comparison.append({
|
||||
'entity_id': eid or None,
|
||||
'entity_name': name,
|
||||
'avg': round((total / n) * 10, 1) if n else 0,
|
||||
'attempts': n,
|
||||
})
|
||||
comparison.sort(key=lambda r: -r['avg'])
|
||||
|
||||
return _json_response({
|
||||
'by_module': by_module,
|
||||
'trend': trend,
|
||||
'distribution': distribution,
|
||||
'comparison': comparison,
|
||||
'meta': {
|
||||
'attempts_considered': len(attempts),
|
||||
'threshold': threshold,
|
||||
'months': months,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('stats-corporate report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── 3. Record ────────────────────────────────────────────────────
|
||||
|
||||
@http.route('/api/reports/record', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def record(self, **kw):
|
||||
"""Per-user attempt history.
|
||||
|
||||
Query params: ``user_id`` (required for focused view; omit to see all),
|
||||
``entity_id``, ``period`` (day|week|month), ``status``, pagination.
|
||||
"""
|
||||
try:
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
domain = _build_attempt_domain(kw, reportable=False)
|
||||
if kw.get('status'):
|
||||
domain.append(('status', '=', kw['status']))
|
||||
|
||||
offset, limit, page = _paginate(kw)
|
||||
total = Att.search_count(domain)
|
||||
attempts = Att.search(domain, offset=offset, limit=limit,
|
||||
order='started_at desc')
|
||||
|
||||
items = []
|
||||
for att in attempts:
|
||||
exam = att.exam_id
|
||||
exam_title = ''
|
||||
if exam:
|
||||
# encoach.exam.custom labels its record with `title`,
|
||||
# not `name`. Fall back to display_name if the field
|
||||
# is ever renamed upstream.
|
||||
exam_title = getattr(exam, 'title', None) \
|
||||
or getattr(exam, 'display_name', '') or ''
|
||||
assignment = request.env['encoach.exam.assignment'].sudo() \
|
||||
.search([('exam_id', '=', exam.id),
|
||||
('student_id', '=', att.student_id.id)],
|
||||
limit=1) if exam and att.student_id else None
|
||||
duration_min = _duration_minutes(att)
|
||||
ct = _attempt_completed_at(att) or att.started_at
|
||||
items.append({
|
||||
'id': att.id,
|
||||
'student_id': att.student_id.id or None,
|
||||
'student_name': att.student_id.name if att.student_id else '',
|
||||
'assignment': exam_title,
|
||||
'assignment_id': assignment.id if assignment else None,
|
||||
'exam': exam_title,
|
||||
'exam_code': f'EX-{exam.id:03d}' if exam else '',
|
||||
'exam_id': exam.id or None,
|
||||
'entity_id': att.entity_id.id or None,
|
||||
'entity_name': att.entity_id.name if att.entity_id else '',
|
||||
'date': ct.strftime('%Y-%m-%d') if ct else None,
|
||||
'started_at': att.started_at.isoformat()
|
||||
if att.started_at else None,
|
||||
'completed_at': (_attempt_completed_at(att).isoformat()
|
||||
if _attempt_completed_at(att) else None),
|
||||
'score': att.overall_band if att.overall_band else None,
|
||||
'duration_min': duration_min,
|
||||
'duration': f'{duration_min} min' if duration_min is not None
|
||||
else '—',
|
||||
'status': att.status or 'in_progress',
|
||||
'status_label': (att.status or 'in_progress').replace('_', ' ').title(),
|
||||
'cefr_level': _normalize_cefr(att.cefr_level)
|
||||
or _cefr_for_band(att.overall_band),
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': limit,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('record report failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ── Shared picker: entities and students for report filters ──────
|
||||
|
||||
@http.route('/api/reports/filters', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def filters(self, **kw):
|
||||
"""Lightweight list of entities + students/users we have attempts for.
|
||||
Used by the filter dropdowns on all three Reports pages."""
|
||||
try:
|
||||
Ent = request.env['encoach.entity'].sudo()
|
||||
entities = [{'id': e.id, 'name': e.name or ''}
|
||||
for e in Ent.search([], order='name')]
|
||||
|
||||
Att = request.env['encoach.student.attempt'].sudo()
|
||||
att_students = Att.search([]).mapped('student_id')
|
||||
students = [{'id': u.id, 'name': u.name or u.login or '',
|
||||
'login': u.login or ''}
|
||||
for u in att_students.sorted('name')
|
||||
if u and u.id > 0]
|
||||
|
||||
return _json_response({
|
||||
'entities': entities,
|
||||
'students': students,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('reports filters failed')
|
||||
return _error_response(str(e), 500)
|
||||
Reference in New Issue
Block a user