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
335 lines
12 KiB
Python
335 lines
12 KiB
Python
#!/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()
|