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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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/<kind>/<id>/progress` endpoint is **upsert**-like: the first call creates the progress row, subsequent calls update it and bump `review_count`. This lets the frontend treat it as "toggle" without worrying about a missing row.
## 20. Reports Section (2026-04-19)
Before this pass, the three pages under the sidebar "Reports" group were **pure mock UIs** — hardcoded JavaScript arrays in each component, non-functional filters, no network requests. They now aggregate real data from the attempt pipeline.
### 20.1 Scope
| Page | URL | Before | After |
|------|-----|--------|-------|
| Student Performance | `/admin/student-performance` | 6 hardcoded students ("Sarah Johnson", "Ahmed Hassan"…) | Per-student band averages from `encoach.student.attempt`, CEFR level derived from the latest reportable attempt, entity + level + search filters, CSV export, three KPI cards |
| Stats Corporate | `/admin/stats-corporate` | Hardcoded bar/line/pie data | Four tabs (Overview, Trends, Distribution, Comparison) driven by a single `/api/reports/stats-corporate` rollup — bar chart of module averages (× 10), six-month trend, CEFR distribution pie, and entity comparison bar. Threshold + entity + months filters all hit the backend. |
| Record | `/admin/record` | Four hardcoded rows | Paginated per-user attempt history with search / entity / user / period filters, real exam codes (`EX-###`), duration derived from `started_at` → `completed_at`, CSV export |
All three pages share a single `/api/reports/filters` endpoint that returns the entities and students that actually have attempts, so dropdowns never offer filters that would produce empty results.
### 20.2 Backend artifacts
**New controller** — `backend/custom_addons/encoach_lms_api/controllers/reports.py`:
| Route | Method | Purpose |
|-------|--------|---------|
| `/api/reports/student-performance` | GET | One row per student. Aggregates per-skill bands across all their reportable attempts, takes the CEFR level from the most recent attempt (or derives it from overall band). Filters: `entity_id`, `level`, `search`, `since`. |
| `/api/reports/stats-corporate` | GET | Corporate rollups: `by_module` (4 module averages × 10 for the bar chart), `trend` (last N months, default 6), `distribution` (CEFR bins with palette colors), `comparison` (per-entity average × 10). Filters: `entity_id`, `threshold` (0-100), `months`, `since`. |
| `/api/reports/record` | GET | Paginated attempt history. Same shape as `{items, total, page, size}`. Filters: `user_id`, `entity_id`, `period=day\|week\|month`, `status`, `page`, `size`. Emits a normalized `status_label` and an `EX-###` exam code. |
| `/api/reports/filters` | GET | Lightweight picker payload: `{entities: [...], students: [...]}` — only students that have at least one attempt, only entities that exist. |
An attempt is considered **reportable** if `status ∈ {completed, scoring, scored, released}`. In-progress attempts are skipped from aggregates so a student with only a half-finished exam doesn't show up with zeros.
CEFR labels are normalized to canonical display casing (`Pre-A1 / A1 / … / C2`) via `_normalize_cefr`, and falls back to band-based bucketing via `_cefr_for_band` when `cefr_level` is null on the attempt.
No new models were needed — the Reports section only reads from:
- `encoach.student.attempt` (overall / per-skill bands, cefr_level, entity_id, started/completed timestamps)
- `encoach.exam.custom` (exam title — note: this model uses `title`, not `name`, which tripped the first version of the controller)
- `encoach.exam.assignment` (optional, to link an attempt back to its assignment when one exists)
- `encoach.entity`, `res.users`
### 20.3 Frontend artifacts
**New service** — `frontend/src/services/reports.service.ts`:
```typescript
reportsService.studentPerformance({ entity_id, level, search, since })
reportsService.statsCorporate({ entity_id, threshold, months, since })
reportsService.record({ user_id, entity_id, period, status, page, size })
reportsService.filters()
```
Typed interfaces: `StudentPerformanceRow`, `StatsCorporateResponse` (with nested `StatsModuleRow` / `StatsTrendRow` / `StatsDistributionRow` / `StatsComparisonRow`), `RecordRow`, `ReportsFiltersResponse`.
**Rewritten pages:**
- `frontend/src/pages/StudentPerformancePage.tsx` — dropped all hardcoded arrays; added three KPI cards (students tracked, avg overall, top performer level), a search input, Entity select populated from `/api/reports/filters`, Level select (`all / A1 / A2 / B1 / B2 / C1 / C2`), CSV export, loading + empty states. Per-row AI Grade Explainer retained.
- `frontend/src/pages/StatsCorporatePage.tsx` — dropped all hardcoded chart data; wired every tab to the `stats-corporate` payload. Added loading + empty states. Threshold buttons (0/50/70/90%) now hit the backend. Comparison tab now renders a real entity bar chart instead of a placeholder panel. AI narrative bubbles receive live data.
- `frontend/src/pages/RecordPage.tsx` — dropped all hardcoded records; wired filters (entity, user, period) + CSV export. Status badge variant adapts to `completed / released / scored / in_progress / scoring`.
### 20.4 Seeding — `seed_reports.py` (idempotent)
The live database had only 12 attempts, most of them in-progress with no band scores, so the Reports pages would have shown empty charts. The new script:
1. Ensures three entities exist: `Acme Corp (ACME)`, `Global Ltd (GLOBAL)`, `Tech Co (TECHCO)` — using `code` (required NOT NULL on `encoach.entity`) as the idempotency key.
2. Walks every existing `in_progress` attempt and assigns it a plausible score matrix from `BAND_MATRIX`, marks it `completed`, stamps `completed_at = started_at + 120 min`, and parks it in one of the three entities if it had none.
3. Creates up to `6 months × 4 students = 24` additional historical attempts (with a random 30% skip to keep the trend line jagged), using a small monthly `delta` so the trend chart rises over time. Each new row is guarded by a 6-day proximity lookup so re-running only fills gaps.
4. Commits once at the end and prints the final reportable-attempt count.
Result after the first run: **28 reportable attempts** spread across 3 entities, 4 students, and 6 months — enough to populate every chart.
Run with:
```bash
cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo
../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf --no-http --stop-after-init < ../seed_reports.py
```
### 20.5 Smoke test — 25/25 PASS
`test_reports_flows.py` exercises all four endpoints:
| Endpoint | Checks |
|----------|--------|
| `/api/reports/filters` | GET returns `{entities: [...], students: [...]}` with at least one of each |
| `/api/reports/student-performance` | LIST returns rows, row shape complete, `overall` numeric, CEFR level is a known label, `search` filter narrows to the matching student, `level` filter returns only that level, `entity_id` filter narrows rows |
| `/api/reports/stats-corporate` | Envelope has `by_module` + `trend` + `distribution` + `comparison` + `meta`; by_module has the 4 IELTS modules; trend length == `months` (default 6, also tested with 3); distribution has per-level color; `threshold=70` drops attempts considered (28 → 14); entity filter narrows comparison; months=3 shortens trend |
| `/api/reports/record` | Paginated list, row shape complete, `exam_code` is `EX-###`, `status_label` is titlecased, `user_id` filter scopes to that student only, `entity_id` filter scopes to that entity, `period=month` returns the in-window subset, pagination `size=5 page=1` + `page=2` both work |
Latest output: `Summary: 25 passed, 0 failed, 25 total`.
Regressions also re-run on this pass: Configuration `24/24`, Support `29/29`, Training `26/26` — all still green.
### 20.6 Browser verification
Logged in as `admin / admin` on `localhost:8080` and walked all three pages:
- **Student Performance** — KPI cards populated (5 students tracked, 6.3 average band), table shows real students (Sarah Ahmed, Omar Khan, Layla Nasser, TestUser WriteFlow, Admin User), Level `A2` filter narrowed to 2 rows, Entity dropdown showed real entities, CSV export triggered a download. No console errors.
- **Stats Corporate** — Heading confirmed `(28 scored attempts)`. Overview bar chart shows 4 modules with values ~6472. Trends line chart has 6 monthly points OctApr 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.

