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:
@@ -1 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import utils # exposes odoo.addons.encoach_api.utils.cache
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
'external_dependencies': {
|
||||
'python': ['PyJWT'],
|
||||
},
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from . import base
|
||||
from . import auth
|
||||
from . import health
|
||||
from . import openapi
|
||||
from . import gdpr
|
||||
|
||||
@@ -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})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
316
custom_addons/encoach_api/controllers/gdpr.py
Normal file
316
custom_addons/encoach_api/controllers/gdpr.py
Normal 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)
|
||||
65
custom_addons/encoach_api/controllers/health.py
Normal file
65
custom_addons/encoach_api/controllers/health.py
Normal 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)
|
||||
265
custom_addons/encoach_api/controllers/openapi.py
Normal file
265
custom_addons/encoach_api/controllers/openapi.py
Normal 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")
|
||||
12
custom_addons/encoach_api/data/cron.xml
Normal file
12
custom_addons/encoach_api/data/cron.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="ir_cron_purge_expired_jwt" model="ir.cron">
|
||||
<field name="name">EnCoach: purge expired JWT refresh tokens</field>
|
||||
<field name="model_id" ref="model_encoach_jwt_token"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.purge_expired()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
2
custom_addons/encoach_api/models/__init__.py
Normal file
2
custom_addons/encoach_api/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import jwt_token
|
||||
from . import gdpr_erasure
|
||||
35
custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
35
custom_addons/encoach_api/models/gdpr_erasure.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tombstone record written when a user exercises the right-to-erasure.
|
||||
|
||||
We cannot hard-delete the original res.users row (ORM FK constraints on
|
||||
attempts, tickets, audit trail, etc.), so this table lets us prove the
|
||||
request happened, what was anonymised/deleted, and when.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class EncoachGdprErasureRequest(models.Model):
|
||||
_name = "encoach.gdpr.erasure.request"
|
||||
_description = "GDPR right-to-erasure audit trail"
|
||||
_order = "create_date desc"
|
||||
|
||||
user_login = fields.Char(required=True, index=True)
|
||||
user_email = fields.Char(index=True)
|
||||
user_id_archived = fields.Integer(
|
||||
index=True,
|
||||
help="Id of the res.users row at time of erasure (row is now archived).",
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
("requested", "Requested"),
|
||||
("completed", "Completed"),
|
||||
("partial", "Partial — manual follow-up required"),
|
||||
],
|
||||
default="requested", required=True,
|
||||
)
|
||||
summary = fields.Text(
|
||||
help="JSON summary of what was deleted/anonymised/retained.",
|
||||
)
|
||||
requested_at = fields.Datetime(default=fields.Datetime.now)
|
||||
completed_at = fields.Datetime()
|
||||
notes = fields.Text()
|
||||
105
custom_addons/encoach_api/models/jwt_token.py
Normal file
105
custom_addons/encoach_api/models/jwt_token.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Refresh-token ledger for the EnCoach API.
|
||||
|
||||
Access tokens stay short-lived (1 hour) and stateless — they are verified
|
||||
with the JWT signature alone. Refresh tokens, on the other hand, must be
|
||||
revocable so compromised devices can be signed out without rotating the
|
||||
server secret. Every refresh token corresponds to one row here, keyed by a
|
||||
UUID (``jti``) that the client echoes back on ``/api/auth/refresh``.
|
||||
|
||||
Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes
|
||||
it) and issues a brand-new row, so a stolen refresh token only works once.
|
||||
Replay attempts after rotation will be rejected because the replayed ``jti``
|
||||
is no longer ``active``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachJwtToken(models.Model):
|
||||
_name = "encoach.jwt.token"
|
||||
_description = "JWT Refresh Token Ledger"
|
||||
_order = "issued_at desc"
|
||||
_rec_name = "jti"
|
||||
|
||||
jti = fields.Char(
|
||||
string="Token ID",
|
||||
required=True,
|
||||
index=True,
|
||||
help="UUID used as the JWT's `jti` claim; serves as the primary handle.",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="User",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
issued_at = fields.Datetime(required=True, default=fields.Datetime.now)
|
||||
expires_at = fields.Datetime(required=True)
|
||||
last_used_at = fields.Datetime()
|
||||
revoked = fields.Boolean(default=False, index=True)
|
||||
user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.")
|
||||
remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.")
|
||||
|
||||
@api.model
|
||||
def _auto_init(self):
|
||||
res = super()._auto_init()
|
||||
cr = self.env.cr
|
||||
for name, ddl in (
|
||||
(
|
||||
"encoach_jwt_token_jti_unique",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique "
|
||||
"ON encoach_jwt_token (jti)",
|
||||
),
|
||||
(
|
||||
"encoach_jwt_token_user_revoked_idx",
|
||||
"CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx "
|
||||
"ON encoach_jwt_token (user_id, revoked, expires_at)",
|
||||
),
|
||||
):
|
||||
try:
|
||||
cr.execute(ddl)
|
||||
except Exception:
|
||||
_logger.warning("could not create index %s", name, exc_info=True)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def revoke_by_jti(self, jti: str) -> bool:
|
||||
"""Soft-revoke every row matching ``jti``.
|
||||
|
||||
Returns ``True`` iff at least one row was flipped. Called from
|
||||
``/api/auth/refresh`` (to consume the rotated token) and
|
||||
``/api/logout`` (to end the session).
|
||||
"""
|
||||
if not jti:
|
||||
return False
|
||||
rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)])
|
||||
if not rows:
|
||||
return False
|
||||
rows.write({"revoked": True})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def purge_expired(self, batch_size: int = 500) -> int:
|
||||
"""Remove rows that expired more than a day ago.
|
||||
|
||||
Wired to the daily cleanup cron in :file:`data/cron.xml`; manual
|
||||
invocations (e.g. migrations) should pass a larger ``batch_size``
|
||||
to avoid multi-pass overhead.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(days=1)
|
||||
rows = self.sudo().search(
|
||||
[("expires_at", "<", cutoff)],
|
||||
limit=batch_size,
|
||||
)
|
||||
n = len(rows)
|
||||
if n:
|
||||
rows.unlink()
|
||||
return n
|
||||
4
custom_addons/encoach_api/security/ir.model.access.csv
Normal file
4
custom_addons/encoach_api/security/ir.model.access.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0
|
||||
access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1
|
||||
access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1
|
||||
|
1
custom_addons/encoach_api/tests/__init__.py
Normal file
1
custom_addons/encoach_api/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_health
|
||||
27
custom_addons/encoach_api/tests/test_health.py
Normal file
27
custom_addons/encoach_api/tests/test_health.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Smoke tests for the health + GDPR endpoints.
|
||||
|
||||
Run with:
|
||||
./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install", "encoach_api")
|
||||
class TestHealthEndpoints(HttpCase):
|
||||
def test_health_live_returns_200(self):
|
||||
"""``GET /api/health`` must always return 200 once Odoo has booted."""
|
||||
response = self.url_open("/api/health", timeout=10)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json() if hasattr(response, "json") else json.loads(response.text)
|
||||
self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"})
|
||||
|
||||
def test_health_ready_returns_2xx(self):
|
||||
"""``GET /api/health/ready`` is allowed to flap, but must not 5xx."""
|
||||
response = self.url_open("/api/health/ready", timeout=10)
|
||||
self.assertIn(
|
||||
response.status_code, (200, 503),
|
||||
f"unexpected status {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
1
custom_addons/encoach_api/utils/__init__.py
Normal file
1
custom_addons/encoach_api/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import cache # noqa: F401
|
||||
106
custom_addons/encoach_api/utils/cache.py
Normal file
106
custom_addons/encoach_api/utils/cache.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Zero-dependency in-process TTL cache for hot endpoints.
|
||||
|
||||
Designed for read-heavy, mostly-idempotent endpoints like the admin report
|
||||
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
|
||||
and the AI narrative generator. A single LRU-ish dict per worker with a hard
|
||||
time bound is usually enough to absorb the dashboard refresh storm caused by
|
||||
an admin tabbing between pages.
|
||||
|
||||
**Not** a substitute for Redis — cross-worker consistency and bounded memory
|
||||
are out of scope. If ops ever needs those guarantees, swap the internal dict
|
||||
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
|
||||
``invalidate``) stays identical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
_MAX_ENTRIES = 512
|
||||
|
||||
|
||||
def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
|
||||
payload = json.dumps([args, kwargs], sort_keys=True, default=str)
|
||||
digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||||
return f"{namespace}:{digest}"
|
||||
|
||||
|
||||
def get(key: str):
|
||||
entry = _cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
expiry, value = entry
|
||||
if expiry < time.time():
|
||||
with _lock:
|
||||
_cache.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def put(key: str, value, ttl_seconds: int) -> None:
|
||||
with _lock:
|
||||
if len(_cache) >= _MAX_ENTRIES:
|
||||
oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
|
||||
for k, _v in oldest:
|
||||
_cache.pop(k, None)
|
||||
_cache[key] = (time.time() + max(1, ttl_seconds), value)
|
||||
|
||||
|
||||
def invalidate(namespace: str | None = None) -> int:
|
||||
"""Drop every cached entry, or just entries under ``namespace``.
|
||||
|
||||
Returns the number of removed entries. Callers should invoke this from
|
||||
write endpoints that affect the cached read so subsequent reads observe
|
||||
the new state.
|
||||
"""
|
||||
removed = 0
|
||||
with _lock:
|
||||
if namespace is None:
|
||||
removed = len(_cache)
|
||||
_cache.clear()
|
||||
return removed
|
||||
prefix = f"{namespace}:"
|
||||
keys = [k for k in _cache if k.startswith(prefix)]
|
||||
for k in keys:
|
||||
_cache.pop(k, None)
|
||||
removed = len(keys)
|
||||
return removed
|
||||
|
||||
|
||||
def memoize_ttl(namespace: str, ttl_seconds: int = 30):
|
||||
"""Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.
|
||||
|
||||
The key is derived from the namespace + a stable JSON dump of the args,
|
||||
so callers don't need to worry about mutable keyword order or unhashable
|
||||
defaults. JWT decorator should run *before* this one so unauthenticated
|
||||
traffic never hits the cache.
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
key = _make_key(namespace, args, kwargs)
|
||||
except Exception:
|
||||
return func(*args, **kwargs)
|
||||
cached = get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = func(*args, **kwargs)
|
||||
try:
|
||||
put(key, value, ttl_seconds)
|
||||
except Exception:
|
||||
_logger.debug("cache put failed for %s", namespace, exc_info=True)
|
||||
return value
|
||||
wrapper._encoach_cache_namespace = namespace
|
||||
return wrapper
|
||||
return decorator
|
||||
Reference in New Issue
Block a user