- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
154 lines
4.5 KiB
Python
154 lines
4.5 KiB
Python
import json
|
|
import functools
|
|
import logging
|
|
import time
|
|
|
|
import jwt as pyjwt
|
|
|
|
from odoo.http import request
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_jwt_secret_cache = {"secret": None, "ts": 0}
|
|
_JWT_SECRET_TTL = 300
|
|
|
|
_user_exists_cache = {}
|
|
_USER_CACHE_TTL = 60
|
|
_USER_CACHE_MAX = 200
|
|
|
|
|
|
def _get_jwt_secret():
|
|
now = time.time()
|
|
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
|
|
return _jwt_secret_cache["secret"]
|
|
secret = (
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("encoach.jwt_secret")
|
|
)
|
|
if secret:
|
|
_jwt_secret_cache["secret"] = secret
|
|
_jwt_secret_cache["ts"] = now
|
|
return secret
|
|
|
|
|
|
def validate_token():
|
|
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
|
|
auth_header = request.httprequest.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return None
|
|
token = auth_header[7:]
|
|
secret = _get_jwt_secret()
|
|
if not secret:
|
|
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
|
|
return None
|
|
try:
|
|
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
|
|
except pyjwt.ExpiredSignatureError:
|
|
return None
|
|
except pyjwt.InvalidTokenError:
|
|
return None
|
|
user_id = payload.get("user_id")
|
|
if not user_id:
|
|
return None
|
|
|
|
now = time.time()
|
|
cache_key = int(user_id)
|
|
cached = _user_exists_cache.get(cache_key)
|
|
if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
|
|
return request.env["res.users"].sudo().browse(cache_key)
|
|
|
|
user = request.env["res.users"].sudo().browse(cache_key)
|
|
if not user.exists():
|
|
return None
|
|
|
|
_user_exists_cache[cache_key] = {"ts": now}
|
|
if len(_user_exists_cache) > _USER_CACHE_MAX:
|
|
oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
|
|
del _user_exists_cache[oldest_key]
|
|
return user
|
|
|
|
|
|
def jwt_required(func):
|
|
"""Decorator that validates the JWT token and sets request.env user context."""
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
user = validate_token()
|
|
if not user:
|
|
return _error_response("Authentication required", status=401)
|
|
request.update_env(user=user.id)
|
|
return func(*args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
def _json_response(data, status=200):
|
|
return request.make_json_response(data, status=status)
|
|
|
|
|
|
def _error_response(message, status=400, code=None):
|
|
body = {"error": message}
|
|
if code:
|
|
body["code"] = code
|
|
return request.make_json_response(body, status=status)
|
|
|
|
|
|
def _get_json_body():
|
|
try:
|
|
return json.loads(request.httprequest.get_data(as_text=True))
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
|
|
|
|
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
|
|
"""Paginate an Odoo model search or extract params from a kwargs dict.
|
|
|
|
Two calling conventions:
|
|
_paginate(Model, domain, page, size, order) → (recordset, total_count)
|
|
_paginate(kwargs_dict) → (offset, limit, page)
|
|
"""
|
|
if isinstance(model_or_kwargs, dict):
|
|
kwargs = model_or_kwargs
|
|
limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
|
|
page_raw = int(kwargs.get("page", 0))
|
|
pg = max(page_raw, 0)
|
|
offset = pg * limit
|
|
return offset, limit, pg
|
|
|
|
Model = model_or_kwargs
|
|
limit = min(max(int(size), 1), 200)
|
|
pg = max(int(page), 0)
|
|
offset = pg * limit
|
|
total = Model.search_count(domain or [])
|
|
records = Model.search(domain or [], offset=offset, limit=limit, order=order)
|
|
return records, total
|
|
|
|
|
|
class EncoachMixin:
|
|
"""Shared authentication and response helpers for all EnCoach API controllers."""
|
|
|
|
def _get_jwt_secret(self):
|
|
return _get_jwt_secret()
|
|
|
|
def _authenticate(self):
|
|
return validate_token()
|
|
|
|
def _json_response(self, data, status=200):
|
|
return _json_response(data, status=status)
|
|
|
|
def _error_response(self, message, status=400, code=None):
|
|
return _error_response(message, status=status, code=code)
|
|
|
|
def _get_json_body(self):
|
|
return _get_json_body()
|
|
|
|
def _paginate_params(self, kwargs):
|
|
return _paginate(kwargs)
|
|
|
|
def _serialize(self, record):
|
|
if hasattr(record, "to_encoach_dict"):
|
|
return record.to_encoach_dict()
|
|
return {"id": record.id}
|
|
|
|
def _serialize_list(self, records):
|
|
return [self._serialize(r) for r in records]
|