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:
Yamen Ahmad
2026-04-19 10:58:43 +04:00
parent 7f23127e44
commit d940db075e
11 changed files with 841 additions and 259 deletions

336
test_config_flows.py Normal file
View 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)