From 3a8a56a56fca67023fbf37c4899c53d3f506615f Mon Sep 17 00:00:00 2001 From: "bernat.roig" Date: Tue, 16 Sep 2025 15:44:46 +0200 Subject: [PATCH] jwt token --- rest_api_odoo/controllers/__init__.py | 1 + rest_api_odoo/controllers/jwt_auth.py | 179 ++ rest_api_odoo/controllers/rest_api_odoo.py | 580 ++++-- .../controllers/swagger_controller.py | 1571 ++++++++++------- rest_api_odoo/models/res_users.py | 170 +- rest_api_odoo/security/ir.model.access.csv | 3 +- rest_api_odoo/views/connection_api_views.xml | 152 +- rest_api_odoo/views/res_users_views.xml | 113 +- 8 files changed, 1768 insertions(+), 1001 deletions(-) create mode 100644 rest_api_odoo/controllers/jwt_auth.py diff --git a/rest_api_odoo/controllers/__init__.py b/rest_api_odoo/controllers/__init__.py index d04083111..f838eaa16 100644 --- a/rest_api_odoo/controllers/__init__.py +++ b/rest_api_odoo/controllers/__init__.py @@ -21,3 +21,4 @@ ############################################################################# from . import rest_api_odoo from . import swagger_controller +from . import jwt_auth diff --git a/rest_api_odoo/controllers/jwt_auth.py b/rest_api_odoo/controllers/jwt_auth.py new file mode 100644 index 000000000..9786ae643 --- /dev/null +++ b/rest_api_odoo/controllers/jwt_auth.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +import jwt +import base64 +import secrets +import logging +from datetime import datetime, timedelta +from odoo import fields +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class JWTAuthMixin: + """Mixin para autenticación JWT reutilizable""" + + def _get_jwt_secret(self): + """Obtiene la clave secreta para JWT desde configuración del sistema""" + secret = request.env['ir.config_parameter'].sudo().get_param('rest_api.jwt_secret') + if not secret: + # Generar y guardar una nueva clave secreta + try: + secret = base64.b64encode(secrets.token_bytes(32)).decode('utf-8') + except (ImportError, AttributeError): + # Fallback si secrets no está disponible + import uuid + import hashlib + secret = hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest() + + request.env['ir.config_parameter'].sudo().set_param('rest_api.jwt_secret', secret) + _logger.info("Generated new JWT secret key") + + return secret + + def _generate_jwt_token(self, user_id, expires_in_hours=24): + """ + Genera un JWT token para el usuario + Args: + user_id: ID del usuario + expires_in_hours: Horas hasta expiración (default: 24h) + Returns: + str: JWT token o None si hay error + """ + try: + now = datetime.utcnow() + payload = { + 'user_id': user_id, + 'iat': now, # Issued at + 'exp': now + timedelta(hours=expires_in_hours), # Expiration + 'iss': 'odoo-rest-api', # Issuer + 'aud': 'odoo-client', # Audience + 'jti': f"{user_id}_{int(now.timestamp())}" # JWT ID + } + + secret = self._get_jwt_secret() + token = jwt.encode(payload, secret, algorithm='HS256') + + _logger.info(f"Generated JWT token for user {user_id}, expires in {expires_in_hours}h") + return token + + except Exception as e: + _logger.error(f"Error generating JWT token: {str(e)}") + return None + + def _validate_jwt_token(self, token): + """ + Valida un JWT token + Args: + token: Token JWT (puede incluir 'Bearer ' al inicio) + Returns: + tuple: (success: bool, user_id: int or None, error_message: str or None) + """ + if not token: + return False, None, "Token no proporcionado" + + # Limpiar token (remover 'Bearer ' si está presente) + if token.startswith('Bearer '): + token = token[7:] + elif token.startswith('bearer '): + token = token[7:] + + try: + secret = self._get_jwt_secret() + + # Decodificar y validar token + payload = jwt.decode( + token, + secret, + algorithms=['HS256'], + audience='odoo-client', + issuer='odoo-rest-api' + ) + + user_id = payload.get('user_id') + if not user_id: + return False, None, "Token inválido: user_id no encontrado" + + # Verificar que el usuario existe y está activo + user = request.env['res.users'].sudo().browse(user_id) + if not user.exists(): + return False, None, "Usuario no encontrado" + + if not user.active: + return False, None, "Usuario inactivo" + + # Configurar contexto de sesión + request.session.uid = user_id + if hasattr(request, 'env'): + request.env.user = user + + # Log successful authentication + _logger.debug(f"JWT authentication successful for user {user_id} ({user.login})") + + return True, user_id, None + + except jwt.ExpiredSignatureError: + _logger.warning("JWT token expired") + return False, None, "Token expirado" + except jwt.InvalidTokenError as e: + _logger.warning(f"Invalid JWT token: {str(e)}") + return False, None, f"Token inválido: {str(e)}" + except jwt.InvalidAudienceError: + _logger.warning("JWT token has invalid audience") + return False, None, "Token inválido: audiencia incorrecta" + except jwt.InvalidIssuerError: + _logger.warning("JWT token has invalid issuer") + return False, None, "Token inválido: emisor incorrecto" + except Exception as e: + _logger.error(f"Error validating JWT token: {str(e)}") + return False, None, "Error interno validando token" + + def _decode_jwt_payload(self, token): + """ + Decodifica un JWT token sin validar (útil para debugging) + Args: + token: Token JWT + Returns: + dict: Payload del token o None si hay error + """ + try: + if token.startswith('Bearer '): + token = token[7:] + + # Decodificar sin verificar (solo para obtener payload) + payload = jwt.decode(token, options={"verify_signature": False}) + return payload + except Exception as e: + _logger.error(f"Error decoding JWT payload: {str(e)}") + return None + + def _get_token_info(self, token): + """ + Obtiene información de un JWT token + Args: + token: Token JWT + Returns: + dict: Información del token + """ + payload = self._decode_jwt_payload(token) + if not payload: + return None + + try: + exp_timestamp = payload.get('exp') + iat_timestamp = payload.get('iat') + + info = { + 'user_id': payload.get('user_id'), + 'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None, + 'expires_at': datetime.fromtimestamp(exp_timestamp) if exp_timestamp else None, + 'issuer': payload.get('iss'), + 'audience': payload.get('aud'), + 'jwt_id': payload.get('jti'), + 'is_expired': datetime.utcnow() > datetime.fromtimestamp(exp_timestamp) if exp_timestamp else True + } + + return info + except Exception as e: + _logger.error(f"Error getting token info: {str(e)}") + return None diff --git a/rest_api_odoo/controllers/rest_api_odoo.py b/rest_api_odoo/controllers/rest_api_odoo.py index 5309d39d7..5d845dedc 100644 --- a/rest_api_odoo/controllers/rest_api_odoo.py +++ b/rest_api_odoo/controllers/rest_api_odoo.py @@ -1,47 +1,40 @@ # -*- coding: utf-8 -*- -############################################################################# -# -# Cybrosys Technologies Pvt. Ltd. -# -# Copyright (C) 2024-TODAY Cybrosys Technologies() -# Author: Ayana KP (odoo@cybrosys.com) -# Modified by: Broigm - Improvements in authentication and structure -# -# You can modify it under the terms of the GNU LESSER -# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. -# -# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE -# (LGPL v3) along with this program. -# If not, see . -# -############################################################################# import json import logging import base64 +import ast from datetime import datetime, date, timedelta -from odoo import http +from odoo import http, fields from odoo.http import request -from werkzeug.exceptions import BadRequest, Unauthorized, NotFound, MethodNotAllowed +from .jwt_auth import JWTAuthMixin _logger = logging.getLogger(__name__) -class RestApi(http.Controller): - """Controlador API REST mejorado con autenticación basada en API Key solamente""" +class RestApi(http.Controller, JWTAuthMixin): + """Controlador API REST mejorado con JWT y filtrado avanzado""" def _json_response(self, data, status=200): """Genera respuesta JSON estandarizada""" - response = request.make_response( - json.dumps(data, ensure_ascii=False, indent=2), - headers=[('Content-Type', 'application/json')] - ) - response.status_code = status - return response + try: + response = request.make_response( + json.dumps(data, ensure_ascii=False, indent=2, default=str), + headers=[('Content-Type', 'application/json; charset=utf-8')] + ) + response.status_code = status + return response + except Exception as e: + _logger.error(f"Error creating JSON response: {str(e)}") + fallback_data = { + 'error': True, + 'message': 'Error interno creando respuesta JSON', + 'status_code': 500 + } + return request.make_response( + json.dumps(fallback_data, indent=2), + status=500, + headers=[('Content-Type', 'application/json; charset=utf-8')] + ) def _error_response(self, message, status=400, error_code=None): """Genera respuesta de error estandarizada""" @@ -55,41 +48,6 @@ class RestApi(http.Controller): return self._json_response(error_data, status) - def _authenticate_api_key(self, api_key): - """ - Autentica usando solo la API key y configura la sesión del usuario - Returns: (success: bool, user_id: int or None, error_message: str or None) - """ - if not api_key: - return False, None, "API Key no proporcionada" - - try: - user = request.env['res.users'].sudo().search([ - ('api_key', '=', api_key), - ('active', '=', True) - ], limit=1) - - if not user: - return False, None, "API Key inválida o usuario inactivo" - - # Verificar si la API key no ha expirado (si implementas expiración) - if hasattr(user, 'api_key_expiry') and user.api_key_expiry: - if user.api_key_expiry < datetime.now(): - return False, None, "API Key expirada" - - # Configurar la sesión con el usuario autenticado - request.session.uid = user.id - request.env.user = user - - # Actualizar estadísticas de uso - user.update_api_key_usage() - - return True, user.id, None - - except Exception as e: - _logger.error(f"Error en autenticación API: {str(e)}") - return False, None, "Error interno de autenticación" - def _serialize_record_values(self, records): """Serializa los valores de los registros para JSON""" if not records: @@ -99,102 +57,170 @@ class RestApi(http.Controller): for record in records: serialized_record = {} for key, value in record.items(): - if isinstance(value, (datetime, date)): - serialized_record[key] = value.isoformat() - elif isinstance(value, bytes): - serialized_record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - try: - serialized_record[key] = list(value) if value else [] - except: - serialized_record[key] = str(value) - else: - serialized_record[key] = value + try: + if isinstance(value, (datetime, date)): + serialized_record[key] = value.isoformat() + elif isinstance(value, bytes): + serialized_record[key] = base64.b64encode(value).decode('utf-8') + elif isinstance(value, tuple) and len(value) == 2: + # Para relaciones Many2one que vienen como (id, name) + serialized_record[key] = list(value) + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict, bytes)): + try: + serialized_record[key] = list(value) if value else [] + except: + serialized_record[key] = str(value) + else: + serialized_record[key] = value + except Exception as e: + _logger.warning(f"Error serializing field {key}: {str(e)}") + serialized_record[key] = str(value) if value is not None else None + serialized_records.append(serialized_record) return serialized_records def _get_model_config(self, model_name): """Obtiene la configuración de la API para un modelo""" - model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1) - if not model_obj: - return None, "Modelo no encontrado" + try: + model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1) + if not model_obj: + return None, "Modelo no encontrado" - api_config = request.env['connection.api'].sudo().search([ - ('model_id', '=', model_obj.id), - ('active', '=', True) - ], limit=1) + api_config = request.env['connection.api'].sudo().search([ + ('model_id', '=', model_obj.id), + ('active', '=', True) + ], limit=1) - if not api_config: - return None, "Modelo no configurado para API REST" + if not api_config: + return None, "Modelo no configurado para API REST" - return api_config, None + return api_config, None + except Exception as e: + _logger.error(f"Error getting model config for {model_name}: {str(e)}") + return None, f"Error obteniendo configuración del modelo: {str(e)}" def _parse_request_data(self, method): - """Parsea los datos de la request según el método HTTP""" + """Parsea los datos de la request con parámetros avanzados""" data = {} fields = [] + domain = [] + limit = None + offset = None + order = None - if method == 'GET': - # Para GET, intentar obtener campos del query string o JSON body - query_params = dict(request.httprequest.args) + try: + if method == 'GET': + query_params = dict(request.httprequest.args) - try: - if request.httprequest.data: - json_data = json.loads(request.httprequest.data) - data.update(json_data) - if 'fields' in json_data: - fields = json_data['fields'] - except json.JSONDecodeError: - pass - - if not fields and 'fields' in query_params: - fields = [field.strip() for field in query_params['fields'].split(',')] - - elif method in ['POST', 'PUT']: - try: - if request.httprequest.data: - data = json.loads(request.httprequest.data) - if 'fields' in data: - fields = data['fields'] - else: - return None, None, "No se proporcionaron datos JSON" - except json.JSONDecodeError: - return None, None, "JSON inválido" + # Parsear domain + if 'domain' in query_params: + try: + domain = ast.literal_eval(query_params['domain']) + if not isinstance(domain, list): + domain = [] + except: + _logger.warning("Invalid domain format, ignoring") + domain = [] + + # Parsear fields + if 'fields' in query_params: + fields = [field.strip() for field in query_params['fields'].split(',') if field.strip()] - return data, fields, None + # Parsear limit + if 'limit' in query_params: + try: + limit = int(query_params['limit']) + if limit <= 0: + limit = None + except: + pass + + # Parsear offset + if 'offset' in query_params: + try: + offset = int(query_params['offset']) + if offset < 0: + offset = None + except: + pass + + # Parsear order + if 'order' in query_params: + order = query_params['order'].strip() + if not order: + order = None + + # También intentar JSON body para GET (opcional) + try: + if request.httprequest.data: + json_data = json.loads(request.httprequest.data.decode('utf-8')) + data.update(json_data) + if 'fields' in json_data and not fields: + fields = json_data['fields'] + if 'domain' in json_data and not domain: + domain = json_data['domain'] + except: + pass + + elif method in ['POST', 'PUT']: + try: + if request.httprequest.data: + data = json.loads(request.httprequest.data.decode('utf-8')) + if 'fields' in data: + fields = data['fields'] + else: + return None, None, None, None, None, None, "No se proporcionaron datos JSON" + except (json.JSONDecodeError, UnicodeDecodeError) as e: + return None, None, None, None, None, None, f"JSON inválido: {str(e)}" + + return data, fields, domain, limit, offset, order, None + except Exception as e: + _logger.error(f"Error parsing request data: {str(e)}") + return None, None, None, None, None, None, f"Error procesando datos de la request: {str(e)}" @http.route(['/api/v1/auth'], type='http', auth='none', methods=['POST'], csrf=False) def authenticate(self, **kw): - """Endpoint de autenticación que genera API key""" + """Endpoint de autenticación que genera JWT token""" try: - data = json.loads(request.httprequest.data or '{}') - except json.JSONDecodeError: + if request.httprequest.data: + data = json.loads(request.httprequest.data.decode('utf-8')) + else: + data = {} + except (json.JSONDecodeError, UnicodeDecodeError): return self._error_response("JSON inválido", 400) username = data.get('username') or request.httprequest.headers.get('username') password = data.get('password') or request.httprequest.headers.get('password') - database = data.get('database') or request.httprequest.headers.get('database', request.env.cr.dbname) + database = data.get('database') or request.httprequest.headers.get('database') or request.env.cr.dbname + expires_in = data.get('expires_in_hours', 24) if not all([username, password]): return self._error_response("Username y password son requeridos", 400) - try: - # Actualizar sesión con la base de datos - request.session.update(http.get_default_session(), db=database) + # Validar expires_in + if not isinstance(expires_in, int) or expires_in < 1 or expires_in > 168: # Max 7 días + expires_in = 24 + try: # Autenticar credenciales - auth_result = request.session.authenticate( - database, - {'login': username, 'password': password, 'type': 'password'} - ) + credential = {'login': username, 'password': password, 'type': 'password'} + auth_result = request.session.authenticate(database, credential) if not auth_result: return self._error_response("Credenciales inválidas", 401) - # Generar o recuperar API key - user = request.env['res.users'].browse(auth_result['uid']) - api_key = user.generate_api_key() + uid = auth_result['uid'] + + if not uid: + return self._error_response("Credenciales inválidas", 401) + + # Generar JWT token + user = request.env['res.users'].browse(uid) + token = self._generate_jwt_token(uid, expires_in) + + if not token: + return self._error_response("Error generando token de acceso", 500) response_data = { "success": True, @@ -203,7 +229,9 @@ class RestApi(http.Controller): "user_id": user.id, "username": user.login, "name": user.name, - "api_key": api_key, + "access_token": token, + "token_type": "Bearer", + "expires_in": expires_in * 3600, # En segundos "database": database } } @@ -214,16 +242,72 @@ class RestApi(http.Controller): _logger.error(f"Error en autenticación: {str(e)}") return self._error_response("Error interno de autenticación", 500) + @http.route(['/api/v1/refresh'], type='http', auth='none', methods=['POST'], csrf=False) + def refresh_token(self, **kw): + """Endpoint para refrescar un JWT token""" + try: + success, user_id, error_msg = self._authenticate_request() + if not success: + return self._error_response(error_msg, 401, "TOKEN_INVALID") + + # Generar nuevo token + try: + data = json.loads(request.httprequest.data.decode('utf-8')) if request.httprequest.data else {} + expires_in = data.get('expires_in_hours', 24) + if not isinstance(expires_in, int) or expires_in < 1 or expires_in > 168: + expires_in = 24 + except: + expires_in = 24 + + new_token = self._generate_jwt_token(user_id, expires_in) + if not new_token: + return self._error_response("Error generando nuevo token", 500) + + user = request.env['res.users'].browse(user_id) + response_data = { + "success": True, + "message": "Token renovado exitosamente", + "data": { + "access_token": new_token, + "token_type": "Bearer", + "expires_in": expires_in * 3600, + "user_id": user.id, + "username": user.login + } + } + + return self._json_response(response_data) + + except Exception as e: + _logger.error(f"Error refreshing token: {str(e)}") + return self._error_response("Error interno renovando token", 500) + + def _authenticate_request(self): + """ + Autentica la request usando JWT token + Returns: (success: bool, user_id: int or None, error_message: str or None) + """ + # Buscar token en headers + auth_header = request.httprequest.headers.get('Authorization') + if not auth_header: + # Fallback a headers alternativos para compatibilidad + token = request.httprequest.headers.get('X-API-Key') or request.httprequest.headers.get('api-key') + if token: + auth_header = f"Bearer {token}" + + if not auth_header: + return False, None, "Token de autorización no proporcionado (use Authorization: Bearer )" + + return self._validate_jwt_token(auth_header) + @http.route(['/api/v1/', '/api/v1//'], type='http', auth='none', methods=['GET', 'POST', 'PUT', 'DELETE'], csrf=False) def api_handler(self, model_name, record_id=None, **kw): - """Endpoint principal de la API REST""" + """Endpoint principal de la API REST con JWT y filtrado avanzado""" method = request.httprequest.method - # Autenticación usando API key - api_key = request.httprequest.headers.get('X-API-Key') or request.httprequest.headers.get('api-key') - success, user_id, error_msg = self._authenticate_api_key(api_key) - + # Autenticación usando JWT + success, user_id, error_msg = self._authenticate_request() if not success: return self._error_response(error_msg, 401, "AUTHENTICATION_FAILED") @@ -243,56 +327,108 @@ class RestApi(http.Controller): if not method_permissions.get(method, False): return self._error_response(f"Método {method} no permitido para este modelo", 405, "METHOD_NOT_ALLOWED") - # Parsear datos de la request - data, fields, error_msg = self._parse_request_data(method) + # Parsear datos de la request con parámetros avanzados + data, fields, domain, limit, offset, order, error_msg = self._parse_request_data(method) if error_msg: return self._error_response(error_msg, 400, "INVALID_REQUEST_DATA") try: - return self._handle_request(method, api_config.model_id.model, record_id, data, fields) + return self._handle_request(method, api_config.model_id.model, record_id, data, fields, domain, limit, offset, order, api_config) except Exception as e: _logger.error(f"Error procesando request {method} para {model_name}: {str(e)}") return self._error_response("Error interno del servidor", 500, "INTERNAL_SERVER_ERROR") - def _handle_request(self, method, model_name, record_id, data, fields): - """Maneja las diferentes operaciones CRUD""" - model = request.env[model_name] - - if method == 'GET': - return self._handle_get(model, record_id, fields) - elif method == 'POST': - return self._handle_post(model, data, fields) - elif method == 'PUT': - return self._handle_put(model, record_id, data, fields) - elif method == 'DELETE': - return self._handle_delete(model, record_id) - - def _handle_get(self, model, record_id, fields): - """Maneja requests GET""" + def _handle_request(self, method, model_name, record_id, data, fields, domain, limit, offset, order, api_config=None): + """Maneja las diferentes operaciones CRUD con parámetros avanzados""" try: + model = request.env[model_name] + + if method == 'GET': + return self._handle_get(model, record_id, fields, domain, limit, offset, order, api_config) + elif method == 'POST': + return self._handle_post(model, data, fields) + elif method == 'PUT': + return self._handle_put(model, record_id, data, fields) + elif method == 'DELETE': + return self._handle_delete(model, record_id) + except Exception as e: + _logger.error(f"Error in _handle_request: {str(e)}") + raise + + def _handle_get(self, model, record_id, fields, domain, limit, offset, order, api_config=None): + """Maneja requests GET con filtrado avanzado""" + try: + # Aplicar límites de la configuración + max_limit = getattr(api_config, 'max_records_limit', 1000) if api_config else 1000 + if limit and limit > max_limit: + limit = max_limit + if record_id: # Obtener registro específico - domain = [('id', '=', record_id)] + search_domain = [('id', '=', record_id)] search_fields = fields if fields else [] + records = model.search_read(domain=search_domain, fields=search_fields) + total_count = len(records) + + response_data = { + "success": True, + "count": len(records), + "total": total_count, + "data": self._serialize_record_values(records) + } else: - # Obtener todos los registros - domain = [] + # Obtener registros con filtros + search_domain = domain if domain else [] search_fields = fields if fields else ['id', 'display_name'] - records = model.search_read(domain=domain, fields=search_fields) - serialized_records = self._serialize_record_values(records) + # Contar total sin límite para metadatos + try: + total_count = model.search_count(search_domain) + except: + total_count = None - response_data = { - "success": True, - "count": len(serialized_records), - "data": serialized_records - } + # Búsqueda con parámetros + search_params = { + 'domain': search_domain, + 'fields': search_fields + } + + if limit: + search_params['limit'] = limit + if offset: + search_params['offset'] = offset + if order: + search_params['order'] = order + + records = model.search_read(**search_params) + + response_data = { + "success": True, + "count": len(records), + "data": self._serialize_record_values(records) + } + + # Agregar metadatos de paginación + if total_count is not None: + response_data["total"] = total_count + if offset: + response_data["offset"] = offset + if limit: + response_data["limit"] = limit + + # Información de paginación + if limit and total_count is not None: + current_offset = offset or 0 + has_more = (current_offset + limit) < total_count + response_data["has_more"] = has_more + if has_more: + response_data["next_offset"] = current_offset + limit return self._json_response(response_data) except Exception as e: _logger.error(f"Error en GET: {str(e)}") - return self._error_response("Error obteniendo registros", 500) + return self._error_response(f"Error obteniendo registros: {str(e)}", 500) def _handle_post(self, model, data, fields): """Maneja requests POST (crear)""" @@ -305,12 +441,12 @@ class RestApi(http.Controller): # Obtener el registro creado con los campos especificados search_fields = fields if fields else ['id', 'display_name'] record_data = new_record.read(search_fields)[0] - serialized_record = self._serialize_record_values([record_data]) response_data = { "success": True, "message": "Registro creado exitosamente", - "data": serialized_record[0] if serialized_record else {} + "count": 1, + "data": self._serialize_record_values([record_data]) } return self._json_response(response_data, 201) @@ -337,12 +473,12 @@ class RestApi(http.Controller): # Obtener el registro actualizado search_fields = fields if fields else ['id', 'display_name'] record_data = record.read(search_fields)[0] - serialized_record = self._serialize_record_values([record_data]) response_data = { "success": True, "message": "Registro actualizado exitosamente", - "data": serialized_record[0] if serialized_record else {} + "count": 1, + "data": self._serialize_record_values([record_data]) } return self._json_response(response_data) @@ -384,9 +520,7 @@ class RestApi(http.Controller): @http.route(['/api/v1/models'], type='http', auth='none', methods=['GET'], csrf=False) def list_available_models(self, **kw): """Endpoint para listar modelos disponibles en la API""" - api_key = request.httprequest.headers.get('X-API-Key') or request.httprequest.headers.get('api-key') - success, user_id, error_msg = self._authenticate_api_key(api_key) - + success, user_id, error_msg = self._authenticate_request() if not success: return self._error_response(error_msg, 401) @@ -398,11 +532,18 @@ class RestApi(http.Controller): model_info = { "model": config.model_id.model, "name": config.model_id.name, + "description": config.description or f"API REST para el modelo {config.model_id.name}", "methods": { "GET": config.is_get, "POST": config.is_post, "PUT": config.is_put, "DELETE": config.is_delete + }, + "max_records_limit": config.max_records_limit, + "endpoints": { + "collection": f"/api/v1/{config.model_id.model}", + "item": f"/api/v1/{config.model_id.model}/{{id}}", + "schema": f"/api/v1/schema/{config.model_id.model}" } } models_data.append(model_info) @@ -416,6 +557,12 @@ class RestApi(http.Controller): "documentation": { "swagger_ui": f"{base_url}/api/v1/docs", "openapi_spec": f"{base_url}/api/v1/openapi.json" + }, + "authentication": { + "type": "JWT Bearer Token", + "header": "Authorization: Bearer ", + "auth_endpoint": f"{base_url}/api/v1/auth", + "refresh_endpoint": f"{base_url}/api/v1/refresh" } } @@ -425,21 +572,86 @@ class RestApi(http.Controller): _logger.error(f"Error listando modelos: {str(e)}") return self._error_response("Error interno del servidor", 500) + @http.route(['/api/v1/health'], type='http', auth='none', methods=['GET'], csrf=False) + def health_check(self, **kwargs): + """Endpoint de verificación de salud de la API""" + try: + # Verificar conexión a BD + request.env.cr.execute("SELECT 1") + + # Verificar configuraciones activas + active_configs = len(request.env["connection.api"].sudo().search([("active", "=", True)])) + + # Verificar configuración JWT + jwt_secret = request.env['ir.config_parameter'].sudo().get_param('rest_api.jwt_secret') + + health_status = { + "status": "healthy", + "timestamp": fields.Datetime.now().isoformat(), + "database": request.env.cr.dbname, + "active_models": active_configs, + "version": "2.0.0", + "auth_method": "JWT Bearer Token", + "jwt_configured": bool(jwt_secret), + "features": { + "dynamic_schemas": True, + "advanced_filtering": True, + "jwt_authentication": True, + "pagination": True, + "field_selection": True + } + } + + return self._json_response(health_status) + + except Exception as e: + error_status = { + "status": "unhealthy", + "error": str(e), + "timestamp": fields.Datetime.now().isoformat() + } + return request.make_response( + json.dumps(error_status), + status=503, + headers=[("Content-Type", "application/json; charset=utf-8")] + ) + @http.route(['/api', '/api/'], type='http', auth='none', methods=['GET'], csrf=False) def api_root(self, **kw): - """Endpoint raíz de la API que redirige a la documentación""" - base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') - - api_info = { - "message": "Bienvenido a la REST API de Odoo", - "version": "1.0.0", - "documentation": f"{base_url}/api/v1/docs", - "endpoints": { - "auth": f"{base_url}/api/v1/auth", - "models": f"{base_url}/api/v1/models", - "docs": f"{base_url}/api/v1/docs", - "openapi": f"{base_url}/api/v1/openapi.json" + """Endpoint raíz de la API que proporciona información básica""" + try: + base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') + + api_info = { + "message": "Bienvenido a la REST API de Odoo v2.0", + "version": "2.0.0", + "status": "active", + "documentation": f"{base_url}/api/v1/docs", + "features": [ + "JWT Bearer Token Authentication", + "Dynamic Schema Generation", + "Advanced Filtering with Domain", + "Pagination Support", + "Field Selection", + "Interactive Swagger Documentation" + ], + "endpoints": { + "auth": f"{base_url}/api/v1/auth", + "refresh": f"{base_url}/api/v1/refresh", + "models": f"{base_url}/api/v1/models", + "health": f"{base_url}/api/v1/health", + "docs": f"{base_url}/api/v1/docs", + "openapi": f"{base_url}/api/v1/openapi.json" + }, + "authentication": { + "type": "JWT Bearer Token", + "header": "Authorization", + "format": "Bearer ", + "expires_in_hours": "configurable (default: 24h)" + } } - } - return self._json_response(api_info) + return self._json_response(api_info) + except Exception as e: + _logger.error(f"Error en api_root: {str(e)}") + return self._error_response("Error interno del servidor", 500) diff --git a/rest_api_odoo/controllers/swagger_controller.py b/rest_api_odoo/controllers/swagger_controller.py index 193ec55f6..2b2e68cdb 100755 --- a/rest_api_odoo/controllers/swagger_controller.py +++ b/rest_api_odoo/controllers/swagger_controller.py @@ -1,741 +1,998 @@ # -*- coding: utf-8 -*- -############################################################################# -# -# Cybrosys Technologies Pvt. Ltd. -# -# Copyright (C) 2024-TODAY Cybrosys Technologies() -# Author: Ayana KP (odoo@cybrosys.com) -# Modified by: [Tu nombre] - Agregado Swagger/OpenAPI Documentation -# -# You can modify it under the terms of the GNU LESSER -# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. -# -# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE -# (LGPL v3) along with this program. -# If not, see . -# -############################################################################# import json +import logging from datetime import datetime -from odoo import http +from odoo import http, fields from odoo.http import request +_logger = logging.getLogger(__name__) + class SwaggerController(http.Controller): - """Controlador para generar documentación Swagger/OpenAPI de la REST API""" + """Controlador Swagger/OpenAPI con esquemas dinámicos mejorados""" - @http.route( - ["/api/v1/docs", "/api/docs"], type="http", auth="none", methods=["GET"] - ) + @http.route(["/api/v1/docs", "/api/docs"], type="http", auth="none", methods=["GET"], csrf=False) def swagger_ui(self, **kwargs): - """Muestra la interfaz de Swagger UI""" - base_url = ( - request.env["ir.config_parameter"] - .sudo() - .get_param("web.base.url", "http://localhost:8069") - ) - - swagger_html = f""" - - - - - - Odoo REST API - Swagger Documentation - - + + +
+

