feat(v3): restructure project + add complete frontend
- 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
This commit is contained in:
1
backend/custom_addons/encoach_api/__init__.py
Normal file
1
backend/custom_addons/encoach_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import controllers
|
||||
11
backend/custom_addons/encoach_api/__manifest__.py
Normal file
11
backend/custom_addons/encoach_api/__manifest__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
'name': 'EnCoach API Base',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API',
|
||||
'author': 'EnCoach',
|
||||
'depends': ['base', 'encoach_core'],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import base
|
||||
from . import auth
|
||||
146
backend/custom_addons/encoach_api/controllers/auth.py
Normal file
146
backend/custom_addons/encoach_api/controllers/auth.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
from .base import (
|
||||
_json_response, _error_response, _get_json_body, _get_jwt_secret, validate_token,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncoachAuthController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/login
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/login', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def login(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
login = (body.get('login') or body.get('email') or '').strip().lower()
|
||||
password = body.get('password', '')
|
||||
|
||||
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,
|
||||
'password': password,
|
||||
}
|
||||
try:
|
||||
request.session.authenticate(request.env, credential)
|
||||
uid = request.session.uid
|
||||
if not uid:
|
||||
return _error_response('Invalid email or password', 401)
|
||||
except AccessDenied:
|
||||
return _error_response('Invalid email or password', 401)
|
||||
except Exception as auth_err:
|
||||
_logger.warning('Auth error for %s: %s', login, auth_err)
|
||||
return _error_response('Invalid email or password', 401)
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
# 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,
|
||||
'user': self._user_to_dict(user),
|
||||
'permissions': permissions,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('login failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/user (returns current authenticated user)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/user', type='http', auth='public',
|
||||
methods=['GET'], csrf=False)
|
||||
def get_current_user(self, **kw):
|
||||
try:
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
|
||||
permissions = []
|
||||
if hasattr(user, 'get_all_permissions'):
|
||||
permissions = user.get_all_permissions().mapped('code')
|
||||
|
||||
return _json_response({
|
||||
'user': self._user_to_dict(user),
|
||||
'permissions': permissions,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('get_current_user failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/logout
|
||||
# ------------------------------------------------------------------
|
||||
@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.
|
||||
return _json_response({'ok': True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _user_to_dict(self, user):
|
||||
"""Convert res.users to the dict shape the React frontend expects."""
|
||||
entities = []
|
||||
if hasattr(user, 'entity_ids'):
|
||||
entities = [
|
||||
{'id': e.id, 'name': e.name, 'role': ''}
|
||||
for e in user.entity_ids
|
||||
]
|
||||
|
||||
classrooms = []
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name or '',
|
||||
'email': user.email or '',
|
||||
'login': user.login or '',
|
||||
'user_type': getattr(user, '_api_user_type', lambda: user.user_type or 'student')()
|
||||
if callable(getattr(user, '_api_user_type', None))
|
||||
else (user.user_type or 'student'),
|
||||
'avatar': bool(getattr(user, 'encoach_avatar', False)),
|
||||
'phone': user.phone or '',
|
||||
'country': '',
|
||||
'timezone': '',
|
||||
'bio': '',
|
||||
'gender': getattr(user, 'gender', '') or '',
|
||||
'student_id': '',
|
||||
'is_verified': getattr(user, 'is_verified', False),
|
||||
'entities': entities,
|
||||
'classrooms': classrooms,
|
||||
'expiry_date': '',
|
||||
}
|
||||
153
backend/custom_addons/encoach_api/controllers/base.py
Normal file
153
backend/custom_addons/encoach_api/controllers/base.py
Normal file
@@ -0,0 +1,153 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user