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

334
test_support_flows.py Normal file
View File

@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
API-level write-flow sweep for the admin Support section.
Exercises CREATE / EDIT / DELETE / FILTER for every support endpoint
(tickets, codes, packages, grading-config, payment-records).
Usage:
python3 test_support_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:22s} {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("data") or payload.get("items") or []
def extract_id(payload):
if isinstance(payload, dict):
if payload.get("id"):
return payload["id"]
data = payload.get("data")
if isinstance(data, dict) and data.get("id"):
return data["id"]
if isinstance(data, list) and data and isinstance(data[0], dict) and data[0].get("id"):
return data[0]["id"]
return None
def login():
global TOKEN
s, b = POST("/login", {"email": "admin", "password": "admin"})
TOKEN = (b or {}).get("token")
return bool(TOKEN)
# ── 1. Tickets ───────────────────────────────────────────────────────────
def test_tickets():
print(f"{BOLD}== /admin/tickets =={RESET}")
# LIST
s, b = GET("/tickets")
log("tickets", "LIST seeded", ok(s) and len(items(b)) >= 4, f"status={s} count={len(items(b))}")
# LIST with filter: status=open
s, b = GET("/tickets", status="open")
only_open = all(t["status"] == "open" for t in items(b))
log("tickets", "LIST filter status=open", ok(s) and only_open,
f"status={s} count={len(items(b))} all_open={only_open}")
# LIST with filter: type=bug
s, b = GET("/tickets", type="bug")
only_bug = all(t["type"] == "bug" for t in items(b))
log("tickets", "LIST filter type=bug", ok(s) and only_bug,
f"status={s} count={len(items(b))} all_bug={only_bug}")
# LIST with search
s, b = GET("/tickets", search="login")
has_login = any("login" in (t["subject"] + t["description"]).lower() for t in items(b))
log("tickets", "LIST search='login'", ok(s) and has_login,
f"status={s} count={len(items(b))}")
# CREATE
subj = f"Test ticket {int(time.time()*1000)}"
s, b = POST("/tickets", {
"subject": subj,
"description": "Raised by API smoke test.",
"type": "bug",
"source": "platform",
"page_context": "/admin/tickets",
})
tid = extract_id(b)
log("tickets", "CREATE", ok(s) and tid, f"status={s} id={tid}")
if not tid:
return
# GET detail
s, b = GET(f"/tickets/{tid}")
log("tickets", "GET detail",
ok(s) and b.get("subject") == subj,
f"status={s} subject={b.get('subject')!r}")
# EDIT -> in_progress
s, b = PATCH(f"/tickets/{tid}", {"status": "in_progress"})
log("tickets", "EDIT status -> in_progress",
ok(s) and b.get("status") == "in_progress",
f"status={s} state={b.get('status')}")
# RESOLVE -> stamps resolved_at
s, b = PATCH(f"/tickets/{tid}", {"status": "resolved"})
log("tickets", "EDIT status -> resolved (stamps resolved_at)",
ok(s) and b.get("status") == "resolved" and bool(b.get("resolved_at")),
f"status={s} resolved_at={b.get('resolved_at')[:19] if b.get('resolved_at') else ''}")
# ASSIGN to admin (user 2)
s, b = PATCH(f"/tickets/{tid}", {"assignee_id": 2})
log("tickets", "ASSIGN", ok(s) and b.get("assignee_id") == 2,
f"status={s} assignee_id={b.get('assignee_id')}")
# assignedToUser lists the ticket
s, b = GET("/tickets/assignedToUser")
mine_ids = {t["id"] for t in items(b)}
log("tickets", "assignedToUser", ok(s) and tid in mine_ids,
f"status={s} count={len(mine_ids)}")
# DELETE
s, _ = DELETE(f"/tickets/{tid}")
log("tickets", "DELETE", ok(s), f"status={s}")
# ── 2. Registration Codes ────────────────────────────────────────────────
def test_codes():
print(f"{BOLD}== /admin/settings-platform (Codes) =={RESET}")
s, b = GET("/codes")
seed_count = len(items(b))
log("codes", "LIST seeded", ok(s) and seed_count >= 4,
f"status={s} count={seed_count}")
# Generate 3 individual codes
s, b = POST("/codes/generate", {
"count": 3,
"code_type": "individual",
"user_type": "student",
"max_uses": 1,
})
created = (b or {}).get("data") or []
log("codes", "GENERATE individual x3",
ok(s) and len(created) == 3,
f"status={s} created={len(created)}")
# Filter used=false
s, b = GET("/codes", used="false")
all_avail = all(not c["used"] for c in items(b))
log("codes", "LIST filter used=false", ok(s) and all_avail,
f"status={s} count={len(items(b))} all_available={all_avail}")
# DELETE one of the created codes
if created:
cid = created[0]["id"]
s, _ = DELETE(f"/codes/{cid}")
log("codes", "DELETE", ok(s), f"status={s} id={cid}")
# Generate corporate batch
s, b = POST("/codes/generate", {
"count": 2, "code_type": "corporate",
"user_type": "corporate", "max_uses": 50,
})
created2 = (b or {}).get("data") or []
log("codes", "GENERATE corporate x2",
ok(s) and len(created2) == 2,
f"status={s} created={len(created2)}")
# ── 3. Packages ──────────────────────────────────────────────────────────
def test_packages():
print(f"{BOLD}== /admin/settings-platform (Packages) =={RESET}")
s, b = GET("/packages")
base_count = len(items(b))
log("packages", "LIST defaults",
ok(s) and base_count >= 3,
f"status={s} count={base_count}")
# CREATE
name = f"Test Pack {int(time.time()*1000)}"
s, b = POST("/packages", {
"name": name, "price": 499, "duration": "6 months", "discount": 10,
})
pid = (b or {}).get("id") or extract_id(b)
log("packages", "CREATE",
ok(s) and pid is not None,
f"status={s} id={pid} name={name}")
if not pid:
return
# EDIT
s, b = PATCH(f"/packages/{pid}", {"price": 599, "discount": 20})
log("packages", "EDIT price+discount",
ok(s) and float(b.get("price", 0)) == 599.0 and float(b.get("discount", 0)) == 20.0,
f"status={s} price={b.get('price')} discount={b.get('discount')}")
# DELETE
s, _ = DELETE(f"/packages/{pid}")
log("packages", "DELETE", ok(s), f"status={s}")
# Verify restored to base_count
s, b = GET("/packages")
log("packages", "Persistence roundtrip",
ok(s) and len(items(b)) == base_count,
f"status={s} count={len(items(b))}")
# ── 4. Grading config ────────────────────────────────────────────────────
def test_grading_config():
print(f"{BOLD}== /admin/settings-platform (Grading) =={RESET}")
s, b = GET("/grading-config")
log("grading-config", "GET",
ok(s) and {"min_score", "max_score", "increment"} <= set(b.keys()),
f"status={s} {b.get('min_score')}{b.get('max_score')} step={b.get('increment')}")
# Set a new scale
s, b = PATCH("/grading-config",
{"min_score": 0, "max_score": 100, "increment": 1.0})
log("grading-config", "SET 0..100 step 1.0",
ok(s) and b.get("max_score") == 100,
f"status={s} max={b.get('max_score')}")
# Validation: min must be < max
s, b = PATCH("/grading-config", {"min_score": 10, "max_score": 5})
log("grading-config", "VALIDATION rejects min>=max",
s == 400, f"status={s}")
# Restore IELTS default
s, b = PATCH("/grading-config", {"min_score": 0, "max_score": 9, "increment": 0.5})
log("grading-config", "RESTORE IELTS default",
ok(s) and b.get("max_score") == 9 and float(b.get("increment") or 0) == 0.5,
f"status={s}")
# ── 5. Payment records ───────────────────────────────────────────────────
def test_payments():
print(f"{BOLD}== /admin/payment-record =={RESET}")
s, b = GET("/payment-records")
it = items(b)
totals = (b or {}).get("totals", {})
log("payment-records", "LIST",
ok(s) and len(it) >= 1,
f"status={s} count={len(it)} paid={totals.get('paid')} unpaid={totals.get('unpaid')}")
# Filter unpaid
s, b = GET("/payment-records", paid="false")
all_unpaid = all(not p["paid"] for p in items(b))
log("payment-records", "FILTER paid=false",
ok(s) and all_unpaid, f"status={s} count={len(items(b))}")
# Invoices list
s, b = GET("/payment-records/invoices")
log("payment-records", "LIST invoices (account.move)",
ok(s),
f"status={s} count={len(items(b))}")
# Paymob stub
s, b = GET("/paymob-orders")
log("paymob-orders", "STUB returns empty",
ok(s) and items(b) == [],
f"status={s} message={b.get('message')!r}")
# ── Main ─────────────────────────────────────────────────────────────────
def main():
if not login():
print("Login failed — is Odoo running at :8069?")
sys.exit(1)
test_tickets()
test_codes()
test_packages()
test_grading_config()
test_payments()
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:22s} {action:38s} {note}")
sys.exit(1)
if __name__ == "__main__":
main()