🔐 Autenticación JWT

+

Esta API usa JWT Bearer Tokens. Usa el endpoint /auth para obtener tu token.

+

Luego agrega: Authorization: Bearer tu_token_aqui

+
+
+ + + + - - - - - """ - return request.make_response( - swagger_html, headers=[("Content-Type", "text/html; charset=utf-8")] - ) - - @http.route(["/api/v1/openapi.json"], type="http", auth="none", methods=["GET"]) + return res; + }} + }}); + window.ui = ui; + }} + + +""" + + return request.make_response(swagger_html, headers=[("Content-Type", "text/html; charset=utf-8")]) + + except Exception as e: + _logger.error(f"Error serving Swagger UI: {str(e)}") + return request.make_response(f"

Error

Could not load documentation: {str(e)}

", status=500) + + @http.route(["/api/v1/openapi.json"], type="http", auth="none", methods=["GET"], csrf=False) def openapi_spec(self, **kwargs): - """Genera la especificación OpenAPI/Swagger en formato JSON""" - - base_url = ( - request.env["ir.config_parameter"] - .sudo() - .get_param("web.base.url", "http://localhost:8069") - ) - db_name = request.env.cr.dbname - - # Obtener información de modelos configurados - api_configs = ( - request.env["connection.api"].sudo().search([("active", "=", True)]) - ) - - # Estructura base de OpenAPI 3.0 - openapi_spec = { - "openapi": "3.0.3", - "info": { - "title": "Odoo REST API", - "description": f""" - ## Odoo REST API Documentation - - Esta es la documentación interactiva de la REST API para Odoo. La API permite realizar operaciones CRUD en los modelos configurados de Odoo. - - ### Autenticación - - La API utiliza autenticación basada en API Key. Para obtener una API Key: - - 1. **Primer paso - Autenticarse:** - ```bash - curl -X POST {base_url}/api/v1/auth \\ - -H "Content-Type: application/json" \\ - -d '{{"username": "tu_usuario", "password": "tu_contraseña", "database": "{db_name}"}}' - 2. Usar la API Key en las requests: - - Agregar header: X-API-Key: tu_api_key_aqui - - O usar header: api-key: tu_api_key_aqui - - Formatos de Respuesta - Todas las respuestas siguen un formato estandarizado: - - Respuesta exitosa: - {{ - "success": true, - "count": 10, - "data": [...] - }} - Respuesta de error: - {{ - "error": true, - "message": "Descripción del error", - "status_code": 400, - "error_code": "ERROR_CODE" - }} - Campos Especiales - Fechas: Se devuelven en formato ISO 8601 (YYYY-MM-DDTHH:MM:SS) - - Archivos binarios: Se codifican en Base64 - - Relaciones Many2one: Se devuelven como [id, "display_name"] - - Relaciones One2many/Many2many: Se devuelven como arrays de IDs - - Modelos Disponibles - {self._get_available_models_description()} - """, - "version": "1.0.0", - "contact": {"name": "API Support", "email": "support@example.com"}, - "license": { - "name": "LGPL-3", - "url": "https://www.gnu.org/licenses/lgpl-3.0.html", - }, - }, - "servers": [ - {"url": f"{base_url}/api/v1", "description": "Production server"} - ], - "security": [{"ApiKeyAuth": []}], - "components": { - "securitySchemes": { - "ApiKeyAuth": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "API Key obtenida del endpoint de autenticación", - } - }, - "schemas": { - "ErrorResponse": { - "type": "object", - "properties": { - "error": {"type": "boolean", "example": True}, - "message": { - "type": "string", - "example": "Descripción del error", - }, - "status_code": {"type": "integer", "example": 400}, - "error_code": {"type": "string", "example": "ERROR_CODE"}, - }, - }, - "AuthRequest": { - "type": "object", - "required": ["username", "password"], - "properties": { - "username": {"type": "string", "example": "admin"}, - "password": {"type": "string", "example": "admin"}, - "database": {"type": "string", "example": f"{db_name}"}, - }, - }, - "AuthResponse": { - "type": "object", - "properties": { - "success": {"type": "boolean", "example": True}, - "message": { - "type": "string", - "example": "Autenticación exitosa", - }, - "data": { - "type": "object", - "properties": { - "user_id": {"type": "integer", "example": 2}, - "username": {"type": "string", "example": "admin"}, - "name": { - "type": "string", - "example": "Administrator", - }, - "api_key": { - "type": "string", - "example": "abcd1234...", - }, - "database": { - "type": "string", - "example": f"{db_name}", - }, - }, - }, - }, - }, - "SuccessResponse": { - "type": "object", - "properties": { - "success": {"type": "boolean", "example": True}, - "count": {"type": "integer", "example": 10}, - "data": {"type": "array", "items": {"type": "object"}}, - }, - }, + """Genera la especificación OpenAPI/Swagger con mejoras""" + try: + base_url = self._get_base_url() + api_configs = [] + + try: + api_configs = request.env["connection.api"].sudo().search([("active", "=", True)]) + except Exception as e: + _logger.warning(f"Could not load API configurations: {str(e)}") + + # Generar esquemas dinámicos mejorados + dynamic_schemas = self._generate_enhanced_schemas(api_configs) + + openapi_spec = { + "openapi": "3.0.3", + "info": { + "title": "Odoo REST API", + "description": self._get_enhanced_description(base_url), + "version": "2.0.0", + "contact": {"name": "API Support", "email": "support@example.com"}, + "license": {"name": "LGPL-3", "url": "https://www.gnu.org/licenses/lgpl-3.0.html"} }, - "responses": { - "UnauthorizedError": { - "description": "API Key missing or invalid", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } - }, + "servers": [{"url": f"{base_url}/api/v1", "description": "Production server"}], + "security": [{"BearerAuth": []}], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT Bearer token obtenido del endpoint /auth" + } }, - "NotFoundError": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } - }, - }, - "ValidationError": { - "description": "Invalid request data", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ErrorResponse"} - } - }, + "schemas": { + **self._get_base_schemas(), + **dynamic_schemas }, + "responses": self._get_common_responses() }, - }, - "paths": self._generate_paths(api_configs), - "tags": self._generate_tags(api_configs), - } - - return request.make_response( - json.dumps(openapi_spec, indent=2), - headers=[("Content-Type", "application/json; charset=utf-8")], - ) + "paths": self._generate_enhanced_paths(api_configs), + "tags": self._generate_enhanced_tags(api_configs) + } + return request.make_response( + json.dumps(openapi_spec, indent=2, ensure_ascii=False), + headers=[("Content-Type", "application/json; charset=utf-8")] + ) - def _get_available_models_description(self): - """Genera descripción de modelos disponibles""" - api_configs = request.env["connection.api"].sudo().search([("active", "=", True)]) - if not api_configs: - return "No hay modelos configurados actualmente." + except Exception as e: + _logger.error(f"Error generating OpenAPI spec: {str(e)}") + return request.make_response( + json.dumps({"error": f"Error loading API specification: {str(e)}"}), + headers=[("Content-Type", "application/json; charset=utf-8")] + ) - description = "Los siguientes modelos están disponibles:\n\n" - for config in api_configs: - methods = [] - if config.is_get: - methods.append("GET") - if config.is_post: - methods.append("POST") - if config.is_put: - methods.append("PUT") - if config.is_delete: - methods.append("DELETE") + @http.route(['/api/v1/schema/'], type='http', auth='none', methods=['GET'], csrf=False) + def get_model_schema(self, model_name, **kwargs): + """Obtiene el esquema de un modelo específico""" + try: + # Obtener configuración del modelo + model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1) + if not model_obj: + return self._error_response("Modelo no encontrado", 404) + + api_config = request.env['connection.api'].sudo().search([ + ('model_id', '=', model_obj.id), + ('active', '=', True) + ], limit=1) + + if not api_config: + return self._error_response("Modelo no configurado para API REST", 404) + + model_class = request.env[model_name].sudo() + schema = self._generate_enhanced_model_schema(model_class, api_config) + + response_data = { + "model": model_name, + "display_name": model_obj.name, + "schema": schema, + "endpoints": { + "collection": f"/api/v1/{model_name}", + "item": f"/api/v1/{model_name}/{{id}}" + }, + "available_methods": { + "GET": api_config.is_get, + "POST": api_config.is_post, + "PUT": api_config.is_put, + "DELETE": api_config.is_delete + } + } - description += f"- **{config.model_id.name}** (`{config.model_id.model}`) - Métodos: {', '.join(methods)}\n" + return request.make_response( + json.dumps(response_data, indent=2, ensure_ascii=False), + headers=[("Content-Type", "application/json; charset=utf-8")] + ) - return description + except Exception as e: + return request.make_response( + json.dumps({"error": f"Error getting schema: {str(e)}"}), + status=500, + headers=[("Content-Type", "application/json; charset=utf-8")] + ) + def _get_base_url(self): + """Obtiene la URL base del servidor""" + try: + if request.env: + return request.env["ir.config_parameter"].sudo().get_param("web.base.url", "http://localhost:8069") + return request.httprequest.host_url.rstrip('/') + except: + return "http://localhost:8069" + + def _get_enhanced_description(self, base_url): + """Descripción mejorada de la API""" + return f""" +# Odoo REST API v2.0 + +API REST completa para Odoo con autenticación JWT y documentación dinámica. + +## 🔐 Autenticación + +Esta API utiliza **JWT Bearer Tokens** para autenticación: + +1. **Obtener token:** +```bash +curl -X POST {base_url}/api/v1/auth \\ + -H "Content-Type: application/json" \\ + -d '{{"username": "admin", "password": "admin"}}' +``` + +2. **Usar token en requests:** +```bash +curl -X GET {base_url}/api/v1/models \\ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## 📊 Características + +- **Esquemas dinámicos** basados en modelos reales de Odoo +- **Filtrado avanzado** con domain, limit, offset +- **Autenticación JWT** segura con expiración configurable +- **Validación automática** de tipos de datos +- **Documentación interactiva** con Swagger UI + +## 🚀 Endpoints Principales + +- `POST /auth` - Autenticación y obtención de token +- `POST /refresh` - Renovar token JWT +- `GET /models` - Lista de modelos disponibles +- `GET /health` - Estado de la API +- `GET /schema/{{model}}` - Esquema específico de un modelo + +## 📝 Formato de Respuestas + +Todas las respuestas siguen un formato consistente: + +**Éxito:** +```json +{{ + "success": true, + "count": 10, + "data": [...] +}} +``` + +**Error:** +```json +{{ + "error": true, + "message": "Descripción del error", + "status_code": 400, + "error_code": "ERROR_CODE" +}} +``` +""" + + def _get_base_schemas(self): + """Esquemas base mejorados""" + return { + "ErrorResponse": { + "type": "object", + "required": ["error", "message", "status_code"], + "properties": { + "error": {"type": "boolean", "example": True}, + "message": {"type": "string", "example": "Error description"}, + "status_code": {"type": "integer", "example": 400}, + "error_code": {"type": "string", "example": "VALIDATION_ERROR"} + } + }, + "AuthRequest": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": {"type": "string", "example": "admin"}, + "password": {"type": "string", "format": "password", "example": "admin"}, + "database": {"type": "string", "example": "odoo"}, + "expires_in_hours": {"type": "integer", "minimum": 1, "maximum": 168, "default": 24, "example": 24} + } + }, + "AuthResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "message": {"type": "string", "example": "Authentication successful"}, + "data": { + "type": "object", + "properties": { + "user_id": {"type": "integer", "example": 2}, + "username": {"type": "string", "example": "admin"}, + "name": {"type": "string", "example": "Administrator"}, + "access_token": {"type": "string", "example": "eyJ0eXAiOiJKV1QiLCJhbGc..."}, + "token_type": {"type": "string", "example": "Bearer"}, + "expires_in": {"type": "integer", "example": 86400}, + "database": {"type": "string", "example": "odoo"} + } + } + } + }, + "HealthResponse": { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["healthy", "unhealthy"]}, + "timestamp": {"type": "string", "format": "date-time"}, + "database": {"type": "string"}, + "active_models": {"type": "integer"}, + "version": {"type": "string"}, + "auth_method": {"type": "string", "example": "JWT Bearer Token"} + } + } + } - def _generate_tags(self, api_configs): - """Genera tags para agrupar endpoints""" - tags = [ - { - "name": "Authentication", - "description": "Endpoints para autenticación y gestión de API keys", + def _get_common_responses(self): + """Respuestas comunes reutilizables""" + return { + "UnauthorizedError": { + "description": "Token JWT missing, invalid, or expired", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"}, + "example": { + "error": True, + "message": "Token expirado", + "status_code": 401, + "error_code": "TOKEN_EXPIRED" + } + } + } }, - { - "name": "System", - "description": "Endpoints del sistema (información de modelos disponibles)", + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}} + } }, - ] + "ValidationError": { + "description": "Invalid request data", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}} + } + } + } + + def _generate_enhanced_schemas(self, api_configs): + """Genera esquemas dinámicos mejorados""" + schemas = {} for config in api_configs: - tags.append( - { - "name": config.model_id.model, - "description": f"Operaciones CRUD para el modelo {config.model_id.name}", + try: + if not hasattr(config, 'model_id') or not config.model_id: + continue + + model_name = config.model_id.model + + try: + model_class = request.env[model_name].sudo() + except KeyError: + _logger.warning(f"Model {model_name} not found") + continue + + # Generar esquema mejorado del modelo + model_schema = self._generate_enhanced_model_schema(model_class, config) + schemas[f"{model_name}_values"] = model_schema + + # Esquemas de request + schemas[f"{model_name}_create_request"] = { + "type": "object", + "required": ["values"], + "properties": { + "values": {"$ref": f"#/components/schemas/{model_name}_values"}, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Campos específicos a retornar en la respuesta", + "example": list(model_schema.get("properties", {}).keys())[:5] + } + }, + "example": { + "values": self._generate_example_values(model_class, config), + "fields": ["id", "display_name"] + } } - ) - return tags + # Esquema de respuesta de lectura + read_properties = dict(model_schema.get("properties", {})) + read_properties.update({ + "id": {"type": "integer", "description": "ID único del registro", "example": 1}, + "display_name": {"type": "string", "description": "Nombre para mostrar"} + }) + schemas[f"{model_name}_read_response"] = { + "type": "object", + "properties": read_properties + } - def _generate_paths(self, api_configs): - """Genera todos los paths/endpoints de la API""" - paths = {} - - # Endpoint de autenticación - paths["/auth"] = { - "post": { - "tags": ["Authentication"], - "summary": "Authenticate user and get API key", - "description": "Autentica un usuario y devuelve una API key para usar en las demás requests", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/AuthRequest"} + # Respuesta de colección con metadatos + schemas[f"{model_name}_collection_response"] = { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "count": {"type": "integer", "example": 1}, + "total": {"type": "integer", "description": "Total de registros (sin limit)"}, + "offset": {"type": "integer", "description": "Registros omitidos"}, + "limit": {"type": "integer", "description": "Límite aplicado"}, + "data": { + "type": "array", + "items": {"$ref": f"#/components/schemas/{model_name}_read_response"} } - }, - }, - "responses": { - "200": { - "description": "Autenticación exitosa", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/AuthResponse"} - } - }, - }, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, - }, - "security": [], - } + } + } + + except Exception as e: + _logger.error(f"Error generating enhanced schema for model {config.model_id.model}: {str(e)}") + continue + + return schemas + + def _generate_enhanced_model_schema(self, model_class, config): + """Genera esquema mejorado para un modelo específico""" + properties = {} + required = [] + + try: + allowed_fields = self._get_allowed_fields(config) + forbidden_fields = self._get_forbidden_fields(config) + + for field_name, field_obj in model_class._fields.items(): + if field_name in forbidden_fields: + continue + + if allowed_fields and field_name not in allowed_fields: + continue + + field_schema = self._odoo_field_to_enhanced_json_schema(field_obj, field_name, model_class) + if field_schema: + properties[field_name] = field_schema + + if getattr(field_obj, 'required', False): + required.append(field_name) + + except Exception as e: + _logger.warning(f"Error processing enhanced model fields: {str(e)}") + + schema = { + "type": "object", + "properties": properties + } + + if required: + schema["required"] = required + + return schema + + def _odoo_field_to_enhanced_json_schema(self, field_obj, field_name, model_class): + """Convierte un campo de Odoo a esquema JSON mejorado""" + field_type = type(field_obj).__name__ + + base_schema = { + "description": getattr(field_obj, 'help', '') or getattr(field_obj, 'string', field_name) + } + + # Mapeo de tipos mejorado + type_mapping = { + 'Char': {"type": "string"}, + 'Text': {"type": "string"}, + 'Html': {"type": "string", "format": "html"}, + 'Boolean': {"type": "boolean"}, + 'Integer': {"type": "integer"}, + 'Float': {"type": "number", "format": "float"}, + 'Monetary': {"type": "number", "format": "currency"}, + 'Date': {"type": "string", "format": "date"}, + 'Datetime': {"type": "string", "format": "date-time"}, + 'Binary': {"type": "string", "format": "binary"}, + 'Selection': {"type": "string"}, + 'Many2one': {"type": "integer"}, + 'One2many': {"type": "array", "items": {"type": "integer"}}, + 'Many2many': {"type": "array", "items": {"type": "integer"}}, } - # Endpoint de modelos disponibles - paths["/models"] = { - "get": { - "tags": ["System"], - "summary": "List available models", - "description": "Lista todos los modelos disponibles en la API con sus métodos permitidos", - "responses": { - "200": { - "description": "Lista de modelos disponibles", + schema = type_mapping.get(field_type, {"type": "string"}) + schema.update(base_schema) + + # Mejoras específicas por tipo de campo + if field_type == 'Char' and hasattr(field_obj, 'size') and field_obj.size: + schema["maxLength"] = field_obj.size + + # Campos Selection con opciones reales + if field_type == 'Selection' and hasattr(field_obj, 'selection'): + try: + if callable(field_obj.selection): + try: + # Intentar obtener opciones dinámicas + options = field_obj.selection(model_class, field_name) + if options: + schema["enum"] = [opt[0] for opt in options if opt[0]] + schema["example"] = options[0][0] if options else None + schema["x-options"] = [{"value": opt[0], "label": opt[1]} for opt in options] + except: + schema["description"] += " (opciones dinámicas)" + else: + options = [opt[0] for opt in field_obj.selection if opt[0]] + if options: + schema["enum"] = options + schema["example"] = options[0] + schema["x-options"] = [{"value": opt[0], "label": opt[1]} for opt in field_obj.selection] + except Exception as e: + _logger.warning(f"Error getting selection options for {field_name}: {str(e)}") + + # Campos relacionales con información del modelo relacionado + if field_type == 'Many2one': + comodel_name = getattr(field_obj, 'comodel_name', None) + if comodel_name: + schema.update({ + "description": f"ID del registro relacionado del modelo {comodel_name}", + "x-related-model": comodel_name, + "minimum": 1 + }) + + # Campos One2many y Many2many + if field_type in ['One2many', 'Many2many']: + comodel_name = getattr(field_obj, 'comodel_name', None) + if comodel_name: + schema["description"] = f"Lista de IDs de registros del modelo {comodel_name}" + schema["x-related-model"] = comodel_name + schema["items"]["minimum"] = 1 + + # Mejores ejemplos basados en el nombre del campo + if "example" not in schema: + schema["example"] = self._get_smart_example(field_name, schema.get("type"), field_type) + + # Propiedades adicionales + if hasattr(field_obj, 'required') and field_obj.required: + schema["x-required"] = True + + if hasattr(field_obj, 'readonly') and field_obj.readonly: + schema["readOnly"] = True + + return schema + + def _get_smart_example(self, field_name, json_type, odoo_type): + """Genera ejemplos inteligentes basados en el nombre del campo""" + field_lower = field_name.lower() + + # Ejemplos específicos por nombre de campo + smart_examples = { + 'name': 'Ejemplo de nombre', + 'email': 'usuario@ejemplo.com', + 'phone': '+34123456789', + 'mobile': '+34987654321', + 'website': 'https://ejemplo.com', + 'url': 'https://ejemplo.com', + 'street': 'Calle Ejemplo 123', + 'city': 'Madrid', + 'zip': '28001', + 'description': 'Descripción detallada del elemento', + 'note': 'Nota adicional', + 'comment': 'Comentario del usuario', + 'reference': 'REF-001', + 'code': 'COD123', + 'login': 'usuario', + 'password': 'contraseña_segura', + 'price': 99.99, + 'amount': 100.0, + 'quantity': 1, + 'qty': 5, + } + + # Buscar coincidencias en el nombre del campo + for pattern, example in smart_examples.items(): + if pattern in field_lower: + return example + + # Ejemplos por tipo JSON + type_examples = { + "string": f"Valor de {field_name}", + "integer": 1, + "number": 10.5, + "boolean": True, + "array": [1, 2, 3] + } + + return type_examples.get(json_type, None) + + def _generate_example_values(self, model_class, config): + """Genera valores de ejemplo para un modelo""" + example_values = {} + + try: + allowed_fields = self._get_allowed_fields(config) + forbidden_fields = self._get_forbidden_fields(config) + + for field_name, field_obj in model_class._fields.items(): + if field_name in forbidden_fields: + continue + if allowed_fields and field_name not in allowed_fields: + continue + if field_name in ['id', 'create_date', 'write_date', 'create_uid', 'write_uid']: + continue + + field_type = type(field_obj).__name__ + example = self._get_smart_example(field_name, + self._get_json_type_for_odoo_field(field_type), + field_type) + if example is not None: + example_values[field_name] = example + + except Exception as e: + _logger.warning(f"Error generating example values: {str(e)}") + + return example_values + + def _get_json_type_for_odoo_field(self, odoo_type): + """Mapeo simple de tipo Odoo a tipo JSON""" + mapping = { + 'Char': 'string', 'Text': 'string', 'Html': 'string', 'Selection': 'string', + 'Integer': 'integer', 'Many2one': 'integer', + 'Float': 'number', 'Monetary': 'number', + 'Boolean': 'boolean', + 'One2many': 'array', 'Many2many': 'array' + } + return mapping.get(odoo_type, 'string') + + def _generate_enhanced_paths(self, api_configs): + """Genera paths mejorados con parámetros adicionales""" + paths = { + "/auth": { + "post": { + "tags": ["Authentication"], + "summary": "Authenticate and get JWT token", + "description": "Autentica credenciales y devuelve un JWT token para usar en requests subsiguientes", + "requestBody": { + "required": True, "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/SuccessResponse"} + "schema": {"$ref": "#/components/schemas/AuthRequest"} } - }, - }, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, - }, - } - } - - # Generar endpoints para cada modelo configurado - for config in api_configs: - model_name = config.model_id.model - model_display_name = config.model_id.name - - # Path para operaciones de colección (GET all, POST) - collection_path = f"/{model_name}" - paths[collection_path] = {} - - # Path para operaciones de item específico (GET one, PUT, DELETE) - item_path = f"/{model_name}/{{id}}" - paths[item_path] = {} - - # Obtener campos del modelo - model_fields = self._get_model_fields_info(model_name) - - # GET - Obtener todos los registros - if config.is_get: - paths[collection_path]["get"] = { - "tags": [model_name], - "summary": f"Get all {model_display_name} records", - "description": f"Obtiene todos los registros del modelo {model_display_name}", - "parameters": [ - { - "name": "fields", - "in": "query", - "description": "Campos específicos a retornar (separados por comas)", - "schema": {"type": "string", "example": "id,name,email"}, } - ], - "responses": { - "200": { - "description": "Lista de registros", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } - } - }, - }, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, }, - } - - # GET - Obtener registro específico - paths[item_path]["get"] = { - "tags": [model_name], - "summary": f"Get specific {model_display_name} record", - "description": f"Obtiene un registro específico del modelo {model_display_name}", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "description": "ID del registro", - "schema": {"type": "integer"}, - } - ], "responses": { "200": { - "description": "Registro encontrado", + "description": "Authentication successful", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } + "schema": {"$ref": "#/components/schemas/AuthResponse"} } - }, + } }, - "404": {"$ref": "#/components/responses/NotFoundError"}, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"} }, + "security": [] } - - # POST - Crear registro - if config.is_post: - paths[collection_path]["post"] = { - "tags": [model_name], - "summary": f"Create new {model_display_name} record", - "description": f"Crea un nuevo registro en el modelo {model_display_name}", + }, + "/refresh": { + "post": { + "tags": ["Authentication"], + "summary": "Refresh JWT token", + "description": "Genera un nuevo JWT token usando el token actual", "requestBody": { - "required": True, "content": { "application/json": { "schema": { "type": "object", - "required": ["values"], "properties": { - "values": { - "type": "object", - "description": "Datos del registro a crear", - "properties": model_fields, - }, - "fields": { - "type": "array", - "items": {"type": "string"}, - "description": "Campos a retornar en la respuesta", - }, - }, - }, - "example": self._get_model_example_data( - model_name, "create" - ), + "expires_in_hours": {"type": "integer", "default": 24} + } + } } - }, + } }, "responses": { - "201": { - "description": "Registro creado exitosamente", + "200": { + "description": "Token refreshed successfully", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } + "schema": {"$ref": "#/components/schemas/AuthResponse"} } - }, - }, - "400": {"$ref": "#/components/responses/ValidationError"}, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, - }, - } - - # PUT - Actualizar registro - if config.is_put: - paths[item_path]["put"] = { - "tags": [model_name], - "summary": f"Update {model_display_name} record", - "description": f"Actualiza un registro existente del modelo {model_display_name}", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "description": "ID del registro a actualizar", - "schema": {"type": "integer"}, - } - ], - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["values"], - "properties": { - "values": { - "type": "object", - "description": "Datos a actualizar", - "properties": model_fields, - }, - "fields": { - "type": "array", - "items": {"type": "string"}, - "description": "Campos a retornar en la respuesta", - }, - }, - }, - "example": self._get_model_example_data( - model_name, "update" - ), } }, - }, + "401": {"$ref": "#/components/responses/UnauthorizedError"} + } + } + }, + "/health": { + "get": { + "tags": ["System"], + "summary": "API health check", + "description": "Verifica el estado de salud de la API y conexiones", "responses": { "200": { - "description": "Registro actualizado exitosamente", + "description": "API is healthy", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } + "schema": {"$ref": "#/components/schemas/HealthResponse"} } - }, + } }, - "404": {"$ref": "#/components/responses/NotFoundError"}, - "400": {"$ref": "#/components/responses/ValidationError"}, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, + "503": { + "description": "API is unhealthy", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/HealthResponse"} + } + } + } }, + "security": [] } + } + } - # DELETE - Eliminar registro - if config.is_delete: - paths[item_path]["delete"] = { - "tags": [model_name], - "summary": f"Delete {model_display_name} record", - "description": f"Elimina un registro del modelo {model_display_name}", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "description": "ID del registro a eliminar", - "schema": {"type": "integer"}, + # Generar paths para cada modelo configurado + for config in api_configs: + try: + if not hasattr(config, 'model_id') or not config.model_id: + continue + + model_name = config.model_id.model + model_display_name = config.model_id.name + + collection_path = f"/{model_name}" + item_path = f"/{model_name}/{{id}}" + schema_path = f"/schema/{model_name}" + + # Endpoint para obtener esquema del modelo + paths[schema_path] = { + "get": { + "tags": ["Schemas"], + "summary": f"Get {model_display_name} schema", + "description": f"Obtiene el esquema completo del modelo {model_display_name}", + "responses": { + "200": {"description": "Schema retrieved successfully"}, + "404": {"$ref": "#/components/responses/NotFoundError"} + }, + "security": [] + } + } + + if collection_path not in paths: + paths[collection_path] = {} + if item_path not in paths: + paths[item_path] = {} + + # GET endpoints con parámetros mejorados + if config.is_get: + paths[collection_path]["get"] = { + "tags": [model_name], + "summary": f"Get all {model_display_name} records", + "description": f"Obtiene registros del modelo {model_display_name} con filtrado avanzado", + "parameters": [ + { + "name": "domain", + "in": "query", + "description": "Filtros en formato Odoo domain", + "schema": {"type": "string"}, + "example": "[['active', '=', True]]" + }, + { + "name": "fields", + "in": "query", + "description": "Campos específicos a retornar (separados por comas)", + "schema": {"type": "string"}, + "example": "id,name,email" + }, + { + "name": "limit", + "in": "query", + "description": "Número máximo de registros", + "schema": {"type": "integer", "minimum": 1, "maximum": config.max_records_limit}, + "example": 10 + }, + { + "name": "offset", + "in": "query", + "description": "Número de registros a omitir", + "schema": {"type": "integer", "minimum": 0}, + "example": 0 + }, + { + "name": "order", + "in": "query", + "description": "Ordenamiento (ej: 'name asc', 'create_date desc')", + "schema": {"type": "string"}, + "example": "name asc" + } + ], + "responses": { + "200": { + "description": "List of records", + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}_collection_response"} + } + } + }, + "401": {"$ref": "#/components/responses/UnauthorizedError"} } - ], - "responses": { - "200": { - "description": "Registro eliminado exitosamente", + } + + paths[item_path]["get"] = { + "tags": [model_name], + "summary": f"Get specific {model_display_name} record", + "description": f"Obtiene un registro específico del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro", + "schema": {"type": "integer", "minimum": 1} + }, + { + "name": "fields", + "in": "query", + "description": "Campos específicos a retornar", + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Record found", + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}_collection_response"} + } + } + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"} + } + } + + # POST endpoint + if config.is_post: + paths[collection_path]["post"] = { + "tags": [model_name], + "summary": f"Create new {model_display_name} record", + "description": f"Crea un nuevo registro en el modelo {model_display_name}", + "requestBody": { + "required": True, "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "success": {"type": "boolean", "example": True}, - "message": { - "type": "string", - "example": "Registro eliminado exitosamente", - }, - "deleted_record": { - "type": "object", - "properties": { - "id": {"type": "integer"}, - "display_name": {"type": "string"}, - }, - }, - }, + "schema": {"$ref": f"#/components/schemas/{model_name}_create_request"} + } + } + }, + "responses": { + "201": { + "description": "Record created successfully", + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}_collection_response"} } } }, + "400": {"$ref": "#/components/responses/ValidationError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"} + } + } + + # PUT endpoint + if config.is_put: + paths[item_path]["put"] = { + "tags": [model_name], + "summary": f"Update {model_display_name} record", + "description": f"Actualiza un registro existente del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro a actualizar", + "schema": {"type": "integer", "minimum": 1} + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}_create_request"} + } + } }, - "404": {"$ref": "#/components/responses/NotFoundError"}, - "401": {"$ref": "#/components/responses/UnauthorizedError"}, - }, - } + "responses": { + "200": { + "description": "Record updated successfully", + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}_collection_response"} + } + } + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "400": {"$ref": "#/components/responses/ValidationError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"} + } + } - return paths + # DELETE endpoint + if config.is_delete: + paths[item_path]["delete"] = { + "tags": [model_name], + "summary": f"Delete {model_display_name} record", + "description": f"Elimina un registro del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro a eliminar", + "schema": {"type": "integer", "minimum": 1} + } + ], + "responses": { + "200": { + "description": "Record deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "message": {"type": "string", "example": "Record deleted successfully"}, + "deleted_record": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "display_name": {"type": "string"} + } + } + } + } + } + } + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"} + } + } - def _get_model_fields_info(self, model_name): - """Obtiene información de los campos de un modelo para la documentación""" - try: - model = request.env[model_name].sudo() - fields_info = {} - - # Campos comunes que suelen existir - common_fields = { - "id": { - "type": "integer", - "description": "ID único del registro", - "readOnly": True, - }, - "name": {"type": "string", "description": "Nombre"}, - "display_name": { - "type": "string", - "description": "Nombre para mostrar", - "readOnly": True, - }, - "create_date": { - "type": "string", - "format": "date-time", - "description": "Fecha de creación", - "readOnly": True, - }, - "write_date": { - "type": "string", - "format": "date-time", - "description": "Fecha de última modificación", - "readOnly": True, - }, - "active": { - "type": "boolean", - "description": "Indica si el registro está activo", - }, - } + except Exception as e: + _logger.warning(f"Error generating enhanced paths for model: {str(e)}") + continue - # Agregar campos comunes que existan en el modelo - for field_name, field_info in common_fields.items(): - if hasattr(model, field_name): - fields_info[field_name] = field_info + return paths - return fields_info + def _generate_enhanced_tags(self, api_configs): + """Genera tags mejorados para agrupar endpoints""" + tags = [ + {"name": "Authentication", "description": "Autenticación JWT y gestión de tokens"}, + {"name": "System", "description": "Endpoints del sistema (salud, estado)"}, + {"name": "Schemas", "description": "Esquemas de modelos disponibles"} + ] - except Exception: - # Si hay error, retornar estructura básica - return { - "id": {"type": "integer", "description": "ID único del registro"}, - "display_name": { - "type": "string", - "description": "Nombre para mostrar", - }, - } + for config in api_configs: + if hasattr(config, 'model_id') and config.model_id: + available_methods = [] + if config.is_get: available_methods.append("GET") + if config.is_post: available_methods.append("POST") + if config.is_put: available_methods.append("PUT") + if config.is_delete: available_methods.append("DELETE") + + tags.append({ + "name": config.model_id.model, + "description": f"Operaciones CRUD para {config.model_id.name} - Métodos: {', '.join(available_methods)}", + "externalDocs": { + "description": "Esquema del modelo", + "url": f"/api/v1/schema/{config.model_id.model}" + } + }) - def _get_model_example_data(self, model_name, operation): - """Genera ejemplos de datos para cada modelo""" - examples = { - "res.partner": { - "create": { - "values": { - "name": "Nuevo Cliente", - "email": "cliente@ejemplo.com", - "phone": "+34123456789", - "is_company": False, - }, - "fields": ["id", "name", "email", "phone"], - }, - "update": { - "values": {"phone": "+34987654321", "street": "Calle Nueva 123"}, - "fields": ["id", "name", "phone", "street"], - }, - }, - "product.product": { - "create": { - "values": { - "name": "Nuevo Producto", - "list_price": 99.99, - "default_code": "PROD001", - }, - "fields": ["id", "name", "list_price", "default_code"], - }, - "update": { - "values": { - "list_price": 89.99, - "description": "Descripción actualizada", - }, - "fields": ["id", "name", "list_price", "description"], - }, - }, - } + return tags - # Retornar ejemplo específico o genérico - return examples.get( - model_name, - { - "create": { - "values": {"name": "Nuevo Registro"}, - "fields": ["id", "name", "display_name"], - }, - "update": { - "values": {"name": "Registro Actualizado"}, - "fields": ["id", "name", "display_name"], - }, - }, - ).get(operation, {}) + def _get_allowed_fields(self, config): + """Obtiene campos permitidos de la configuración""" + if not hasattr(config, 'allowed_fields') or not config.allowed_fields: + return None + return [f.strip() for f in config.allowed_fields.split(',') if f.strip()] + + def _get_forbidden_fields(self, config): + """Obtiene campos prohibidos de la configuración""" + default_forbidden = ['__last_update', 'create_uid', 'create_date', 'write_uid', 'write_date'] + if not hasattr(config, 'forbidden_fields') or not config.forbidden_fields: + return default_forbidden + return [f.strip() for f in config.forbidden_fields.split(',') if f.strip()] diff --git a/rest_api_odoo/models/res_users.py b/rest_api_odoo/models/res_users.py index 883fb33a3..5704d9dd0 100644 --- a/rest_api_odoo/models/res_users.py +++ b/rest_api_odoo/models/res_users.py @@ -23,9 +23,12 @@ import uuid import secrets import string +import logging from datetime import datetime, timedelta from odoo import fields, models, api +_logger = logging.getLogger(__name__) + class ResUsers(models.Model): """Extensión del modelo de usuarios para gestión de API keys""" @@ -62,14 +65,20 @@ class ResUsers(models.Model): Returns: str: API key generada """ + self.ensure_one() + if not self.api_key or force_new: - # Generar una API key más segura - alphabet = string.ascii_letters + string.digits - api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + try: + # Generar una API key más segura usando secrets si está disponible + alphabet = string.ascii_letters + string.digits + api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + except (ImportError, AttributeError): + # Fallback usando uuid si secrets no está disponible + api_key = str(uuid.uuid4()).replace('-', '') + str(uuid.uuid4()).replace('-', '')[:32] - self.write({ + self.sudo().write({ 'api_key': api_key, - 'api_key_created': datetime.now(), + 'api_key_created': fields.Datetime.now(), 'api_key_last_used': None, 'api_requests_count': 0 }) @@ -78,16 +87,19 @@ class ResUsers(models.Model): def regenerate_api_key(self): """Regenera la API key (útil para botón en interfaz)""" + self.ensure_one() return self.generate_api_key(force_new=True) def revoke_api_key(self): """Revoca la API key actual""" - self.write({ + self.ensure_one() + self.sudo().write({ 'api_key': False, 'api_key_expiry': False, 'api_key_created': False, 'api_key_last_used': False }) + return True def set_api_key_expiry(self, days=None): """ @@ -95,43 +107,159 @@ class ResUsers(models.Model): Args: days (int): Días hasta la expiración (default: sin expiración) """ + self.ensure_one() if days: - expiry_date = datetime.now() + timedelta(days=days) - self.api_key_expiry = expiry_date + expiry_date = fields.Datetime.now() + timedelta(days=days) + self.sudo().write({'api_key_expiry': expiry_date}) else: - self.api_key_expiry = False + self.sudo().write({'api_key_expiry': False}) def update_api_key_usage(self): """Actualiza estadísticas de uso de la API key""" - self.write({ - 'api_key_last_used': datetime.now(), - 'api_requests_count': self.api_requests_count + 1 - }) + self.ensure_one() + try: + self.sudo().write({ + 'api_key_last_used': fields.Datetime.now(), + 'api_requests_count': self.api_requests_count + 1 + }) + except Exception as e: + _logger.warning(f"Could not update API key usage for user {self.id}: {str(e)}") @api.model def cleanup_expired_api_keys(self): """Limpia API keys expiradas (para ejecutar en cron)""" - expired_users = self.search([ - ('api_key_expiry', '!=', False), - ('api_key_expiry', '<', datetime.now()) - ]) + try: + expired_users = self.search([ + ('api_key_expiry', '!=', False), + ('api_key_expiry', '<', fields.Datetime.now()) + ]) - for user in expired_users: - user.revoke_api_key() + for user in expired_users: + try: + user.revoke_api_key() + except Exception as e: + _logger.warning(f"Could not revoke API key for user {user.id}: {str(e)}") - return len(expired_users) + return len(expired_users) + except Exception as e: + _logger.error(f"Error in cleanup_expired_api_keys: {str(e)}") + return 0 def is_api_key_valid(self): """Verifica si la API key es válida y no ha expirado""" + self.ensure_one() + if not self.api_key: return False - if self.api_key_expiry and self.api_key_expiry < datetime.now(): + if self.api_key_expiry and self.api_key_expiry < fields.Datetime.now(): return False return True + def action_generate_api_key(self): + """Acción para generar API key desde la interfaz""" + self.ensure_one() + try: + api_key = self.generate_api_key() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'API Key generada exitosamente: {api_key}', + 'type': 'success', + 'sticky': True, + } + } + except Exception as e: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'Error generando API Key: {str(e)}', + 'type': 'danger', + 'sticky': True, + } + } + + def action_regenerate_api_key(self): + """Acción para regenerar API key desde la interfaz""" + self.ensure_one() + try: + api_key = self.regenerate_api_key() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'API Key regenerada exitosamente: {api_key}', + 'type': 'success', + 'sticky': True, + } + } + except Exception as e: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'Error regenerando API Key: {str(e)}', + 'type': 'danger', + 'sticky': True, + } + } + + def action_revoke_api_key(self): + """Acción para revocar API key desde la interfaz""" + self.ensure_one() + try: + self.revoke_api_key() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': 'API Key revocada exitosamente', + 'type': 'warning', + 'sticky': False, + } + } + except Exception as e: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'Error revocando API Key: {str(e)}', + 'type': 'danger', + 'sticky': True, + } + } + # Método legacy para compatibilidad con código anterior def generate_api(self, username): """Método de compatibilidad con la versión anterior""" return self.generate_api_key() + + @api.model + def get_api_statistics(self): + """Obtiene estadísticas de uso de API keys""" + try: + users_with_keys = self.search([('api_key', '!=', False)]) + active_keys = users_with_keys.filtered(lambda u: u.is_api_key_valid()) + expired_keys = users_with_keys.filtered(lambda u: not u.is_api_key_valid()) + + total_requests = sum(users_with_keys.mapped('api_requests_count')) + + return { + 'total_users_with_keys': len(users_with_keys), + 'active_keys': len(active_keys), + 'expired_keys': len(expired_keys), + 'total_api_requests': total_requests, + 'average_requests_per_user': total_requests / len(users_with_keys) if users_with_keys else 0 + } + except Exception as e: + _logger.error(f"Error getting API statistics: {str(e)}") + return { + 'total_users_with_keys': 0, + 'active_keys': 0, + 'expired_keys': 0, + 'total_api_requests': 0, + 'average_requests_per_user': 0 + } diff --git a/rest_api_odoo/security/ir.model.access.csv b/rest_api_odoo/security/ir.model.access.csv index 042bd737c..ba4748c46 100644 --- a/rest_api_odoo/security/ir.model.access.csv +++ b/rest_api_odoo/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_connection_api_user,access.connection.api.user,model_connection_api,,1,1,1,1 +access_connection_api_user,access_connection_api_user,model_connection_api,base.group_user,1,0,0,0 +access_connection_api_manager,access_connection_api_manager,model_connection_api,base.group_system,1,1,1,1 diff --git a/rest_api_odoo/views/connection_api_views.xml b/rest_api_odoo/views/connection_api_views.xml index 5e8200f07..cc552abdc 100644 --- a/rest_api_odoo/views/connection_api_views.xml +++ b/rest_api_odoo/views/connection_api_views.xml @@ -1,6 +1,6 @@ - + connection.api.view.form connection.api @@ -16,8 +16,7 @@ - - - - - - - - - - + connection.api.view.search connection.api @@ -196,11 +147,6 @@ - - - - - @@ -209,12 +155,11 @@ - + REST API Configuration - ir.actions.act_window connection.api - kanban,list,form + list,form {'search_default_active': 1}

@@ -222,40 +167,61 @@

Create API configurations to expose Odoo models through REST endpoints. - You can control which HTTP methods are allowed and configure field access. +

+

+ + View API Documentation +

- + Create Default API Configurations code - -created = model.create_default_configurations() + + ]]> - - + + - + view.users.form.inherit.rest.api.enhanced @@ -10,8 +10,7 @@ - + @@ -21,30 +20,30 @@ -
-
- -
- - - view.users.tree.api.info + + + view.users.list.api.info res.users - - - + + + - + Cleanup Expired API Keys code - -cleaned_count = model.cleanup_expired_api_keys() -message = f"Cleaned {cleaned_count} expired API keys." if cleaned_count > 0 else "No expired API keys found." + 0: + message = "Cleaned " + str(cleaned_count) + " expired API keys." + msg_type = 'success' +else: + message = "No expired API keys found." + msg_type = 'info' action = { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'message': message, - 'type': 'success' if cleaned_count > 0 else 'info', + 'type': msg_type, 'sticky': False, } } - + ]]> - + - + Cleanup Expired API Keys code - model.cleanup_expired_api_keys() + 1 days True -