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
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:09 +04:00
parent 1a0349c381
commit 3972023a30
64 changed files with 4121 additions and 701 deletions

View File

@@ -2,6 +2,7 @@ import json
import functools
import logging
import time
import uuid
import jwt as pyjwt
@@ -9,14 +10,75 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
REQUEST_ID_HEADER = "X-Request-Id"
def _get_or_create_request_id():
"""Return the X-Request-Id for the current request, minting one if absent.
The value is cached on ``request`` so the same id is reused for every log
line emitted during the request lifecycle (dispatcher, controller,
post-dispatch). Call sites should prefer :func:`current_request_id`.
"""
existing = getattr(request, "_encoach_request_id", None)
if existing:
return existing
header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
req_id = (header_val or uuid.uuid4().hex)[:64]
try:
request._encoach_request_id = req_id
except Exception:
pass
return req_id
def current_request_id():
"""Best-effort accessor for the active request id (None outside a request)."""
try:
return getattr(request, "_encoach_request_id", None)
except Exception:
return None
def _log_json(level, event, **fields):
"""Emit a single structured JSON log line, enriched with the request id.
Keeping all non-interactive logs JSON-formatted lets ops scrape them with
Loki / OpenSearch without brittle regexes. Falls back silently if the
logging backend rejects the payload.
"""
try:
payload = {
"ts": round(time.time(), 3),
"event": event,
"rid": current_request_id(),
}
payload.update(fields)
_logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
except Exception:
_logger.log(level, "%s %s", event, fields)
_jwt_secret_cache = {"secret": None, "ts": 0}
_JWT_SECRET_TTL = 300
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
# via ir.config_parameter propagates quickly across all workers without
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
_JWT_SECRET_TTL = 30
_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200
def invalidate_jwt_secret_cache():
"""Invalidate the per-worker JWT secret cache immediately.
Called by ``encoach.auth.settings`` whenever the admin rotates the secret
so that subsequent requests pick up the new value on the next read.
"""
_jwt_secret_cache["secret"] = None
_jwt_secret_cache["ts"] = 0
def _get_jwt_secret():
now = time.time()
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
@@ -48,6 +110,12 @@ def validate_token():
return None
except pyjwt.InvalidTokenError:
return None
# Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
# a Bearer on any other endpoint is treated as unauthenticated so a
# leaked refresh token never buys API access on its own.
token_type = payload.get("type")
if token_type and token_type != "access":
return None
user_id = payload.get("user_id")
if not user_id:
return None
@@ -70,26 +138,95 @@ def validate_token():
def jwt_required(func):
"""Decorator that validates the JWT token and sets request.env user context."""
"""Decorator that validates the JWT token and sets request.env user context.
Also handles per-request observability plumbing:
- Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
service call, and response can be correlated back to the same request.
- Emits a structured ``api.request.start`` / ``api.request.end`` log pair
with duration in ms, status code, user id, route, and method — powering
the P2.3 metrics pipeline once it lands without any extra wiring.
- Echoes the request id back to the caller via the ``X-Request-Id``
response header.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
req_id = _get_or_create_request_id()
route = request.httprequest.path
method = request.httprequest.method
t0 = time.time()
user = validate_token()
if not user:
return _error_response("Authentication required", status=401)
_log_json(logging.INFO, "api.request.unauthorized",
route=route, method=method, status=401)
resp = _error_response("Authentication required", status=401)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
return resp
request.update_env(user=user.id)
return func(*args, **kwargs)
_log_json(logging.INFO, "api.request.start",
route=route, method=method, user_id=user.id)
try:
resp = func(*args, **kwargs)
status = getattr(resp, "status_code", 200)
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.INFO, "api.request.end",
route=route, method=method, user_id=user.id,
status=status, duration_ms=duration_ms)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
_record_metric(route, status, duration_ms)
return resp
except Exception as exc:
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.ERROR, "api.request.error",
route=route, method=method, user_id=user.id,
duration_ms=duration_ms,
error=type(exc).__name__, message=str(exc)[:500])
_record_metric(route, 500, duration_ms)
raise
return wrapper
def _record_metric(route, status, duration_ms):
"""Soft import of the metrics recorder so this module has no hard cycle."""
try:
from odoo.addons.encoach_api.controllers.openapi import record_request
record_request(route, status, duration_ms)
except Exception:
pass
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
resp = request.make_json_response(data, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _error_response(message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
resp = request.make_json_response(body, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _get_json_body():
@@ -99,6 +236,31 @@ def _get_json_body():
return {}
def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
"""Canonical ``{items, total, page, size}`` envelope.
This is the platform-wide pagination shape — prefer returning the output of
this helper from any new list endpoint. Legacy keys (``data``, ``results``,
and custom aliases such as ``students`` / ``subjects``) may be added via
the ``extra`` dict for transitional backward compatibility with the
frontend services that still read those keys; new code must read ``items``.
"""
items = list(items or [])
envelope = {
"items": items,
"total": int(total if total is not None else len(items)),
}
if page is not None:
envelope["page"] = int(page)
if size is not None:
envelope["size"] = int(size)
if extra:
for k, v in extra.items():
if k not in envelope:
envelope[k] = v
return envelope
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
"""Paginate an Odoo model search or extract params from a kwargs dict.