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:
167
seed_reports.py
Normal file
167
seed_reports.py
Normal 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()
|
||||
Reference in New Issue
Block a user