Files
full_encoach_platform/test_support_flows.py
Yamen Ahmad 98b9837a54 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
2026-04-19 03:13:23 +04:00

335 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()