feat: institutional + support + training admin sections (backend + frontend)

Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent 50f58dc995
commit 98b9837a54
141 changed files with 20235 additions and 1453 deletions

305
test_training_flows.py Normal file
View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
API-level write-flow sweep for the admin Training section
(`/admin/training/vocabulary`, `/admin/training/grammar`).
Exercises CREATE / EDIT / DELETE / FILTER / SEARCH / PROGRESS on both models.
Usage:
python3 test_training_flows.py
"""
import json
import sys
import time
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:26s} {action:38s} {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 PATCH(path, body=None): return _req("PATCH", path, body=body or {})
def DELETE(path): return _req("DELETE", path)
def ok(s): return 200 <= s < 300
def items(payload):
return payload.get("items") or payload.get("data") or []
def extract_id(payload):
if isinstance(payload, dict):
if payload.get("id"):
return payload["id"]
d = payload.get("data")
if isinstance(d, dict) and d.get("id"):
return d["id"]
return None
def login():
global TOKEN
s, b = POST("/login", {"email": "admin", "password": "admin"})
TOKEN = (b or {}).get("token")
return bool(TOKEN)
# ── Vocabulary ───────────────────────────────────────────────────────────
def test_vocab():
print(f"{BOLD}== /admin/training/vocabulary =={RESET}")
s, b = GET("/training/vocabulary")
summary = (b or {}).get("summary") or {}
log("vocabulary", "LIST seeded",
ok(s) and len(items(b)) >= 10,
f"status={s} total={summary.get('total')} completed={summary.get('completed')} rate={summary.get('completion_rate')}%")
# Filter by level
s, b = GET("/training/vocabulary", level="B2")
all_b2 = all(v["level"] == "B2" for v in items(b))
log("vocabulary", "LIST filter level=B2",
ok(s) and all_b2 and len(items(b)) >= 1,
f"status={s} count={len(items(b))} all_B2={all_b2}")
# Search
s, b = GET("/training/vocabulary", search="eloqu")
has_eloquent = any("eloquent" in v["word"].lower() for v in items(b))
log("vocabulary", "LIST search='eloqu'",
ok(s) and has_eloquent,
f"status={s} count={len(items(b))}")
# CREATE
word = f"Testword{int(time.time()*1000) % 100000}"
s, b = POST("/training/vocabulary", {
"word": word,
"meaning": "A word created by the API smoke test.",
"example_sentence": f"This is a {word} in a sentence.",
"level": "C1",
"part_of_speech": "noun",
"category": "test",
})
vid = extract_id(b)
log("vocabulary", "CREATE", ok(s) and vid,
f"status={s} id={vid} word={word}")
if not vid:
return
# Uniqueness — creating the same word should fail
s, _ = POST("/training/vocabulary", {
"word": word, "meaning": "duplicate attempt", "level": "C1",
})
log("vocabulary", "CREATE duplicate word rejected",
s >= 400, f"status={s}")
# Required fields — missing meaning
s, _ = POST("/training/vocabulary", {"word": f"{word}-no-meaning"})
log("vocabulary", "CREATE missing meaning rejected",
s == 400, f"status={s}")
# GET detail
s, b = GET(f"/training/vocabulary/{vid}")
log("vocabulary", "GET detail",
ok(s) and b.get("word") == word,
f"status={s} word={b.get('word')!r}")
# EDIT level + meaning
s, b = PATCH(f"/training/vocabulary/{vid}", {
"level": "B2",
"meaning": "An updated definition by the test.",
})
log("vocabulary", "EDIT level+meaning",
ok(s) and b.get("level") == "B2" and "updated definition" in (b.get("meaning") or ""),
f"status={s} level={b.get('level')}")
# PROGRESS — mark completed + mastered
s, b = POST(f"/training/vocabulary/{vid}/progress",
{"completed": True, "mastery": "mastered"})
log("vocabulary", "PROGRESS mark completed",
ok(s) and b.get("completed") is True and b.get("mastery") == "mastered",
f"status={s} completed={b.get('completed')}")
# Verify summary reflects the completion after the new row was added
s, b = GET("/training/vocabulary")
summary2 = (b or {}).get("summary") or {}
log("vocabulary", "SUMMARY reflects new completion",
ok(s) and summary2.get("completed", 0) >= (summary.get("completed", 0) + 1),
f"status={s} completed={summary2.get('completed')}")
# PROGRESS — un-mark
s, b = POST(f"/training/vocabulary/{vid}/progress", {"completed": False})
log("vocabulary", "PROGRESS un-mark",
ok(s) and b.get("completed") is False,
f"status={s} completed={b.get('completed')}")
# DELETE
s, _ = DELETE(f"/training/vocabulary/{vid}")
log("vocabulary", "DELETE", ok(s), f"status={s}")
# GET after delete should 404
s, _ = GET(f"/training/vocabulary/{vid}")
log("vocabulary", "GET after DELETE returns 404",
s == 404, f"status={s}")
# ── Grammar ──────────────────────────────────────────────────────────────
def test_grammar():
print(f"{BOLD}== /admin/training/grammar =={RESET}")
s, b = GET("/training/grammar")
summary = (b or {}).get("summary") or {}
log("grammar", "LIST seeded",
ok(s) and len(items(b)) >= 8,
f"status={s} total={summary.get('total')} completed={summary.get('completed')} rate={summary.get('completion_rate')}%")
# Filter + search
s, b = GET("/training/grammar", level="B1")
all_b1 = all(r["level"] == "B1" for r in items(b))
log("grammar", "LIST filter level=B1",
ok(s) and all_b1 and len(items(b)) >= 1,
f"status={s} count={len(items(b))} all_B1={all_b1}")
s, b = GET("/training/grammar", search="passive")
has_passive = any("passive" in r["name"].lower() for r in items(b))
log("grammar", "LIST search='passive'",
ok(s) and has_passive,
f"status={s} count={len(items(b))}")
# CREATE
name = f"Test rule {int(time.time()*1000) % 100000}"
s, b = POST("/training/grammar", {
"name": name,
"description": "A rule created by the API smoke test.",
"example": "This is an example.",
"level": "C1",
"category": "test",
})
rid = extract_id(b)
log("grammar", "CREATE",
ok(s) and rid,
f"status={s} id={rid}")
if not rid:
return
# Required fields
s, _ = POST("/training/grammar", {"name": f"{name}-no-desc"})
log("grammar", "CREATE missing description rejected",
s == 400, f"status={s}")
# GET detail
s, b = GET(f"/training/grammar/{rid}")
log("grammar", "GET detail",
ok(s) and b.get("name") == name,
f"status={s} name={b.get('name')!r}")
# EDIT
s, b = PATCH(f"/training/grammar/{rid}", {
"level": "B2",
"category": "updated",
})
log("grammar", "EDIT level+category",
ok(s) and b.get("level") == "B2" and b.get("category") == "updated",
f"status={s} level={b.get('level')} category={b.get('category')}")
# PROGRESS
s, b = POST(f"/training/grammar/{rid}/progress", {"completed": True})
log("grammar", "PROGRESS mark completed",
ok(s) and b.get("completed") is True,
f"status={s}")
s, b = POST(f"/training/grammar/{rid}/progress", {"completed": False})
log("grammar", "PROGRESS un-mark",
ok(s) and b.get("completed") is False,
f"status={s}")
# DELETE
s, _ = DELETE(f"/training/grammar/{rid}")
log("grammar", "DELETE", ok(s), f"status={s}")
s, _ = GET(f"/training/grammar/{rid}")
log("grammar", "GET after DELETE returns 404",
s == 404, f"status={s}")
# ── Cross-cutting: pagination sanity ─────────────────────────────────────
def test_pagination():
print(f"{BOLD}== Pagination / envelope =={RESET}")
s, b = GET("/training/vocabulary", size=3, page=1)
log("pagination", "vocab size=3 page=1",
ok(s) and len(items(b)) <= 3 and b.get("size") == 3 and b.get("page") == 1,
f"status={s} got={len(items(b))} size={b.get('size')} page={b.get('page')}")
s, b = GET("/training/grammar", size=3, page=2)
log("pagination", "grammar size=3 page=2",
ok(s) and b.get("page") == 2,
f"status={s} got={len(items(b))} page={b.get('page')}")
# ── Main ─────────────────────────────────────────────────────────────────
def main():
if not login():
print("Login failed — is Odoo running at :8069?")
sys.exit(1)
test_vocab()
test_grammar()
test_pagination()
print()
total = len(results)
passed = sum(1 for _, _, ok_, _ in results if ok_)
failed = total - passed
color = GREEN if failed == 0 else RED
print("=" * 70)
print(f"{BOLD}SUMMARY {color}PASS={passed}{RESET}{BOLD} "
f"{RED if failed else GREEN}FAIL={failed}{RESET}{BOLD} TOTAL={total}{RESET}")
print("=" * 70)
if failed:
print(f"\n{BOLD}FAILURES{RESET}")
for page, action, ok_, note in results:
if not ok_:
print(f" {RED}×{RESET} {page:26s} {action:38s} {note}")
sys.exit(1)
if __name__ == "__main__":
main()