Files
encoach_backend_new_v2/custom_addons/encoach_api/controllers/openapi.py
Yamen Ahmad 3972023a30 feat(backend): Phase 2/3 hardening release
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
2026-04-19 14:16:09 +04:00

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")