fix(config): align Configuration pages with platform logic
Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.
FAQ
* Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
rejected the `both` and `entity` values emitted by the FAQ Manager
UI. Widened the Selection so the UI's full vocabulary round-trips.
* FAQ item `video_url` was already accepted by the UI but silently
dropped by the controller; now persisted via a new `video_url`
field on `encoach.faq.item` and serialized on list/get responses.
Notification Rules
* Added `days_before`, `frequency`, `channel`, `entity_id` to
`encoach.notification.rule`. These are the exact fields the admin
form collects; prior to this change they were serialized into the
JSON body, ignored by the controller, and omitted from responses,
leaving the Active switch permanently off and the table columns
blank.
* Controller now translates `active` <-> `is_active` both ways so the
new frontend contract and legacy callers coexist. Missing required
fields return HTTP 400 instead of 500.
Approval Workflows
* The controller had been hand-rolling raw SQL against three tables
(`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
model declared, so the tables never existed. List() was guarded and
returned empty; create() would 500 with "relation does not exist".
* Introduced real ORM models in `encoach_exam_template/models/approval.py`
plus access rights, which auto-provision the tables on -u.
* Rewrote the controller to use ORM, added PATCH, and emitted both
`items` and `results` in the list envelope so the frontend's
PaginatedResponse reader and legacy callers both work. Step
payloads now carry `max_days`, `auto_escalate`, and
`notification_email` end to end.
* Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
send the full stage shape + `allow_bypass`, tolerate both envelope
keys, and validate at least one approver before submit.
Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.
Made-with: Cursor
This commit is contained in:
336
test_config_flows.py
Normal file
336
test_config_flows.py
Normal file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""API smoke tests for the admin Configuration section.
|
||||
|
||||
Covers the three pages in `AdminLmsLayout.tsx` under "Configuration":
|
||||
|
||||
* /admin/faq -> /api/faq/{categories,items}
|
||||
* /admin/notification-rules -> /api/notification-rules
|
||||
* /admin/approval-config -> /api/approval-workflows
|
||||
|
||||
Usage:
|
||||
python3 test_config_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:42s} {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 login():
|
||||
global TOKEN
|
||||
s, b = POST("/login", {"email": "admin", "password": "admin"})
|
||||
TOKEN = (b or {}).get("token")
|
||||
return bool(TOKEN)
|
||||
|
||||
|
||||
# ── 1. FAQ ───────────────────────────────────────────────────────────────
|
||||
def test_faq():
|
||||
print(f"{BOLD}== /admin/faq =={RESET}")
|
||||
ts = int(time.time())
|
||||
|
||||
# LIST categories
|
||||
s, b = GET("/faq/categories")
|
||||
log("faq", "LIST categories", ok(s) and isinstance(b, list),
|
||||
f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}")
|
||||
|
||||
# CREATE category with audience='both' (frontend-only value)
|
||||
cat_name = f"Cfg Test Cat {ts}"
|
||||
s, b = POST("/faq/categories", {"name": cat_name, "audience": "both"})
|
||||
cat_id = (b or {}).get("id")
|
||||
log("faq", "CREATE category audience=both", ok(s) and cat_id and b.get("audience") == "both",
|
||||
f"status={s} id={cat_id} audience={(b or {}).get('audience')}")
|
||||
|
||||
# CREATE category with audience='entity'
|
||||
s2, b2 = POST("/faq/categories", {"name": f"Cfg Entity {ts}", "audience": "entity"})
|
||||
entity_cat_id = (b2 or {}).get("id")
|
||||
log("faq", "CREATE category audience=entity", ok(s2) and entity_cat_id and b2.get("audience") == "entity",
|
||||
f"status={s2} id={entity_cat_id} audience={(b2 or {}).get('audience')}")
|
||||
|
||||
# EDIT category
|
||||
if cat_id:
|
||||
s, b = PATCH(f"/faq/categories/{cat_id}", {"name": cat_name + " (edited)"})
|
||||
log("faq", "EDIT category name", ok(s) and "edited" in (b or {}).get("name", ""),
|
||||
f"status={s} name={(b or {}).get('name')}")
|
||||
|
||||
# CREATE item with video_url (regression: previously dropped)
|
||||
if cat_id:
|
||||
s, b = POST("/faq/items", {
|
||||
"question": f"Q? {ts}",
|
||||
"answer": "Because reasons.",
|
||||
"category_id": cat_id,
|
||||
"audience": "both",
|
||||
"video_url": "https://example.com/walkthrough.mp4",
|
||||
})
|
||||
item_id = (b or {}).get("id")
|
||||
log("faq", "CREATE item audience=both +video",
|
||||
ok(s) and item_id and b.get("video_url") == "https://example.com/walkthrough.mp4",
|
||||
f"status={s} id={item_id} video_url={(b or {}).get('video_url')}")
|
||||
else:
|
||||
item_id = None
|
||||
|
||||
# LIST items filtered by category
|
||||
if cat_id:
|
||||
s, b = GET("/faq/items", category_id=cat_id)
|
||||
log("faq", "LIST items filter category_id",
|
||||
ok(s) and isinstance(b, list) and any(it["id"] == item_id for it in b),
|
||||
f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}")
|
||||
|
||||
# EDIT item: change audience 'both' -> 'student'
|
||||
if item_id:
|
||||
s, b = PATCH(f"/faq/items/{item_id}", {"audience": "student"})
|
||||
log("faq", "EDIT item audience both->student",
|
||||
ok(s) and b.get("audience") == "student",
|
||||
f"status={s} audience={(b or {}).get('audience')}")
|
||||
|
||||
# DELETE item
|
||||
if item_id:
|
||||
s, _ = DELETE(f"/faq/items/{item_id}")
|
||||
log("faq", "DELETE item", ok(s), f"status={s}")
|
||||
|
||||
# DELETE categories (cleanup)
|
||||
for cid, label in [(cat_id, "cat"), (entity_cat_id, "entity cat")]:
|
||||
if cid:
|
||||
s, _ = DELETE(f"/faq/categories/{cid}")
|
||||
log("faq", f"DELETE {label}", ok(s), f"status={s} id={cid}")
|
||||
|
||||
|
||||
# ── 2. Notification Rules ───────────────────────────────────────────────
|
||||
def test_notification_rules():
|
||||
print(f"{BOLD}== /admin/notification-rules =={RESET}")
|
||||
ts = int(time.time())
|
||||
|
||||
# LIST
|
||||
s, b = GET("/notification-rules")
|
||||
log("notif-rules", "LIST", ok(s) and isinstance(b, list),
|
||||
f"status={s} count={len(b) if isinstance(b, list) else 'n/a'}")
|
||||
|
||||
# CREATE rule with new fields (days_before, frequency, channel, active)
|
||||
payload = {
|
||||
"name": f"Deadline reminder {ts}",
|
||||
"event_type": "deadline",
|
||||
"days_before": 3,
|
||||
"frequency": "daily",
|
||||
"channel": "email",
|
||||
}
|
||||
s, b = POST("/notification-rules", payload)
|
||||
rid = (b or {}).get("id")
|
||||
correct = (
|
||||
ok(s) and rid
|
||||
and b.get("days_before") == 3
|
||||
and b.get("frequency") == "daily"
|
||||
and b.get("channel") == "email"
|
||||
and b.get("active") is True # default from is_active
|
||||
)
|
||||
log("notif-rules", "CREATE with new fields", correct,
|
||||
f"status={s} id={rid} days={(b or {}).get('days_before')} "
|
||||
f"freq={(b or {}).get('frequency')} ch={(b or {}).get('channel')} "
|
||||
f"active={(b or {}).get('active')}")
|
||||
|
||||
# EDIT: send `active: False` (frontend-speak) and expect `is_active` to flip
|
||||
if rid:
|
||||
s, b = PATCH(f"/notification-rules/{rid}", {"active": False, "days_before": 7})
|
||||
log("notif-rules", "EDIT active=false + days_before=7",
|
||||
ok(s) and b.get("active") is False and b.get("is_active") is False
|
||||
and b.get("days_before") == 7,
|
||||
f"status={s} active={(b or {}).get('active')} "
|
||||
f"is_active={(b or {}).get('is_active')} days={(b or {}).get('days_before')}")
|
||||
|
||||
# EDIT: change frequency + channel
|
||||
if rid:
|
||||
s, b = PATCH(f"/notification-rules/{rid}", {"frequency": "once", "channel": "both"})
|
||||
log("notif-rules", "EDIT frequency=once channel=both",
|
||||
ok(s) and b.get("frequency") == "once" and b.get("channel") == "both",
|
||||
f"status={s} freq={(b or {}).get('frequency')} ch={(b or {}).get('channel')}")
|
||||
|
||||
# CREATE without required field -> 400
|
||||
s, _ = POST("/notification-rules", {"event_type": "exam"})
|
||||
log("notif-rules", "CREATE rejects missing name", s == 400, f"status={s}")
|
||||
|
||||
# DELETE
|
||||
if rid:
|
||||
s, _ = DELETE(f"/notification-rules/{rid}")
|
||||
log("notif-rules", "DELETE", ok(s), f"status={s} id={rid}")
|
||||
|
||||
|
||||
# ── 3. Approval Workflows ───────────────────────────────────────────────
|
||||
def test_approval_workflows():
|
||||
print(f"{BOLD}== /admin/approval-config =={RESET}")
|
||||
ts = int(time.time())
|
||||
|
||||
# LIST (previously would have emitted {items:[], total:0} silently; now
|
||||
# confirm it also emits `results` for the frontend PaginatedResponse shape)
|
||||
s, b = GET("/approval-workflows")
|
||||
has_envelope = (
|
||||
isinstance(b, dict)
|
||||
and "items" in b and "results" in b and "total" in b
|
||||
)
|
||||
log("approvals", "LIST envelope items+results+total",
|
||||
ok(s) and has_envelope,
|
||||
f"status={s} keys={sorted((b or {}).keys()) if isinstance(b, dict) else 'n/a'}")
|
||||
|
||||
# Need an approver (any admin user). Ask the approval-users endpoint.
|
||||
s_u, b_u = GET("/approval-users")
|
||||
users = (b_u or {}).get("items") or []
|
||||
if not users:
|
||||
log("approvals", "LIST approval-users", False, f"status={s_u} (no users)")
|
||||
return
|
||||
approver = users[0]
|
||||
log("approvals", "LIST approval-users", True,
|
||||
f"status={s_u} first={approver.get('name')} id={approver.get('id')}")
|
||||
|
||||
# CREATE workflow with full stage fields
|
||||
payload = {
|
||||
"name": f"Leave Approval {ts}",
|
||||
"type": "leave",
|
||||
"status": "draft",
|
||||
"allow_bypass": True,
|
||||
"bypass_enabled": True,
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"approver_id": approver["id"],
|
||||
"approver_name": approver["name"],
|
||||
"max_days": 2,
|
||||
"auto_escalate": True,
|
||||
"notification_email": "esc@example.com",
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"approver_id": approver["id"],
|
||||
"approver_name": approver["name"],
|
||||
"max_days": 5,
|
||||
"auto_escalate": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
s, b = POST("/approval-workflows", payload)
|
||||
wf_id = (b or {}).get("id")
|
||||
stages = (b or {}).get("steps") or []
|
||||
correct = (
|
||||
ok(s) and wf_id
|
||||
and b.get("allow_bypass") is True
|
||||
and len(stages) == 2
|
||||
and stages[0].get("max_days") == 2
|
||||
and stages[0].get("auto_escalate") is True
|
||||
and stages[0].get("notification_email") == "esc@example.com"
|
||||
)
|
||||
log("approvals", "CREATE with 2 stages + bypass", correct,
|
||||
f"status={s} id={wf_id} stages={len(stages)} "
|
||||
f"bypass={(b or {}).get('allow_bypass')}")
|
||||
|
||||
# GET single
|
||||
if wf_id:
|
||||
s, b = GET(f"/approval-workflows/{wf_id}")
|
||||
log("approvals", "GET detail", ok(s) and b.get("id") == wf_id,
|
||||
f"status={s}")
|
||||
|
||||
# PATCH status draft -> active (used by the toggle switch)
|
||||
if wf_id:
|
||||
s, b = PATCH(f"/approval-workflows/{wf_id}", {"status": "active"})
|
||||
log("approvals", "PATCH status draft->active",
|
||||
ok(s) and b.get("status") == "active",
|
||||
f"status={s} new_status={(b or {}).get('status')}")
|
||||
|
||||
# PATCH stages replacement
|
||||
if wf_id:
|
||||
s, b = PATCH(f"/approval-workflows/{wf_id}", {
|
||||
"steps": [{
|
||||
"order": 1,
|
||||
"approver_id": approver["id"],
|
||||
"approver_name": approver["name"],
|
||||
"max_days": 10,
|
||||
"auto_escalate": False,
|
||||
}],
|
||||
})
|
||||
new_stages = (b or {}).get("steps") or []
|
||||
log("approvals", "PATCH replace stages",
|
||||
ok(s) and len(new_stages) == 1 and new_stages[0].get("max_days") == 10,
|
||||
f"status={s} stages={len(new_stages)}")
|
||||
|
||||
# CREATE rejects missing name
|
||||
s, _ = POST("/approval-workflows", {"type": "custom"})
|
||||
log("approvals", "CREATE rejects missing name", s == 400, f"status={s}")
|
||||
|
||||
# DELETE
|
||||
if wf_id:
|
||||
s, _ = DELETE(f"/approval-workflows/{wf_id}")
|
||||
log("approvals", "DELETE", ok(s), f"status={s} id={wf_id}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
print(f"{BOLD}Configuration Section API Smoke Tests{RESET}")
|
||||
if not login():
|
||||
print(f"{RED}Login failed. Is Odoo running on :8069 with admin/admin?{RESET}")
|
||||
sys.exit(1)
|
||||
print(f" logged in, token={TOKEN[:12] if TOKEN else 'none'}...\n")
|
||||
|
||||
test_faq()
|
||||
print()
|
||||
test_notification_rules()
|
||||
print()
|
||||
test_approval_workflows()
|
||||
|
||||
total = len(results)
|
||||
passed = sum(1 for r in results if r[2])
|
||||
failed = total - passed
|
||||
print("\n" + "=" * 70)
|
||||
print(f"{BOLD}Summary: {GREEN}{passed} passed{RESET}, "
|
||||
f"{RED}{failed} failed{RESET}, {total} total")
|
||||
if failed:
|
||||
print(f"\n{RED}Failing:{RESET}")
|
||||
for page, action, ok_, note in results:
|
||||
if not ok_:
|
||||
print(f" - {page:22s} {action:42s} {note}")
|
||||
sys.exit(2)
|
||||
Reference in New Issue
Block a user