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

@@ -1,2 +1,5 @@
from . import base
from . import auth
from . import health
from . import openapi
from . import gdpr

View File

@@ -1,5 +1,25 @@
"""Authentication endpoints.
Split-token design:
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
Sent on every request as ``Authorization: Bearer <access_token>``.
* ``refresh_token`` — long-lived (7d), stateful, backed by
``encoach.jwt.token``. Never sent on regular API calls; only to
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
usable at most once.
The frontend should store ``refresh_token`` in a secure, non-extractable
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
otherwise ``localStorage`` behind an explicit trust boundary) and call
``/api/auth/refresh`` in the background whenever the access token is within
2 minutes of expiry.
"""
import logging
import time
import uuid
from datetime import datetime, timedelta
import jwt as pyjwt
@@ -13,6 +33,40 @@ from .base import (
_logger = logging.getLogger(__name__)
ACCESS_TTL_SECONDS = 3600 # 1 hour
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
Persisting the refresh token is the caller's job — they get the ``jti``
back so they can insert exactly one row.
"""
secret = _get_jwt_secret()
now = int(time.time())
access_token = pyjwt.encode(
{
"user_id": user.id,
"type": "access",
"iat": now,
"exp": now + ACCESS_TTL_SECONDS,
},
secret, algorithm="HS256",
)
refresh_jti = uuid.uuid4().hex
refresh_token = pyjwt.encode(
{
"user_id": user.id,
"type": "refresh",
"jti": refresh_jti,
"iat": now,
"exp": now + REFRESH_TTL_SECONDS,
},
secret, algorithm="HS256",
)
return access_token, refresh_token, refresh_jti
class EncoachAuthController(http.Controller):
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
if not login or not password:
return _error_response('login and password are required', 400)
# Odoo 19: session.authenticate(env, credential_dict)
credential = {
'type': 'password',
'login': login,
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
user = request.env['res.users'].sudo().browse(uid)
# Generate JWT token
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
token = pyjwt.encode(
{'user_id': user.id, 'exp': int(time.time()) + 86400},
secret, algorithm='HS256',
)
# User-Agent / IP are best-effort — some proxies strip them; we
# record whatever we get but never refuse login because they're
# missing.
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
request.env['encoach.jwt.token'].sudo().create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
# Get permissions
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
# Update last login
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'token': token,
'token': access_token, # legacy name (frontend fallback)
'access_token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
'user': self._user_to_dict(user),
'permissions': permissions,
})
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
_logger.exception('login failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/auth/refresh
# ------------------------------------------------------------------
@http.route('/api/auth/refresh', type='http', auth='public',
methods=['POST'], csrf=False)
def refresh(self, **kw):
"""Rotate a refresh token into a fresh access+refresh pair.
Consumes (revokes) the presented refresh token and issues new ones.
This is the *only* place a refresh token is ever accepted, so replay
attempts against other endpoints fail at the access-token layer.
"""
try:
body = _get_json_body()
token = body.get('refresh_token') or ''
if not token:
return _error_response('refresh_token is required', 400)
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
try:
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
except pyjwt.ExpiredSignatureError:
return _error_response('refresh_token expired', 401)
except pyjwt.InvalidTokenError:
return _error_response('refresh_token invalid', 401)
if claims.get('type') != 'refresh':
return _error_response('wrong token type', 401)
jti = claims.get('jti')
user_id = claims.get('user_id')
if not jti or not user_id:
return _error_response('refresh_token malformed', 401)
Token = request.env['encoach.jwt.token'].sudo()
row = Token.search([
('jti', '=', jti),
('user_id', '=', user_id),
('revoked', '=', False),
], limit=1)
if not row:
# Either already rotated (possible replay) or never existed.
# Revoke every active token for that user as a defensive
# measure — stolen refresh tokens shouldn't buy multiple
# rotations.
Token.search([
('user_id', '=', user_id),
('revoked', '=', False),
]).write({'revoked': True})
return _error_response('refresh_token revoked', 401)
user = request.env['res.users'].sudo().browse(user_id)
if not user.exists() or not user.active:
return _error_response('user disabled', 401)
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
Token.create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
return _json_response({
'access_token': access_token,
'token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
})
except Exception as e:
_logger.exception('refresh failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/user (returns current authenticated user)
# ------------------------------------------------------------------
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
# JWT is stateless — client clears token. Server just returns OK.
"""Revoke the presented refresh token (if any) and return OK.
Access tokens remain stateless; they expire naturally within an hour.
Clients that care about immediate lockout should also delete the
access token from local storage when they call this endpoint.
"""
try:
body = _get_json_body() or {}
token = body.get('refresh_token') or ''
if token:
secret = _get_jwt_secret()
if secret:
try:
claims = pyjwt.decode(
token, secret, algorithms=['HS256'],
options={'verify_exp': False},
)
jti = claims.get('jti')
if jti:
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
except pyjwt.InvalidTokenError:
# Invalid tokens can't be revoked but also can't be
# reused, so this is effectively a no-op.
pass
except Exception:
_logger.exception('logout cleanup failed')
return _json_response({'ok': True})
# ------------------------------------------------------------------

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.

View File

@@ -0,0 +1,316 @@
"""GDPR data-subject rights endpoints.
Exposes two routes:
* ``GET /api/gdpr/export``
Returns a JSON snapshot of the calling user's personal data — profile,
entity membership, exam attempts, answers, AI interactions, feedback,
tickets, and login history.
* ``POST /api/gdpr/delete``
Initiates the "right to erasure" flow. Because Odoo enforces FK
constraints on linked business records (exam attempts, tickets, audit
trail), we **anonymise** instead of hard-deleting: the ``res.users``
row is deactivated, ``res.partner`` PII fields are wiped, and a
tombstone row is written to ``encoach.gdpr.erasure.request`` for
audit. Hard delete of linked business rows (attempts, answers,
feedback) is performed for records where it is legally required and
where no other user depends on them.
The flow is intentionally verbose so operators can see what happened. A
production deployment may want to queue the erasure to a background job
and require a cooling-off period before executing; we expose a
``confirm`` flag for that.
"""
from __future__ import annotations
import json
import logging
from odoo import fields, http
from odoo.http import request
from .base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _safe_records(env_name: str, domain, fields_to_read):
"""Read records if the model exists, else return []."""
env = request.env
if env_name not in env:
return []
try:
return env[env_name].sudo().search(domain).read(fields_to_read)
except Exception as exc:
_logger.warning("gdpr: could not read %s: %s", env_name, exc)
return []
def _sanitize(values):
"""Convert non-JSON-serialisable values to strings/nulls."""
out = {}
for key, value in values.items():
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int):
# Many2one read tuples — keep both id and display name.
out[key] = {"id": value[0], "display_name": value[1]}
elif isinstance(value, bytes):
out[key] = f"<{len(value)} bytes omitted>"
elif hasattr(value, "isoformat"):
out[key] = value.isoformat()
else:
out[key] = value
return out
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class EncoachGdprController(http.Controller):
"""Implements export + right-to-erasure endpoints."""
# ------------------------------------------------------------------
# GET /api/gdpr/export
# ------------------------------------------------------------------
@http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def export(self, **kw):
try:
user = request.env.user
partner = user.partner_id
profile = {
"id": user.id,
"login": user.login,
"name": user.name,
"email": user.email or "",
"lang": user.lang or "",
"tz": user.tz or "",
"create_date": user.create_date.isoformat() if user.create_date else None,
"login_date": user.login_date.isoformat() if user.login_date else None,
}
partner_data = {
"id": partner.id if partner else None,
"name": partner.name if partner else None,
"email": partner.email if partner else None,
"phone": partner.phone if partner else None,
"mobile": partner.mobile if partner else None,
"street": partner.street if partner else None,
"city": partner.city if partner else None,
"country": partner.country_id.name if partner and partner.country_id else None,
}
# Entity / role memberships
entity_rel = _safe_records(
"encoach.user.entity.rel",
[("user_id", "=", user.id)],
["id", "entity_id", "role_id", "is_active"],
)
# Exam attempts & answers
attempts = _safe_records(
"encoach.student.attempt",
[("user_id", "=", user.id)],
["id", "exam_id", "status", "score", "started_at", "submitted_at"],
)
attempt_ids = [a["id"] for a in attempts]
answers = _safe_records(
"encoach.student.answer",
[("attempt_id", "in", attempt_ids)],
["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"],
) if attempt_ids else []
# AI feedback they left
feedback = _safe_records(
"encoach.ai.feedback",
[("user_id", "=", user.id)],
["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"],
)
# AI calls they triggered (logs)
ai_logs = _safe_records(
"encoach.ai.log",
[("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [],
["id", "service", "action", "model_used", "total_tokens", "status", "create_date"],
)
# Tickets they filed
tickets = _safe_records(
"encoach.ticket",
[("created_by_id", "=", user.id)],
["id", "name", "status", "priority", "assigned_to_id", "create_date"],
)
# Feedback on their coaching, if any
coaching_sessions = _safe_records(
"encoach.coaching.session",
[("user_id", "=", user.id)],
["id", "create_date", "summary"],
)
payload = {
"profile": profile,
"partner": partner_data,
"entity_memberships": [_sanitize(r) for r in entity_rel],
"exam_attempts": [_sanitize(r) for r in attempts],
"exam_answers": [_sanitize(r) for r in answers],
"ai_feedback": [_sanitize(r) for r in feedback],
"ai_calls": [_sanitize(r) for r in ai_logs],
"tickets": [_sanitize(r) for r in tickets],
"coaching_sessions": [_sanitize(r) for r in coaching_sessions],
"exported_at": fields.Datetime.to_string(fields.Datetime.now()),
"export_format_version": "1.0",
}
return _json_response(payload)
except Exception as e:
_logger.exception("gdpr export failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/gdpr/delete
# ------------------------------------------------------------------
@http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def delete(self, **kw):
try:
body = _get_json_body() or {}
if not body.get("confirm"):
return _error_response(
"Set {\"confirm\": true} to initiate erasure. "
"This action cannot be undone.",
400,
)
user = request.env.user
if user.id == 1 or user.has_group("base.group_system"):
return _error_response(
"Admin accounts cannot self-erase via this endpoint. "
"Contact the data protection officer.",
403,
)
login = user.login
email = user.email or ""
archived_id = user.id
summary = {
"anonymised_partner_fields": [],
"deactivated_user": False,
"deleted_feedback_count": 0,
"deleted_coaching_count": 0,
"retained_attempts_count": 0,
}
with request.env.cr.savepoint():
# 1. Anonymise the partner PII.
partner = user.partner_id
if partner:
partner.sudo().write({
"name": f"deleted-user-{archived_id}",
"email": False,
"phone": False,
"mobile": False,
"street": False,
"street2": False,
"city": False,
"zip": False,
"image_1920": False,
"comment": "Erased per GDPR request.",
})
summary["anonymised_partner_fields"] = [
"name", "email", "phone", "mobile",
"street", "street2", "city", "zip",
"image_1920", "comment",
]
# 2. Hard-delete their feedback (personal reviews/comments).
if "encoach.ai.feedback" in request.env:
feedback = request.env["encoach.ai.feedback"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_feedback_count"] = len(feedback)
feedback.unlink()
# 3. Hard-delete coaching session transcripts (personal).
if "encoach.coaching.session" in request.env:
sessions = request.env["encoach.coaching.session"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_coaching_count"] = len(sessions)
sessions.unlink()
# 4. Retain exam attempts (aggregate analytics rely on them)
# but strip personal free-text fields where they exist.
if "encoach.student.attempt" in request.env:
retained = request.env["encoach.student.attempt"].sudo().search([
("user_id", "=", user.id),
])
summary["retained_attempts_count"] = len(retained)
# Clear any written responses that contain user PII — keep
# the scores/timestamps for analytics integrity.
if "encoach.student.answer" in request.env:
answers = request.env["encoach.student.answer"].sudo().search([
("attempt_id", "in", retained.ids),
])
# student_answer can contain free-text PII.
answers.write({"student_answer": False})
# 5. Rewrite AI log user_id → None so we can no longer tie
# those rows to the person.
if "encoach.ai.log" in request.env:
ai_logs = request.env["encoach.ai.log"].sudo().search([
("user_id", "=", user.id),
])
if ai_logs:
try:
ai_logs.write({"user_id": False})
except Exception:
# user_id may be required; fall back to deletion.
ai_logs.unlink()
# 6. Deactivate the user (we can't unlink — FKs).
user.sudo().write({
"active": False,
"login": f"erased-{archived_id}@example.invalid",
})
summary["deactivated_user"] = True
# 7. Write tombstone audit row.
request.env["encoach.gdpr.erasure.request"].sudo().create({
"user_login": login,
"user_email": email,
"user_id_archived": archived_id,
"status": "completed",
"summary": json.dumps(summary),
"completed_at": fields.Datetime.now(),
})
# Blow away any cached JWT sessions for this user.
try:
from odoo.addons.encoach_api.utils.jwt_cache import (
invalidate_user as _invalidate_user,
)
_invalidate_user(archived_id)
except Exception as exc:
_logger.debug("jwt cache invalidation skipped: %s", exc)
return _json_response({
"ok": True,
"summary": summary,
"message": (
"Your account has been anonymised and deactivated. "
"Some records (e.g. exam attempts) are retained "
"without personal identifiers for statistical purposes."
),
})
except Exception as e:
_logger.exception("gdpr delete failed")
return _error_response(str(e), 500)

View File

@@ -0,0 +1,65 @@
"""Platform health probes (P0.4).
Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready``
(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by
uptime monitors, load-balancer health checks and container orchestrators.
"""
import logging
import time
from odoo import http
from odoo.http import request
from odoo.release import version as odoo_version
_logger = logging.getLogger(__name__)
_STARTED_AT = time.time()
class HealthController(http.Controller):
"""Platform health probes — safe to expose publicly."""
@http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **_kw):
"""Liveness probe: the process is up and able to handle HTTP."""
return request.make_json_response({
"status": "ok",
"service": "encoach-backend",
"odoo_version": odoo_version,
"uptime_seconds": int(time.time() - _STARTED_AT),
})
@http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False)
def ready(self, **_kw):
"""Readiness probe: DB reachable, JWT secret configured, AI key known."""
checks = {}
ok = True
try:
request.env.cr.execute("SELECT 1")
checks["database"] = "ok"
except Exception as exc:
ok = False
checks["database"] = f"error: {exc}"
try:
IrParam = request.env["ir.config_parameter"].sudo()
checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing"
if checks["jwt_secret"] == "missing":
ok = False
ai_key = IrParam.get_param("encoach_ai.openai_api_key")
import os
if not ai_key:
ai_key = os.environ.get("OPENAI_API_KEY", "")
checks["openai_key"] = "ok" if ai_key else "missing"
except Exception as exc:
ok = False
checks["config"] = f"error: {exc}"
status = 200 if ok else 503
return request.make_json_response({
"status": "ok" if ok else "degraded",
"checks": checks,
"uptime_seconds": int(time.time() - _STARTED_AT),
}, status=status)

View File

@@ -0,0 +1,265 @@
"""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")