#!/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()