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