diff --git a/backend/custom_addons/encoach_lms_api/controllers/__init__.py b/backend/custom_addons/encoach_lms_api/controllers/__init__.py index 4414c625..11d0f73a 100644 --- a/backend/custom_addons/encoach_lms_api/controllers/__init__.py +++ b/backend/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/backend/custom_addons/encoach_lms_api/controllers/reports.py b/backend/custom_addons/encoach_lms_api/controllers/reports.py new file mode 100644 index 00000000..f6984cd8 --- /dev/null +++ b/backend/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) diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md index d6307728..752446c1 100644 --- a/docs/PROJECT_SUMMARY.md +++ b/docs/PROJECT_SUMMARY.md @@ -5,6 +5,7 @@ > This workspace (`odoo19/`) is a **developer monorepo / working tree only** — it conveniently contains both halves side-by-side for local development and testing. The two split repos above are the **authoritative origins** for each half: every change must be published to them (via `git subtree split + push`) before the team lead can deploy. See **§6 Git Remotes & Repositories** for the exact workflow. > **Latest events:** +> - **2026-04-19 (reports section):** Built the Reports section end-to-end — the three pages `/admin/student-performance`, `/admin/stats-corporate`, `/admin/record` (previously pure hardcoded-array mocks) are now wired to real aggregated data from `encoach.student.attempt`. New `/api/reports/{student-performance,stats-corporate,record,filters}` controller (`encoach_lms_api/controllers/reports.py`) does the rollups: per-student band averages + CEFR, per-module corporate charts, trend / distribution / entity comparison, and per-user attempt history with search / level / entity / period filters and CSV export. New `seed_reports.py` completes in-progress attempts and backfills six months of historical attempts so the trend chart and KPI cards are meaningful. 25/25 API smoke passing (`test_reports_flows.py`), 24/24 Configuration + 29/29 Support + 26/26 Training regressions still green, all three pages verified live in-browser with 28 real attempts showing across 4 tabs. See §20. > - **2026-04-19 (remote rename):** Aligned local remote names with the new doctrine — `backend-v4 → origin-backend`, `frontend-v4 → origin-frontend`, `origin → mirror-monorepo`. The `v4` branch now tracks `mirror-monorepo/v4`. All publishing commands in §6 updated. No remote URLs changed. > - **2026-04-19 (repos-of-record reorganization):** Declared `encoach_backend_v4` and `encoach_frontend_v4` as the canonical origins for their respective halves; the monorepo `encoach_backend_new_v2/v4` is now a secondary working-tree mirror kept only for history/convenience. §6 rewritten around this model. > - **2026-04-19 (release to VPS repos):** Pushed the accumulated institutional + support + training work. Frontend canonical → `encoach_frontend_v4/main` (`b78124bb..435930a8`, 403 files, clean fast-forward). Backend canonical → `encoach_backend_v4/main` (`74d83af5..6ec68160`, 420 files, clean fast-forward). Monorepo mirror → `mirror-monorepo/v4` (`98b9837a`). `.gitignore` now excludes `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/` so the 674 MB of local pgdata backups never leak into a push. See §6. @@ -1215,3 +1216,106 @@ No functional errors in the console — only React Router v7 future-flag warning - Progress rows are keyed to `res.users.id`, not `op.student`. Any authenticated API user (admin, teacher, student) sees only their own completions in the `summary` block, while `learners_count` / `completion_count` on the library item itself aggregate across all users. - The `/api/training///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. + diff --git a/frontend/src/pages/RecordPage.tsx b/frontend/src/pages/RecordPage.tsx index 12179578..ba49e2e6 100644 --- a/frontend/src/pages/RecordPage.tsx +++ b/frontend/src/pages/RecordPage.tsx @@ -1,66 +1,212 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Loader2, Download } from "lucide-react"; import AiTipBanner from "@/components/ai/AiTipBanner"; import AiReportNarrative from "@/components/ai/AiReportNarrative"; +import { reportsService, RecordRow } from "@/services/reports.service"; -const records = [ - { id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" }, - { id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" }, - { id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" }, - { id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" }, -]; +type Period = "all" | "day" | "week" | "month"; export default function RecordPage() { + const [entityId, setEntityId] = useState("all"); + const [userId, setUserId] = useState("all"); + const [period, setPeriod] = useState("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 (
-
-

Record

-

Browse assignment and exam attempt history.

+
+
+

Record

+

+ Browse assignment and exam attempt history. + {data && ( + ({data.total} attempts) + )} +

+
+
- + {records.length > 0 && ( + + )}
- + + + + + All Entities + {filters?.entities.map((e) => ( + + {e.name} + + ))} + - + + + + + All Users + {filters?.students.map((s) => ( + + {s.name} + + ))} +
- {["Day", "Week", "Month"].map(t => ( - + {(["all", "day", "week", "month"] as Period[]).map((p) => ( + ))}
- - - - AssignmentExamDate - ScoreDurationStatus - - - - {records.map((r) => ( - - {r.assignment} - {r.exam} - {r.date} - {r.score ? r.score.toFixed(1) : "—"} - {r.duration} - {r.status} + {isLoading ? ( +
+ Loading + attempts... +
+ ) : records.length === 0 ? ( +
+ No attempts match these filters. +
+ ) : ( +
+ + + Student + Assignment + Exam + Date + Score + Duration + Status - ))} - -
+ + + {records.map((r) => ( + + + {r.student_name || "—"} + + {r.assignment || "—"} + + {r.exam_code} + + {r.date || "—"} + + {r.score ? r.score.toFixed(1) : "—"} + + {r.duration} + + + {r.status_label} + + + + ))} + + + )}
diff --git a/frontend/src/pages/StatsCorporatePage.tsx b/frontend/src/pages/StatsCorporatePage.tsx index 12f72bae..7db2f73e 100644 --- a/frontend/src/pages/StatsCorporatePage.tsx +++ b/frontend/src/pages/StatsCorporatePage.tsx @@ -1,126 +1,266 @@ import { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useQuery } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + LineChart, + Line, + PieChart, + Pie, + Cell, +} from "recharts"; +import { Loader2 } from "lucide-react"; import AiReportNarrative from "@/components/ai/AiReportNarrative"; +import { reportsService } from "@/services/reports.service"; const thresholds = ["0%", "50%", "70%", "90%"]; -const barData = [ - { module: "Reading", score: 72 }, - { module: "Listening", score: 68 }, - { module: "Writing", score: 61 }, - { module: "Speaking", score: 65 }, -]; - -const trendData = [ - { month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 }, - { month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 }, -]; - -const distData = [ - { name: "A1", value: 15, color: "hsl(0, 72%, 51%)" }, - { name: "A2", value: 22, color: "hsl(38, 92%, 50%)" }, - { name: "B1", value: 30, color: "hsl(199, 89%, 48%)" }, - { name: "B2", value: 20, color: "hsl(243, 75%, 59%)" }, - { name: "C1", value: 10, color: "hsl(142, 71%, 45%)" }, - { name: "C2", value: 3, color: "hsl(280, 65%, 50%)" }, -]; - export default function StatsCorporatePage() { const [threshold, setThreshold] = useState("0%"); + const [entityId, setEntityId] = useState("all"); + + const { data: filters } = useQuery({ + queryKey: ["reports-filters"], + queryFn: () => reportsService.filters(), + }); + + const { data, isLoading } = useQuery({ + queryKey: ["stats-corporate", threshold, entityId], + queryFn: () => + reportsService.statsCorporate({ + entity_id: entityId === "all" ? undefined : Number(entityId), + threshold: Number(threshold.replace("%", "")), + months: 6, + }), + }); + + const barData = data?.by_module ?? []; + const trendData = data?.trend ?? []; + const distData = data?.distribution ?? []; + const comparison = data?.comparison ?? []; + const attemptsConsidered = data?.meta.attempts_considered ?? 0; return (
-

Corporate Statistics

-

Entity-level performance analytics and reports.

+

+ Corporate Statistics +

+

+ Entity-level performance analytics and reports. + {data && ( + + ({attemptsConsidered} scored attempts) + + )} +

- {thresholds.map(t => ( - + {thresholds.map((t) => ( + ))}
- - + + + + + All Entities + {filters?.entities.map((e) => ( + + {e.name} + + ))} +
- - - Overview - Trends - Distribution - Comparison - + {isLoading ? ( +
+ Aggregating + attempts... +
+ ) : attemptsConsidered === 0 ? ( + + + No scored attempts match this threshold / entity combination yet. + + + ) : ( + + + Overview + Trends + Distribution + Comparison + - - - - Average Score by Module - - - - - - - - - - - - - + + + + + + Average Score by Module + + + + + + + + + + + + + + + - - - - Score Trend Over Time - - - - - - - - - - - - - + + + + + + Score Trend Over Time + + + + + + + + + + + + + + + - - - - Level Distribution - - - - - {distData.map((entry, i) => )} - - - - - - - + + + + + Level Distribution + + + + + + {distData.map((entry, i) => ( + + ))} + + + + + + + - - - - Entity comparison charts will appear here based on selected filters. - - - + + + + + + Entity Comparison (average band × 10) + + + + {comparison.length === 0 ? ( +

+ No entities have attempts above this threshold. +

+ ) : ( + + + + + + + + + + )} +
+
+
+
+ )}
); } diff --git a/frontend/src/pages/StudentPerformancePage.tsx b/frontend/src/pages/StudentPerformancePage.tsx index 8f7e844a..41aabf4f 100644 --- a/frontend/src/pages/StudentPerformancePage.tsx +++ b/frontend/src/pages/StudentPerformancePage.tsx @@ -1,77 +1,270 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; -import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Loader2, Download } from "lucide-react"; import AiStudyCoach from "@/components/ai/AiStudyCoach"; import AiGradeExplainer from "@/components/ai/AiGradeExplainer"; +import { reportsService } from "@/services/reports.service"; -const students = [ - { name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" }, - { name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" }, - { name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" }, - { name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" }, - { name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" }, - { name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" }, -]; +const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const; -function ScoreBadge({ score }: { score: number }) { - const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive"; - return {score.toFixed(1)}; +function ScoreBadge({ score }: { score: number | null }) { + if (score === null || score === undefined) { + return ; + } + const color = + score >= 7.5 + ? "bg-success/10 text-success" + : score >= 6.0 + ? "bg-warning/10 text-warning" + : "bg-destructive/10 text-destructive"; + return ( + + {score.toFixed(1)} + + ); } export default function StudentPerformancePage() { - const [showUtilisation, setShowUtilisation] = useState(false); + const [entityId, setEntityId] = useState("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 (
-
-

Student Performance

-

Track student scores across all IELTS modules.

+
+
+

+ Student Performance +

+

+ Track student scores across all IELTS modules. +

+
+
+ {summary && ( +
+ + +

Students tracked

+

{summary.total}

+
+
+ + +

Average overall band

+

{summary.avg}

+
+
+ + +

Top performer level

+

{summary.topLevel}

+
+
+
+ )} +
- setSearch(e.target.value)} + className="w-[220px]" + /> + + -
- - -
- - - - StudentEntityLevel - ReadingListening - WritingSpeaking - Overall - AI - - - - {students.map((s) => ( - - {s.name} - {s.entity} - {s.level} - - - - - - + {isLoading ? ( +
+ Loading + performance data... +
+ ) : rows.length === 0 ? ( +
+ No completed attempts match these filters yet. +
+ ) : ( +
+ + + Student + Entity + Level + Reading + Listening + Writing + Speaking + Overall + Attempts + AI - ))} - -
+ + + {rows.map((r) => ( + + + {r.student_name} + + {r.entity_name || "—"} + + {r.level ? ( + {r.level} + ) : ( + "—" + )} + + + + + + + + + + + + + + + + + + {r.attempts_count} + + + + + + ))} + + + )}
diff --git a/frontend/src/services/reports.service.ts b/frontend/src/services/reports.service.ts new file mode 100644 index 00000000..d6dbe6cc --- /dev/null +++ b/frontend/src/services/reports.service.ts @@ -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; + +export const reportsService = { + async studentPerformance(params?: { + entity_id?: number; + level?: string; + search?: string; + since?: string; + }): Promise { + return api.get( + "/reports/student-performance", + params as QueryParams, + ); + }, + + async statsCorporate(params?: { + entity_id?: number; + threshold?: number; + months?: number; + since?: string; + }): Promise { + return api.get( + "/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 { + return api.get("/reports/record", params as QueryParams); + }, + + async filters(): Promise { + return api.get("/reports/filters"); + }, +}; diff --git a/seed_reports.py b/seed_reports.py new file mode 100644 index 00000000..225b19ca --- /dev/null +++ b/seed_reports.py @@ -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() diff --git a/test_reports_flows.py b/test_reports_flows.py new file mode 100644 index 00000000..e3e095b8 --- /dev/null +++ b/test_reports_flows.py @@ -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)