Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.
Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.
Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.
Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.
Made-with: Cursor
266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""OpenAPI 3.0 + Prometheus-ish metrics exporter.
|
|
|
|
Both endpoints are **unauthenticated** by design so they can be scraped by
|
|
third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing
|
|
to solve a JWT exchange first. The OpenAPI spec is generated by introspecting
|
|
every class decorated with :func:`odoo.http.route` and therefore stays in sync
|
|
with the running server automatically.
|
|
|
|
Scope: the generator emits a minimal but valid OpenAPI document — routes,
|
|
HTTP methods, path params, tag derived from the route prefix, and a shared
|
|
``BearerAuth`` security scheme. Request / response schemas are left open
|
|
(``{"type": "object"}``) since we don't have pydantic models yet; a future
|
|
iteration can enrich individual routes via ``route_decorated_method.openapi``
|
|
annotations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from collections import Counter, defaultdict
|
|
|
|
from odoo import http
|
|
from odoo.http import Controller, Response, request
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_metrics_lock = None
|
|
_metrics: dict = {
|
|
"started_at": time.time(),
|
|
"routes": Counter(), # key -> hit count
|
|
"statuses": Counter(), # status bucket -> hit count
|
|
"latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128)
|
|
}
|
|
_LATENCY_SAMPLE_CAP = 128
|
|
_PATH_PARAM_RE = re.compile(r"<(?:(?P<conv>\w+):)?(?P<name>\w+)>")
|
|
|
|
|
|
def record_request(route: str, status: int, duration_ms: int) -> None:
|
|
"""Lightweight in-process request counter used by the metrics endpoint.
|
|
|
|
Intentionally thread-unsafe for minimum overhead; worst case is a slightly
|
|
off counter on concurrent writes which is fine for ops dashboards. Switch
|
|
to a proper Prometheus client (``prometheus_client``) in P2.3 when needed.
|
|
"""
|
|
try:
|
|
_metrics["routes"][route] += 1
|
|
bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx"
|
|
_metrics["statuses"][bucket] += 1
|
|
samples = _metrics["latencies_ms"][route]
|
|
samples.append(int(duration_ms))
|
|
if len(samples) > _LATENCY_SAMPLE_CAP:
|
|
del samples[0:len(samples) - _LATENCY_SAMPLE_CAP]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _convert_odoo_path(path: str) -> str:
|
|
"""Translate Odoo's ``<int:foo>`` placeholder syntax to OpenAPI ``{foo}``."""
|
|
def repl(m: re.Match) -> str:
|
|
return "{" + m.group("name") + "}"
|
|
return _PATH_PARAM_RE.sub(repl, path)
|
|
|
|
|
|
def _path_parameters(path: str) -> list[dict]:
|
|
params = []
|
|
for m in _PATH_PARAM_RE.finditer(path):
|
|
conv = (m.group("conv") or "string").lower()
|
|
if conv in ("int", "integer"):
|
|
schema = {"type": "integer"}
|
|
elif conv in ("float", "number"):
|
|
schema = {"type": "number"}
|
|
else:
|
|
schema = {"type": "string"}
|
|
params.append({
|
|
"name": m.group("name"),
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": schema,
|
|
})
|
|
return params
|
|
|
|
|
|
def _iter_routes():
|
|
"""Yield ``(route_str, method_name, tag, class_name, doc)`` for every
|
|
``@http.route`` decorated callable currently registered.
|
|
|
|
Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the
|
|
dispatcher actually routes to — including extension classes.
|
|
"""
|
|
seen = set()
|
|
try:
|
|
routing_map = request.env["ir.http"].routing_map()
|
|
except Exception:
|
|
_logger.exception("could not fetch routing_map")
|
|
return
|
|
|
|
try:
|
|
rules = list(routing_map.iter_rules())
|
|
except Exception:
|
|
_logger.exception("routing_map has no iter_rules")
|
|
return
|
|
|
|
for rule in rules:
|
|
url = rule.rule
|
|
if not isinstance(url, str) or not url.startswith("/api/"):
|
|
continue
|
|
endpoint = rule.endpoint
|
|
# ``endpoint`` is a wrapped callable; the real controller method is
|
|
# stashed on ``endpoint.func`` by Odoo.
|
|
fn = getattr(endpoint, "func", endpoint)
|
|
routing = getattr(fn, "routing", {}) or {}
|
|
methods = routing.get("methods") or list(rule.methods or {"GET"})
|
|
methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}]
|
|
if not methods:
|
|
methods = ["GET"]
|
|
cls_name = ""
|
|
try:
|
|
qual = getattr(fn, "__qualname__", fn.__name__)
|
|
cls_name = qual.split(".")[0] if "." in qual else ""
|
|
except Exception:
|
|
cls_name = ""
|
|
module = getattr(fn, "__module__", "") or ""
|
|
tag_base = module.split(".")[-2] if "." in module else module or "api"
|
|
doc = ""
|
|
try:
|
|
doc = (fn.__doc__ or "").strip()
|
|
except Exception:
|
|
pass
|
|
for m in methods:
|
|
key = (url, m.upper(), cls_name, fn.__name__)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
yield url, m.upper(), tag_base, cls_name, doc
|
|
|
|
|
|
def _build_openapi_spec() -> dict:
|
|
base_url = (
|
|
request.env["ir.config_parameter"].sudo().get_param("web.base.url")
|
|
or "http://localhost:8069"
|
|
)
|
|
paths: dict[str, dict] = {}
|
|
tags: dict[str, str] = {}
|
|
|
|
for route, method, tag_base, cls_name, doc in _iter_routes():
|
|
openapi_path = _convert_odoo_path(route)
|
|
path_entry = paths.setdefault(openapi_path, {})
|
|
|
|
# Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``).
|
|
segs = [s for s in route.split("/") if s and s != "api"]
|
|
tag = segs[0] if segs else tag_base
|
|
tags.setdefault(tag, f"Endpoints under /api/{tag}")
|
|
|
|
summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}"
|
|
|
|
op: dict = {
|
|
"summary": summary,
|
|
"operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}",
|
|
"tags": [tag],
|
|
"security": [{"BearerAuth": []}],
|
|
"responses": {
|
|
"200": {"description": "OK",
|
|
"content": {"application/json":
|
|
{"schema": {"type": "object"}}}},
|
|
"401": {"description": "Authentication required"},
|
|
"400": {"description": "Bad request"},
|
|
"500": {"description": "Server error"},
|
|
},
|
|
}
|
|
params = _path_parameters(route)
|
|
if params:
|
|
op["parameters"] = params
|
|
if method in ("POST", "PUT", "PATCH"):
|
|
op["requestBody"] = {
|
|
"required": False,
|
|
"content": {"application/json":
|
|
{"schema": {"type": "object"}}},
|
|
}
|
|
path_entry[method.lower()] = op
|
|
|
|
return {
|
|
"openapi": "3.0.3",
|
|
"info": {
|
|
"title": "EnCoach Platform API",
|
|
"version": "19.0.1.0",
|
|
"description": (
|
|
"Auto-generated from ``@http.route`` decorators. "
|
|
"Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the "
|
|
"roadmap to enrich request/response shapes."
|
|
),
|
|
},
|
|
"servers": [{"url": base_url}],
|
|
"tags": [{"name": name, "description": desc}
|
|
for name, desc in sorted(tags.items())],
|
|
"components": {
|
|
"securitySchemes": {
|
|
"BearerAuth": {
|
|
"type": "http", "scheme": "bearer", "bearerFormat": "JWT",
|
|
},
|
|
},
|
|
},
|
|
"paths": dict(sorted(paths.items())),
|
|
}
|
|
|
|
|
|
class EncoachOpenAPIController(Controller):
|
|
|
|
@http.route("/api/openapi.json", type="http", auth="none",
|
|
methods=["GET"], csrf=False)
|
|
def openapi(self, **_kw):
|
|
"""Return a freshly generated OpenAPI 3.0 spec for the running server."""
|
|
try:
|
|
spec = _build_openapi_spec()
|
|
return request.make_json_response(spec)
|
|
except Exception:
|
|
_logger.exception("openapi generation failed")
|
|
return request.make_json_response(
|
|
{"error": "could not generate openapi spec"}, status=500,
|
|
)
|
|
|
|
@http.route("/api/metrics", type="http", auth="none",
|
|
methods=["GET"], csrf=False)
|
|
def metrics(self, **_kw):
|
|
"""Return in-process request counters in a Prometheus-compatible text format.
|
|
|
|
This is a minimalist shim, not a full Prometheus client. It exposes:
|
|
|
|
- ``encoach_requests_total{route, method, status_bucket}`` — request count
|
|
- ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based)
|
|
- ``encoach_uptime_seconds`` — process uptime
|
|
"""
|
|
lines = [
|
|
"# HELP encoach_uptime_seconds Seconds since this worker booted",
|
|
"# TYPE encoach_uptime_seconds gauge",
|
|
f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}",
|
|
"",
|
|
"# HELP encoach_requests_total Total number of /api/* requests handled",
|
|
"# TYPE encoach_requests_total counter",
|
|
]
|
|
for route, count in _metrics["routes"].most_common():
|
|
safe = route.replace('"', '\\"')
|
|
lines.append(f'encoach_requests_total{{route="{safe}"}} {count}')
|
|
lines.append("")
|
|
lines.append("# HELP encoach_responses_total Count of responses per status bucket")
|
|
lines.append("# TYPE encoach_responses_total counter")
|
|
for bucket, count in _metrics["statuses"].items():
|
|
lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}')
|
|
lines.append("")
|
|
lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route")
|
|
lines.append("# TYPE encoach_request_latency_ms_p95 gauge")
|
|
for route, samples in _metrics["latencies_ms"].items():
|
|
if not samples:
|
|
continue
|
|
sorted_samples = sorted(samples)
|
|
p95 = sorted_samples[min(len(sorted_samples) - 1,
|
|
int(0.95 * len(sorted_samples)))]
|
|
safe = route.replace('"', '\\"')
|
|
lines.append(
|
|
f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}'
|
|
)
|
|
body = "\n".join(lines) + "\n"
|
|
return Response(body, status=200,
|
|
content_type="text/plain; version=0.0.4")
|