Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
343 lines
12 KiB
Python
343 lines
12 KiB
Python
import json
|
|
import functools
|
|
import logging
|
|
import time
|
|
import uuid
|
|
|
|
import jwt as pyjwt
|
|
|
|
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}
|
|
# 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:
|
|
return _jwt_secret_cache["secret"]
|
|
secret = (
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("encoach.jwt_secret")
|
|
)
|
|
if secret:
|
|
_jwt_secret_cache["secret"] = secret
|
|
_jwt_secret_cache["ts"] = now
|
|
return secret
|
|
|
|
|
|
def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
|
|
"""Pull a JWT off the current request.
|
|
|
|
By default we only honour the ``Authorization: Bearer …`` header. Some
|
|
endpoints (notably media streams that get embedded in ``<img>`` /
|
|
``<audio>`` / ``<video>`` tags, where the browser cannot attach custom
|
|
headers) opt in to ``allow_query_param=True`` so callers can send the
|
|
token via ``?token=<jwt>`` or ``?access_token=<jwt>``. We deliberately
|
|
keep this off by default so a leaked URL never gives access to JSON
|
|
APIs — only to the specific raw-media route that opts in.
|
|
"""
|
|
auth_header = request.httprequest.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header[7:].strip() or None
|
|
if allow_query_param:
|
|
try:
|
|
args = request.httprequest.args
|
|
except Exception:
|
|
args = {}
|
|
token = (args.get("token") or args.get("access_token") or "").strip()
|
|
if token:
|
|
return token
|
|
return None
|
|
|
|
|
|
def validate_token(allow_query_param: bool = False):
|
|
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None.
|
|
|
|
See :func:`_extract_bearer_token` for the ``allow_query_param`` flag.
|
|
"""
|
|
token = _extract_bearer_token(allow_query_param=allow_query_param)
|
|
if not token:
|
|
return None
|
|
secret = _get_jwt_secret()
|
|
if not secret:
|
|
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
|
|
return None
|
|
try:
|
|
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
|
|
except pyjwt.ExpiredSignatureError:
|
|
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
|
|
|
|
now = time.time()
|
|
cache_key = int(user_id)
|
|
cached = _user_exists_cache.get(cache_key)
|
|
if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
|
|
return request.env["res.users"].sudo().browse(cache_key)
|
|
|
|
user = request.env["res.users"].sudo().browse(cache_key)
|
|
if not user.exists():
|
|
return None
|
|
|
|
_user_exists_cache[cache_key] = {"ts": now}
|
|
if len(_user_exists_cache) > _USER_CACHE_MAX:
|
|
oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
|
|
del _user_exists_cache[oldest_key]
|
|
return user
|
|
|
|
|
|
def jwt_required(func):
|
|
"""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:
|
|
_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)
|
|
_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):
|
|
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
|
|
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():
|
|
try:
|
|
return json.loads(request.httprequest.get_data(as_text=True))
|
|
except (ValueError, TypeError):
|
|
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.
|
|
|
|
Two calling conventions:
|
|
_paginate(Model, domain, page, size, order) → (recordset, total_count)
|
|
_paginate(kwargs_dict) → (offset, limit, page)
|
|
"""
|
|
if isinstance(model_or_kwargs, dict):
|
|
kwargs = model_or_kwargs
|
|
limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
|
|
page_raw = int(kwargs.get("page", 0))
|
|
pg = max(page_raw, 0)
|
|
offset = pg * limit
|
|
return offset, limit, pg
|
|
|
|
Model = model_or_kwargs
|
|
limit = min(max(int(size), 1), 200)
|
|
pg = max(int(page), 0)
|
|
offset = pg * limit
|
|
total = Model.search_count(domain or [])
|
|
records = Model.search(domain or [], offset=offset, limit=limit, order=order)
|
|
return records, total
|
|
|
|
|
|
class EncoachMixin:
|
|
"""Shared authentication and response helpers for all EnCoach API controllers."""
|
|
|
|
def _get_jwt_secret(self):
|
|
return _get_jwt_secret()
|
|
|
|
def _authenticate(self):
|
|
return validate_token()
|
|
|
|
def _json_response(self, data, status=200):
|
|
return _json_response(data, status=status)
|
|
|
|
def _error_response(self, message, status=400, code=None):
|
|
return _error_response(message, status=status, code=code)
|
|
|
|
def _get_json_body(self):
|
|
return _get_json_body()
|
|
|
|
def _paginate_params(self, kwargs):
|
|
return _paginate(kwargs)
|
|
|
|
def _serialize(self, record):
|
|
if hasattr(record, "to_encoach_dict"):
|
|
return record.to_encoach_dict()
|
|
return {"id": record.id}
|
|
|
|
def _serialize_list(self, records):
|
|
return [self._serialize(r) for r in records]
|