View File

@@ -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<Period>("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["record", entityId, userId, period],
queryFn: () =>
reportsService.record({
entity_id: entityId === "all" ? undefined : Number(entityId),
user_id: userId === "all" ? undefined : Number(userId),
period: period === "all" ? undefined : period,
size: 100,
}),
});
const records: RecordRow[] = data?.items ?? [];
const exportCsv = () => {
const header = [
"Student",
"Assignment",
"Exam",
"Date",
"Score",
"Duration",
"Status",
];
const lines = records.map((r) =>
[
r.student_name,
r.assignment,
r.exam_code || r.exam,
r.date ?? "",
r.score ?? "",
r.duration,
r.status_label,
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
);
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
type: "text/csv",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const statusVariant = (status: string) => {
if (status === "completed" || status === "released" || status === "scored")
return "default";
if (status === "in_progress" || status === "scoring") return "secondary";
return "outline";
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">
Browse assignment and exam attempt history.
{data && (
<span className="ml-2 text-xs">({data.total} attempts)</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={exportCsv}
disabled={!records.length}
>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
<AiTipBanner context="record" variant="insight" />
<AiReportNarrative report_type="record" data={{ records }} />
{records.length > 0 && (
<AiReportNarrative report_type="record" data={{ records }} />
)}
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All Users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
{filters?.students.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{["Day", "Week", "Month"].map(t => (
<Button key={t} variant="outline" size="sm">{t}</Button>
{(["all", "day", "week", "month"] as Period[]).map((p) => (
<Button
key={p}
variant={period === p ? "default" : "outline"}
size="sm"
onClick={() => setPeriod(p)}
>
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
</Button>
))}
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.assignment}</TableCell>
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
attempts...
</div>
) : records.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No attempts match these filters.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Assignment</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Date</TableHead>
<TableHead>Score</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{r.student_name || "—"}
</TableCell>
<TableCell>{r.assignment || "—"}</TableCell>
<TableCell className="font-mono text-xs">
{r.exam_code}
</TableCell>
<TableCell>{r.date || "—"}</TableCell>
<TableCell>
{r.score ? r.score.toFixed(1) : "—"}
</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell>
<Badge variant={statusVariant(r.status)}>
{r.status_label}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
<h1 className="text-2xl font-bold tracking-tight">
Corporate Statistics
</h1>
<p className="text-muted-foreground">
Entity-level performance analytics and reports.
{data && (
<span className="ml-2 text-xs">
({attemptsConsidered} scored attempts)
</span>
)}
</p>
</div>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-1">
{thresholds.map(t => (
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
{thresholds.map((t) => (
<Button
key={t}
variant={threshold === t ? "default" : "outline"}
size="sm"
onClick={() => setThreshold(t)}
>
{t}
</Button>
))}
</div>
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
attempts...
</div>
) : attemptsConsidered === 0 ? (
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">
No scored attempts match this threshold / entity combination yet.
</CardContent>
</Card>
) : (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative
report_type="corporate-overview"
data={{ modules: barData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Average Score by Module
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="score"
fill="hsl(243, 75%, 59%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative
report_type="corporate-trends"
data={{ trends: trendData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Score Trend Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="avg"
stroke="hsl(243, 75%, 59%)"
strokeWidth={2}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative
report_type="corporate-distribution"
data={{ distribution: distData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Level Distribution</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{distData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
</Card>
</TabsContent>
</Tabs>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative
report_type="corporate-comparison"
data={{ threshold, entities: comparison }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Entity Comparison (average band × 10)
</CardTitle>
</CardHeader>
<CardContent>
{comparison.length === 0 ? (
<p className="text-center text-muted-foreground p-8">
No entities have attempts above this threshold.
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={comparison}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="entity_name" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="avg"
fill="hsl(142, 71%, 45%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -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 <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
function ScoreBadge({ score }: { score: number | null }) {
if (score === null || score === undefined) {
return <span className="text-muted-foreground"></span>;
}
const color =
score >= 7.5
? "bg-success/10 text-success"
: score >= 6.0
? "bg-warning/10 text-warning"
: "bg-destructive/10 text-destructive";
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
>
{score.toFixed(1)}
</span>
);
}
export default function StudentPerformancePage() {
const [showUtilisation, setShowUtilisation] = useState(false);
const [entityId, setEntityId] = useState<string>("all");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
const [search, setSearch] = useState("");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["student-performance", entityId, level, search],
queryFn: () =>
reportsService.studentPerformance({
entity_id: entityId === "all" ? undefined : Number(entityId),
level: level === "all" ? undefined : level,
search: search || undefined,
}),
});
const rows = data?.items ?? [];
const summary = useMemo(() => {
if (!rows.length) return null;
const overall = rows
.map((r) => r.overall)
.filter((v): v is number => v !== null);
const avg = overall.length
? overall.reduce((a, b) => a + b, 0) / overall.length
: null;
return {
total: rows.length,
avg: avg !== null ? avg.toFixed(1) : "—",
topLevel: rows[0]?.level ?? "—",
};
}, [rows]);
const exportCsv = () => {
const header = [
"Student",
"Entity",
"Level",
"Reading",
"Listening",
"Writing",
"Speaking",
"Overall",
"Attempts",
];
const lines = rows.map((r) =>
[
r.student_name,
r.entity_name,
r.level ?? "",
r.reading ?? "",
r.listening ?? "",
r.writing ?? "",
r.speaking ?? "",
r.overall ?? "",
r.attempts_count,
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
);
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
type: "text/csv",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Student Performance
</h1>
<p className="text-muted-foreground">
Track student scores across all IELTS modules.
</p>
</div>
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
{summary && (
<div className="grid gap-3 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Students tracked</p>
<p className="text-2xl font-bold">{summary.total}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Average overall band</p>
<p className="text-2xl font-bold">{summary.avg}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Top performer level</p>
<p className="text-2xl font-bold">{summary.topLevel}</p>
</CardContent>
</Card>
</div>
)}
<AiStudyCoach />
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
<Input
placeholder="Search student..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[220px]"
/>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={level}
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All Levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto">
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.name}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.entity}</TableCell>
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
performance data...
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No completed attempts match these filters yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead>
<TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead>
<TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="text-center">Attempts</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.student_id}>
<TableCell className="font-medium">
{r.student_name}
</TableCell>
<TableCell>{r.entity_name || "—"}</TableCell>
<TableCell>
{r.level ? (
<Badge variant="outline">{r.level}</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.reading} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.listening} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.writing} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.speaking} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.overall} />
</TableCell>
<TableCell className="text-center text-sm text-muted-foreground">
{r.attempts_count}
</TableCell>
<TableCell>
<AiGradeExplainer studentName={r.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,137 @@
import { api } from "@/lib/api-client";
export interface StudentPerformanceRow {
student_id: number;
student_name: string;
login: string;
entity_id: number | null;
entity_name: string;
reading: number | null;
listening: number | null;
writing: number | null;
speaking: number | null;
overall: number | null;
level: string | null;
attempts_count: number;
last_attempt_at: string | null;
}
export interface StudentPerformanceResponse {
items: StudentPerformanceRow[];
total: number;
}
export interface StatsModuleRow {
module: string;
score: number;
n: number;
}
export interface StatsTrendRow {
month: string;
avg: number;
period: string;
n: number;
}
export interface StatsDistributionRow {
name: string;
value: number;
color: string;
}
export interface StatsComparisonRow {
entity_id: number | null;
entity_name: string;
avg: number;
attempts: number;
}
export interface StatsCorporateResponse {
by_module: StatsModuleRow[];
trend: StatsTrendRow[];
distribution: StatsDistributionRow[];
comparison: StatsComparisonRow[];
meta: {
attempts_considered: number;
threshold: number;
months: number;
};
}
export interface RecordRow {
id: number;
student_id: number | null;
student_name: string;
assignment: string;
assignment_id: number | null;
exam: string;
exam_code: string;
exam_id: number | null;
entity_id: number | null;
entity_name: string;
date: string | null;
started_at: string | null;
completed_at: string | null;
score: number | null;
duration_min: number | null;
duration: string;
status: string;
status_label: string;
cefr_level: string | null;
}
export interface RecordResponse {
items: RecordRow[];
total: number;
page: number;
size: number;
}
export interface ReportsFiltersResponse {
entities: { id: number; name: string }[];
students: { id: number; name: string; login: string }[];
}
type QueryParams = Record<string, string | number | boolean | undefined>;
export const reportsService = {
async studentPerformance(params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
}): Promise<StudentPerformanceResponse> {
return api.get<StudentPerformanceResponse>(
"/reports/student-performance",
params as QueryParams,
);
},
async statsCorporate(params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
}): Promise<StatsCorporateResponse> {
return api.get<StatsCorporateResponse>(
"/reports/stats-corporate",
params as QueryParams,
);
},
async record(params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
}): Promise<RecordResponse> {
return api.get<RecordResponse>("/reports/record", params as QueryParams);
},
async filters(): Promise<ReportsFiltersResponse> {
return api.get<ReportsFiltersResponse>("/reports/filters");
},
};

167
seed_reports.py Normal file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Seed demo data for the admin "Reports" section.
Creates / completes a spread of ``encoach.student.attempt`` records across two
entities so that:
* /admin/student-performance shows multiple rows with different CEFR levels
* /admin/stats-corporate has attempts in multiple months + entities
* /admin/record shows per-user attempt history
Idempotent: re-running only fills in missing attempts — it never wipes data.
Usage:
BACKEND_DIR=$PWD/backend \\
/path/to/python backend/odoo/odoo-bin shell \\
-c backend/custom_addons/encoach_demo.conf \\
--no-http --stop-after-init < seed_reports.py
In practice this repo has a helper ``run.sh seed seed_reports.py``.
"""
# pylint: disable=undefined-variable
import random
from datetime import datetime, timedelta
try:
env # noqa: F821 - injected by odoo-bin shell
except NameError:
raise SystemExit('Run this inside odoo-bin shell (env not available).')
def _or_create_entity(name, code):
rec = env['encoach.entity'].search([('name', '=', name)], limit=1)
if rec:
return rec
rec = env['encoach.entity'].search([('code', '=', code)], limit=1)
if rec:
return rec
return env['encoach.entity'].create({'name': name, 'code': code})
def _students():
Users = env['res.users'].sudo()
logins = ['sarah@encoach.test', 'omar@encoach.test',
'layla@encoach.test', 'testuser@writeflow.test']
out = []
for login in logins:
u = Users.search([('login', '=', login)], limit=1)
if u:
out.append(u)
return out
def _default_exam():
exam = env['encoach.exam.custom'].sudo().search([], order='id', limit=1)
if exam:
return exam
# Fallback: create a minimal exam shell if none exists.
return env['encoach.exam.custom'].sudo().create({
'title': 'Seeded Demo Exam',
'status': 'active',
})
BAND_MATRIX = [
# listening, reading, writing, speaking -> overall auto (avg)
(7.5, 8.0, 7.0, 7.5, 'C1'),
(6.0, 6.5, 5.5, 6.0, 'B2'),
(5.0, 5.5, 4.5, 5.0, 'B1'),
(4.0, 4.5, 4.0, 4.0, 'A2'),
(8.0, 8.5, 8.0, 8.5, 'C1'),
(6.5, 7.0, 6.5, 6.5, 'B2'),
]
def _avg(l, r, w, s):
return round((l + r + w + s) / 4.0, 1)
def seed():
acme = _or_create_entity('Acme Corp', 'ACME')
globe = _or_create_entity('Global Ltd', 'GLOBAL')
tech = _or_create_entity('Tech Co', 'TECHCO')
entities = [acme, globe, tech]
students = _students()
if not students:
print('[seed_reports] No students found — nothing to seed.')
return
exam = _default_exam()
print(f'[seed_reports] Using exam id={exam.id} title={exam.title!r}')
Att = env['encoach.student.attempt'].sudo()
# 1. Fill in bands for any existing in_progress attempts that have no scores.
stale = Att.search([('status', '=', 'in_progress')])
for idx, att in enumerate(stale):
vals = BAND_MATRIX[idx % len(BAND_MATRIX)]
l, r, w, s, cefr = vals
started = att.started_at or (datetime.now() - timedelta(days=5))
att.write({
'status': 'completed',
'listening_band': l,
'reading_band': r,
'writing_band': w,
'speaking_band': s,
'overall_band': _avg(l, r, w, s),
'cefr_level': cefr.lower().replace('-', '_'),
'completed_at': started + timedelta(minutes=120),
'entity_id': att.entity_id.id or random.choice(entities).id,
})
print(f'[seed_reports] Completed {len(stale)} existing in_progress attempts.')
# 2. Add historical attempts across the last 6 months so the trend chart
# has enough data points.
now = datetime.now()
created = 0
for month_ago in range(5, -1, -1):
ref = now - timedelta(days=30 * month_ago + random.randint(0, 15))
for i, student in enumerate(students):
# Skip ~30% of (student, month) pairs so the trend varies.
if random.random() < 0.3 and month_ago > 0:
continue
# Slight progression over time so trend line rises.
base = BAND_MATRIX[(i + month_ago) % len(BAND_MATRIX)]
l, r, w, s, cefr = base
delta = max(0.0, (6 - month_ago) * 0.2)
l = min(9.0, l + delta)
r = min(9.0, r + delta)
w = min(9.0, w + delta)
s = min(9.0, s + delta)
entity = entities[(i + month_ago) % len(entities)]
existing = Att.search([
('student_id', '=', student.id),
('exam_id', '=', exam.id),
('started_at', '>=', ref - timedelta(days=3)),
('started_at', '<=', ref + timedelta(days=3)),
], limit=1)
if existing:
continue
vals = {
'student_id': student.id,
'exam_id': exam.id,
'entity_id': entity.id,
'status': 'completed',
'started_at': ref,
'completed_at': ref + timedelta(minutes=100 + i * 15),
'listening_band': l,
'reading_band': r,
'writing_band': w,
'speaking_band': s,
'overall_band': _avg(l, r, w, s),
'cefr_level': cefr.lower().replace('-', '_'),
}
Att.create(vals)
created += 1
print(f'[seed_reports] Created {created} historical attempts.')
env.cr.commit()
total_reportable = Att.search_count([('status', 'in',
['completed', 'scored',
'released', 'scoring'])])
print(f'[seed_reports] OK. Reportable attempts now: {total_reportable}')
seed()

276
test_reports_flows.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""API smoke tests for the admin Reports section.
Covers the three pages in `AdminLmsLayout.tsx` under "Reports":
* /admin/student-performance -> /api/reports/student-performance
* /admin/stats-corporate -> /api/reports/stats-corporate
* /admin/record -> /api/reports/record
* (shared filter picker) -> /api/reports/filters
Usage:
python3 test_reports_flows.py
"""
import json
import sys
from urllib import request as urlreq
from urllib.error import HTTPError
BASE = "http://localhost:8069/api"
TOKEN = None
GREEN = "\033[32m"; RED = "\033[31m"; RESET = "\033[0m"; BOLD = "\033[1m"
results = []
def log(page, action, ok_, note=""):
results.append((page, action, ok_, note))
tag = f"{GREEN}PASS{RESET}" if ok_ else f"{RED}FAIL{RESET}"
print(f" [{tag}] {page:22s} {action:42s} {note}")
def _req(method, path, body=None, params=None):
url = BASE + path
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
url += ("?" + qs) if qs else ""
data = None
headers = {"Accept": "application/json"}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urlreq.Request(url, data=data, method=method, headers=headers)
try:
with urlreq.urlopen(req, timeout=20) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else {})
except HTTPError as e:
raw = e.read().decode() if e.fp else ""
try:
payload = json.loads(raw)
except Exception:
payload = {"error": raw[:200]}
return e.code, payload
except Exception as e:
return 0, {"error": str(e)}
def GET(path, **params): return _req("GET", path, params=params)
def POST(path, body=None): return _req("POST", path, body=body or {})
def ok(s): return 200 <= s < 300
def login():
global TOKEN
s, b = POST("/login", {"email": "admin", "password": "admin"})
TOKEN = (b or {}).get("token")
return bool(TOKEN)
# ── Filters picker ──────────────────────────────────────────────────────
def test_filters():
print(f"{BOLD}== /api/reports/filters =={RESET}")
s, b = GET("/reports/filters")
entities = (b or {}).get("entities") or []
students = (b or {}).get("students") or []
log("reports-filters", "GET", ok(s) and isinstance(entities, list) and isinstance(students, list),
f"status={s} entities={len(entities)} students={len(students)}")
return entities, students
# ── 1. Student Performance ─────────────────────────────────────────────
def test_student_performance(entities):
print(f"{BOLD}== /admin/student-performance =={RESET}")
s, b = GET("/reports/student-performance")
rows = (b or {}).get("items") or []
log("student-performance", "LIST returns rows",
ok(s) and isinstance(rows, list) and len(rows) > 0,
f"status={s} rows={len(rows)}")
if rows:
r0 = rows[0]
expected_keys = {"student_id", "student_name", "entity_name",
"reading", "listening", "writing", "speaking",
"overall", "level", "attempts_count"}
log("student-performance", "row shape complete",
expected_keys.issubset(r0.keys()),
f"missing={sorted(expected_keys - set(r0.keys()))}")
log("student-performance", "overall is numeric/None",
r0["overall"] is None or isinstance(r0["overall"], (int, float)),
f"overall={r0['overall']}")
log("student-performance", "CEFR level assigned",
r0["level"] in {"Pre-A1","A1","A2","B1","B2","C1","C2", None},
f"level={r0['level']}")
s2, b2 = GET("/reports/student-performance",
search=(r0["student_name"] or "").split()[0])
rows2 = (b2 or {}).get("items") or []
log("student-performance", "search filter",
ok(s2) and any(r["student_id"] == r0["student_id"] for r in rows2),
f"status={s2} matched={len(rows2)}")
if r0["level"]:
s3, b3 = GET("/reports/student-performance", level=r0["level"])
rows3 = (b3 or {}).get("items") or []
log("student-performance", "level filter",
ok(s3) and all(r["level"] == r0["level"] for r in rows3),
f"status={s3} filtered={len(rows3)}")
if entities:
eid = entities[0]["id"]
s4, b4 = GET("/reports/student-performance", entity_id=eid)
rows4 = (b4 or {}).get("items") or []
log("student-performance", "entity filter",
ok(s4) and all(r["entity_id"] in (None, eid) or r["entity_id"] == eid for r in rows4),
f"status={s4} entity_id={eid} rows={len(rows4)}")
# ── 2. Stats Corporate ─────────────────────────────────────────────────
def test_stats_corporate(entities):
print(f"{BOLD}== /admin/stats-corporate =={RESET}")
s, b = GET("/reports/stats-corporate")
by_module = (b or {}).get("by_module") or []
trend = (b or {}).get("trend") or []
dist = (b or {}).get("distribution") or []
comp = (b or {}).get("comparison") or []
meta = (b or {}).get("meta") or {}
log("stats-corporate", "LIST returns envelope",
ok(s) and all(k in (b or {}) for k in
("by_module", "trend", "distribution",
"comparison", "meta")),
f"status={s} keys={sorted((b or {}).keys()) if b else 'n/a'}")
log("stats-corporate", "by_module has 4 modules",
len(by_module) == 4 and {m["module"] for m in by_module} ==
{"Reading","Listening","Writing","Speaking"},
f"modules={[m['module'] for m in by_module]}")
log("stats-corporate", "trend length == 6 (default months)",
len(trend) == 6,
f"trend={len(trend)}")
log("stats-corporate", "distribution has CEFR levels",
isinstance(dist, list) and len(dist) > 0 and
all("name" in d and "value" in d and "color" in d for d in dist),
f"bins={len(dist)}")
log("stats-corporate", "comparison is entity-level",
isinstance(comp, list) and all("entity_name" in c for c in comp),
f"entities={len(comp)}")
log("stats-corporate", "meta.attempts_considered > 0",
meta.get("attempts_considered", 0) > 0,
f"meta={meta}")
# Threshold filter -> should narrow attempts considered.
s2, b2 = GET("/reports/stats-corporate", threshold=70)
log("stats-corporate", "threshold=70 filter",
ok(s2) and (b2 or {}).get("meta", {}).get("threshold") == 70.0,
f"status={s2} meta={(b2 or {}).get('meta')}")
# Entity filter
if entities:
eid = entities[0]["id"]
s3, b3 = GET("/reports/stats-corporate", entity_id=eid)
cmp3 = (b3 or {}).get("comparison") or []
log("stats-corporate", "entity filter narrows comparison",
ok(s3) and all(c["entity_id"] in (None, eid) or c["entity_id"] == eid
for c in cmp3),
f"status={s3} entities={len(cmp3)}")
# Months horizon
s4, b4 = GET("/reports/stats-corporate", months=3)
log("stats-corporate", "months=3 trend len",
ok(s4) and len((b4 or {}).get("trend") or []) == 3,
f"status={s4} trend={len((b4 or {}).get('trend') or [])}")
# ── 3. Record ──────────────────────────────────────────────────────────
def test_record(entities, students):
print(f"{BOLD}== /admin/record =={RESET}")
s, b = GET("/reports/record", size=10)
items = (b or {}).get("items") or []
log("record", "LIST (paginated)", ok(s) and isinstance(items, list),
f"status={s} got={len(items)} total={(b or {}).get('total')}")
if items:
r0 = items[0]
keys = {"id", "student_name", "assignment", "exam", "exam_code",
"date", "score", "duration_min", "status", "status_label"}
log("record", "row shape complete",
keys.issubset(r0.keys()),
f"missing={sorted(keys - set(r0.keys()))}")
log("record", "exam_code is EX-###",
r0["exam_code"].startswith("EX-") if r0["exam_code"] else True,
f"exam_code={r0['exam_code']}")
log("record", "status_label is titlecase",
isinstance(r0["status_label"], str) and r0["status_label"][:1].isupper(),
f"status_label={r0['status_label']}")
if students:
sid = students[0]["id"]
s2, b2 = GET("/reports/record", user_id=sid, size=50)
items2 = (b2 or {}).get("items") or []
log("record", "user_id filter",
ok(s2) and all(i["student_id"] == sid for i in items2),
f"status={s2} user_id={sid} rows={len(items2)}")
if entities:
eid = entities[0]["id"]
s3, b3 = GET("/reports/record", entity_id=eid, size=50)
items3 = (b3 or {}).get("items") or []
log("record", "entity_id filter",
ok(s3) and all(i["entity_id"] in (None, eid) or i["entity_id"] == eid
for i in items3),
f"status={s3} entity_id={eid} rows={len(items3)}")
# Period filter (month should return most data)
s4, b4 = GET("/reports/record", period="month", size=50)
log("record", "period=month filter",
ok(s4) and isinstance((b4 or {}).get("items"), list),
f"status={s4} rows={len((b4 or {}).get('items') or [])}")
# Pagination
s5, b5 = GET("/reports/record", size=5, page=1)
s6, b6 = GET("/reports/record", size=5, page=2)
log("record", "pagination size=5",
ok(s5) and ok(s6)
and len((b5 or {}).get("items") or []) <= 5
and len((b6 or {}).get("items") or []) <= 5,
f"p1={len((b5 or {}).get('items') or [])} p2={len((b6 or {}).get('items') or [])}")
# ── Main ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"{BOLD}Reports Section API Smoke Tests{RESET}")
if not login():
print(f"{RED}Login failed. Is Odoo running on :8069 with admin/admin?{RESET}")
sys.exit(1)
print(f" logged in, token={TOKEN[:12] if TOKEN else 'none'}...\n")
entities, students = test_filters()
print()
test_student_performance(entities)
print()
test_stats_corporate(entities)
print()
test_record(entities, students)
total = len(results)
passed = sum(1 for r in results if r[2])
failed = total - passed
print("\n" + "=" * 70)
print(f"{BOLD}Summary: {GREEN}{passed} passed{RESET}, "
f"{RED}{failed} failed{RESET}, {total} total")
if failed:
print(f"\n{RED}Failing:{RESET}")
for page, action, ok_, note in results:
if not ok_:
print(f" - {page:22s} {action:42s} {note}")
sys.exit(2)