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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user