From 1a0349c381ec9b3901ae6c31802f69058d3316ed Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 19 Apr 2026 11:28:26 +0400 Subject: [PATCH] feat(reports): replace mock Reports pages with real backend aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../encoach_lms_api/controllers/__init__.py | 1 + .../encoach_lms_api/controllers/reports.py | 446 ++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 custom_addons/encoach_lms_api/controllers/reports.py diff --git a/custom_addons/encoach_lms_api/controllers/__init__.py b/custom_addons/encoach_lms_api/controllers/__init__.py index 4414c62..11d0f73 100644 --- a/custom_addons/encoach_lms_api/controllers/__init__.py +++ b/custom_addons/encoach_lms_api/controllers/__init__.py @@ -24,3 +24,4 @@ from . import tickets from . import payments from . import platform_settings from . import training +from . import reports diff --git a/custom_addons/encoach_lms_api/controllers/reports.py b/custom_addons/encoach_lms_api/controllers/reports.py new file mode 100644 index 0000000..f6984cd --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/reports.py @@ -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)