Browse Source

jwt token

pull/401/head
bernat.roig 7 days ago
parent
commit
3a8a56a56f
  1. 1
      rest_api_odoo/controllers/__init__.py
  2. 179
      rest_api_odoo/controllers/jwt_auth.py
  3. 580
      rest_api_odoo/controllers/rest_api_odoo.py
  4. 1571
      rest_api_odoo/controllers/swagger_controller.py
  5. 170
      rest_api_odoo/models/res_users.py
  6. 3
      rest_api_odoo/security/ir.model.access.csv
  7. 152
      rest_api_odoo/views/connection_api_views.xml
  8. 113
      rest_api_odoo/views/res_users_views.xml

1
rest_api_odoo/controllers/__init__.py

@ -21,3 +21,4 @@
############################################################################# #############################################################################
from . import rest_api_odoo from . import rest_api_odoo
from . import swagger_controller from . import swagger_controller
from . import jwt_auth

179
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

580
rest_api_odoo/controllers/rest_api_odoo.py

@ -1,47 +1,40 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# 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 <http://www.gnu.org/licenses/>.
#
#############################################################################
import json import json
import logging import logging
import base64 import base64
import ast
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from odoo import http from odoo import http, fields
from odoo.http import request from odoo.http import request
from werkzeug.exceptions import BadRequest, Unauthorized, NotFound, MethodNotAllowed from .jwt_auth import JWTAuthMixin
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class RestApi(http.Controller): class RestApi(http.Controller, JWTAuthMixin):
"""Controlador API REST mejorado con autenticación basada en API Key solamente""" """Controlador API REST mejorado con JWT y filtrado avanzado"""
def _json_response(self, data, status=200): def _json_response(self, data, status=200):
"""Genera respuesta JSON estandarizada""" """Genera respuesta JSON estandarizada"""
response = request.make_response( try:
json.dumps(data, ensure_ascii=False, indent=2), response = request.make_response(
headers=[('Content-Type', 'application/json')] json.dumps(data, ensure_ascii=False, indent=2, default=str),
) headers=[('Content-Type', 'application/json; charset=utf-8')]
response.status_code = status )
return response 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): def _error_response(self, message, status=400, error_code=None):
"""Genera respuesta de error estandarizada""" """Genera respuesta de error estandarizada"""
@ -55,41 +48,6 @@ class RestApi(http.Controller):
return self._json_response(error_data, status) 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): def _serialize_record_values(self, records):
"""Serializa los valores de los registros para JSON""" """Serializa los valores de los registros para JSON"""
if not records: if not records:
@ -99,102 +57,170 @@ class RestApi(http.Controller):
for record in records: for record in records:
serialized_record = {} serialized_record = {}
for key, value in record.items(): for key, value in record.items():
if isinstance(value, (datetime, date)): try:
serialized_record[key] = value.isoformat() if isinstance(value, (datetime, date)):
elif isinstance(value, bytes): serialized_record[key] = value.isoformat()
serialized_record[key] = base64.b64encode(value).decode('utf-8') elif isinstance(value, bytes):
elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): serialized_record[key] = base64.b64encode(value).decode('utf-8')
try: elif isinstance(value, tuple) and len(value) == 2:
serialized_record[key] = list(value) if value else [] # Para relaciones Many2one que vienen como (id, name)
except: serialized_record[key] = list(value)
serialized_record[key] = str(value) elif hasattr(value, '__iter__') and not isinstance(value, (str, dict, bytes)):
else: try:
serialized_record[key] = value 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) serialized_records.append(serialized_record)
return serialized_records return serialized_records
def _get_model_config(self, model_name): def _get_model_config(self, model_name):
"""Obtiene la configuración de la API para un modelo""" """Obtiene la configuración de la API para un modelo"""
model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1) try:
if not model_obj: model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1)
return None, "Modelo no encontrado" if not model_obj:
return None, "Modelo no encontrado"
api_config = request.env['connection.api'].sudo().search([ api_config = request.env['connection.api'].sudo().search([
('model_id', '=', model_obj.id), ('model_id', '=', model_obj.id),
('active', '=', True) ('active', '=', True)
], limit=1) ], limit=1)
if not api_config: if not api_config:
return None, "Modelo no configurado para API REST" 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): 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 = {} data = {}
fields = [] fields = []
domain = []
limit = None
offset = None
order = None
if method == 'GET': try:
# Para GET, intentar obtener campos del query string o JSON body if method == 'GET':
query_params = dict(request.httprequest.args) query_params = dict(request.httprequest.args)
try: # Parsear domain
if request.httprequest.data: if 'domain' in query_params:
json_data = json.loads(request.httprequest.data) try:
data.update(json_data) domain = ast.literal_eval(query_params['domain'])
if 'fields' in json_data: if not isinstance(domain, list):
fields = json_data['fields'] domain = []
except json.JSONDecodeError: except:
pass _logger.warning("Invalid domain format, ignoring")
domain = []
if not fields and 'fields' in query_params:
fields = [field.strip() for field in query_params['fields'].split(',')] # Parsear fields
if 'fields' in query_params:
elif method in ['POST', 'PUT']: fields = [field.strip() for field in query_params['fields'].split(',') if field.strip()]
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"
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) @http.route(['/api/v1/auth'], type='http', auth='none', methods=['POST'], csrf=False)
def authenticate(self, **kw): def authenticate(self, **kw):
"""Endpoint de autenticación que genera API key""" """Endpoint de autenticación que genera JWT token"""
try: try:
data = json.loads(request.httprequest.data or '{}') if request.httprequest.data:
except json.JSONDecodeError: data = json.loads(request.httprequest.data.decode('utf-8'))
else:
data = {}
except (json.JSONDecodeError, UnicodeDecodeError):
return self._error_response("JSON inválido", 400) return self._error_response("JSON inválido", 400)
username = data.get('username') or request.httprequest.headers.get('username') username = data.get('username') or request.httprequest.headers.get('username')
password = data.get('password') or request.httprequest.headers.get('password') 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]): if not all([username, password]):
return self._error_response("Username y password son requeridos", 400) return self._error_response("Username y password son requeridos", 400)
try: # Validar expires_in
# Actualizar sesión con la base de datos if not isinstance(expires_in, int) or expires_in < 1 or expires_in > 168: # Max 7 días
request.session.update(http.get_default_session(), db=database) expires_in = 24
try:
# Autenticar credenciales # Autenticar credenciales
auth_result = request.session.authenticate( credential = {'login': username, 'password': password, 'type': 'password'}
database,
{'login': username, 'password': password, 'type': 'password'}
)
auth_result = request.session.authenticate(database, credential)
if not auth_result: if not auth_result:
return self._error_response("Credenciales inválidas", 401) return self._error_response("Credenciales inválidas", 401)
# Generar o recuperar API key uid = auth_result['uid']
user = request.env['res.users'].browse(auth_result['uid'])
api_key = user.generate_api_key() 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 = { response_data = {
"success": True, "success": True,
@ -203,7 +229,9 @@ class RestApi(http.Controller):
"user_id": user.id, "user_id": user.id,
"username": user.login, "username": user.login,
"name": user.name, "name": user.name,
"api_key": api_key, "access_token": token,
"token_type": "Bearer",
"expires_in": expires_in * 3600, # En segundos
"database": database "database": database
} }
} }
@ -214,16 +242,72 @@ class RestApi(http.Controller):
_logger.error(f"Error en autenticación: {str(e)}") _logger.error(f"Error en autenticación: {str(e)}")
return self._error_response("Error interno de autenticación", 500) 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 <token>)"
return self._validate_jwt_token(auth_header)
@http.route(['/api/v1/<model_name>', '/api/v1/<model_name>/<int:record_id>'], @http.route(['/api/v1/<model_name>', '/api/v1/<model_name>/<int:record_id>'],
type='http', auth='none', methods=['GET', 'POST', 'PUT', 'DELETE'], csrf=False) type='http', auth='none', methods=['GET', 'POST', 'PUT', 'DELETE'], csrf=False)
def api_handler(self, model_name, record_id=None, **kw): 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 method = request.httprequest.method
# Autenticación usando API key # Autenticación usando JWT
api_key = request.httprequest.headers.get('X-API-Key') or request.httprequest.headers.get('api-key') success, user_id, error_msg = self._authenticate_request()
success, user_id, error_msg = self._authenticate_api_key(api_key)
if not success: if not success:
return self._error_response(error_msg, 401, "AUTHENTICATION_FAILED") return self._error_response(error_msg, 401, "AUTHENTICATION_FAILED")
@ -243,56 +327,108 @@ class RestApi(http.Controller):
if not method_permissions.get(method, False): if not method_permissions.get(method, False):
return self._error_response(f"Método {method} no permitido para este modelo", 405, "METHOD_NOT_ALLOWED") return self._error_response(f"Método {method} no permitido para este modelo", 405, "METHOD_NOT_ALLOWED")
# Parsear datos de la request # Parsear datos de la request con parámetros avanzados
data, fields, error_msg = self._parse_request_data(method) data, fields, domain, limit, offset, order, error_msg = self._parse_request_data(method)
if error_msg: if error_msg:
return self._error_response(error_msg, 400, "INVALID_REQUEST_DATA") return self._error_response(error_msg, 400, "INVALID_REQUEST_DATA")
try: 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: except Exception as e:
_logger.error(f"Error procesando request {method} para {model_name}: {str(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") return self._error_response("Error interno del servidor", 500, "INTERNAL_SERVER_ERROR")
def _handle_request(self, method, model_name, record_id, data, fields): def _handle_request(self, method, model_name, record_id, data, fields, domain, limit, offset, order, api_config=None):
"""Maneja las diferentes operaciones CRUD""" """Maneja las diferentes operaciones CRUD con parámetros avanzados"""
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"""
try: 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: if record_id:
# Obtener registro específico # Obtener registro específico
domain = [('id', '=', record_id)] search_domain = [('id', '=', record_id)]
search_fields = fields if fields else [] 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: else:
# Obtener todos los registros # Obtener registros con filtros
domain = [] search_domain = domain if domain else []
search_fields = fields if fields else ['id', 'display_name'] search_fields = fields if fields else ['id', 'display_name']
records = model.search_read(domain=domain, fields=search_fields) # Contar total sin límite para metadatos
serialized_records = self._serialize_record_values(records) try:
total_count = model.search_count(search_domain)
except:
total_count = None
response_data = { # Búsqueda con parámetros
"success": True, search_params = {
"count": len(serialized_records), 'domain': search_domain,
"data": serialized_records '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) return self._json_response(response_data)
except Exception as e: except Exception as e:
_logger.error(f"Error en GET: {str(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): def _handle_post(self, model, data, fields):
"""Maneja requests POST (crear)""" """Maneja requests POST (crear)"""
@ -305,12 +441,12 @@ class RestApi(http.Controller):
# Obtener el registro creado con los campos especificados # Obtener el registro creado con los campos especificados
search_fields = fields if fields else ['id', 'display_name'] search_fields = fields if fields else ['id', 'display_name']
record_data = new_record.read(search_fields)[0] record_data = new_record.read(search_fields)[0]
serialized_record = self._serialize_record_values([record_data])
response_data = { response_data = {
"success": True, "success": True,
"message": "Registro creado exitosamente", "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) return self._json_response(response_data, 201)
@ -337,12 +473,12 @@ class RestApi(http.Controller):
# Obtener el registro actualizado # Obtener el registro actualizado
search_fields = fields if fields else ['id', 'display_name'] search_fields = fields if fields else ['id', 'display_name']
record_data = record.read(search_fields)[0] record_data = record.read(search_fields)[0]
serialized_record = self._serialize_record_values([record_data])
response_data = { response_data = {
"success": True, "success": True,
"message": "Registro actualizado exitosamente", "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) 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) @http.route(['/api/v1/models'], type='http', auth='none', methods=['GET'], csrf=False)
def list_available_models(self, **kw): def list_available_models(self, **kw):
"""Endpoint para listar modelos disponibles en la API""" """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_request()
success, user_id, error_msg = self._authenticate_api_key(api_key)
if not success: if not success:
return self._error_response(error_msg, 401) return self._error_response(error_msg, 401)
@ -398,11 +532,18 @@ class RestApi(http.Controller):
model_info = { model_info = {
"model": config.model_id.model, "model": config.model_id.model,
"name": config.model_id.name, "name": config.model_id.name,
"description": config.description or f"API REST para el modelo {config.model_id.name}",
"methods": { "methods": {
"GET": config.is_get, "GET": config.is_get,
"POST": config.is_post, "POST": config.is_post,
"PUT": config.is_put, "PUT": config.is_put,
"DELETE": config.is_delete "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) models_data.append(model_info)
@ -416,6 +557,12 @@ class RestApi(http.Controller):
"documentation": { "documentation": {
"swagger_ui": f"{base_url}/api/v1/docs", "swagger_ui": f"{base_url}/api/v1/docs",
"openapi_spec": f"{base_url}/api/v1/openapi.json" "openapi_spec": f"{base_url}/api/v1/openapi.json"
},
"authentication": {
"type": "JWT Bearer Token",
"header": "Authorization: Bearer <token>",
"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)}") _logger.error(f"Error listando modelos: {str(e)}")
return self._error_response("Error interno del servidor", 500) 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) @http.route(['/api', '/api/'], type='http', auth='none', methods=['GET'], csrf=False)
def api_root(self, **kw): def api_root(self, **kw):
"""Endpoint raíz de la API que redirige a la documentación""" """Endpoint raíz de la API que proporciona información básica"""
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') 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", api_info = {
"version": "1.0.0", "message": "Bienvenido a la REST API de Odoo v2.0",
"documentation": f"{base_url}/api/v1/docs", "version": "2.0.0",
"endpoints": { "status": "active",
"auth": f"{base_url}/api/v1/auth", "documentation": f"{base_url}/api/v1/docs",
"models": f"{base_url}/api/v1/models", "features": [
"docs": f"{base_url}/api/v1/docs", "JWT Bearer Token Authentication",
"openapi": f"{base_url}/api/v1/openapi.json" "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 <token>",
"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)

1571
rest_api_odoo/controllers/swagger_controller.py

File diff suppressed because it is too large

170
rest_api_odoo/models/res_users.py

@ -23,9 +23,12 @@
import uuid import uuid
import secrets import secrets
import string import string
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import fields, models, api from odoo import fields, models, api
_logger = logging.getLogger(__name__)
class ResUsers(models.Model): class ResUsers(models.Model):
"""Extensión del modelo de usuarios para gestión de API keys""" """Extensión del modelo de usuarios para gestión de API keys"""
@ -62,14 +65,20 @@ class ResUsers(models.Model):
Returns: Returns:
str: API key generada str: API key generada
""" """
self.ensure_one()
if not self.api_key or force_new: if not self.api_key or force_new:
# Generar una API key más segura try:
alphabet = string.ascii_letters + string.digits # Generar una API key más segura usando secrets si está disponible
api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) 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': api_key,
'api_key_created': datetime.now(), 'api_key_created': fields.Datetime.now(),
'api_key_last_used': None, 'api_key_last_used': None,
'api_requests_count': 0 'api_requests_count': 0
}) })
@ -78,16 +87,19 @@ class ResUsers(models.Model):
def regenerate_api_key(self): def regenerate_api_key(self):
"""Regenera la API key (útil para botón en interfaz)""" """Regenera la API key (útil para botón en interfaz)"""
self.ensure_one()
return self.generate_api_key(force_new=True) return self.generate_api_key(force_new=True)
def revoke_api_key(self): def revoke_api_key(self):
"""Revoca la API key actual""" """Revoca la API key actual"""
self.write({ self.ensure_one()
self.sudo().write({
'api_key': False, 'api_key': False,
'api_key_expiry': False, 'api_key_expiry': False,
'api_key_created': False, 'api_key_created': False,
'api_key_last_used': False 'api_key_last_used': False
}) })
return True
def set_api_key_expiry(self, days=None): def set_api_key_expiry(self, days=None):
""" """
@ -95,43 +107,159 @@ class ResUsers(models.Model):
Args: Args:
days (int): Días hasta la expiración (default: sin expiración) days (int): Días hasta la expiración (default: sin expiración)
""" """
self.ensure_one()
if days: if days:
expiry_date = datetime.now() + timedelta(days=days) expiry_date = fields.Datetime.now() + timedelta(days=days)
self.api_key_expiry = expiry_date self.sudo().write({'api_key_expiry': expiry_date})
else: else:
self.api_key_expiry = False self.sudo().write({'api_key_expiry': False})
def update_api_key_usage(self): def update_api_key_usage(self):
"""Actualiza estadísticas de uso de la API key""" """Actualiza estadísticas de uso de la API key"""
self.write({ self.ensure_one()
'api_key_last_used': datetime.now(), try:
'api_requests_count': self.api_requests_count + 1 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 @api.model
def cleanup_expired_api_keys(self): def cleanup_expired_api_keys(self):
"""Limpia API keys expiradas (para ejecutar en cron)""" """Limpia API keys expiradas (para ejecutar en cron)"""
expired_users = self.search([ try:
('api_key_expiry', '!=', False), expired_users = self.search([
('api_key_expiry', '<', datetime.now()) ('api_key_expiry', '!=', False),
]) ('api_key_expiry', '<', fields.Datetime.now())
])
for user in expired_users: for user in expired_users:
user.revoke_api_key() 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): def is_api_key_valid(self):
"""Verifica si la API key es válida y no ha expirado""" """Verifica si la API key es válida y no ha expirado"""
self.ensure_one()
if not self.api_key: if not self.api_key:
return False 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 False
return True 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 # Método legacy para compatibilidad con código anterior
def generate_api(self, username): def generate_api(self, username):
"""Método de compatibilidad con la versión anterior""" """Método de compatibilidad con la versión anterior"""
return self.generate_api_key() 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
}

3
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 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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_connection_api_user access.connection.api.user access_connection_api_user model_connection_api base.group_user 1 1 0 1 0 1 0
3 access_connection_api_manager access_connection_api_manager model_connection_api base.group_system 1 1 1 1

152
rest_api_odoo/views/connection_api_views.xml

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- Form view mejorada para 'connection.api' --> <!-- Form view para 'connection.api' -->
<record id="connection_api_view_form" model="ir.ui.view"> <record id="connection_api_view_form" model="ir.ui.view">
<field name="name">connection.api.view.form</field> <field name="name">connection.api.view.form</field>
<field name="model">connection.api</field> <field name="model">connection.api</field>
@ -16,8 +16,7 @@
<button name="action_test_api_endpoint" type="object" <button name="action_test_api_endpoint" type="object"
string="Test Endpoint" class="btn-info" string="Test Endpoint" class="btn-info"
invisible="not model_id"/> invisible="not model_id"/>
<field name="active" widget="boolean_toggle" <field name="active" widget="boolean_toggle"/>
options="{'autosave': False}"/>
</header> </header>
<sheet> <sheet>
<div class="oe_title"> <div class="oe_title">
@ -32,7 +31,7 @@
<group> <group>
<group string="Model Configuration"> <group string="Model Configuration">
<field name="model_id" options="{'no_create': True}"/> <field name="model_id" options="{'no_create': True, 'no_open': True}"/>
<field name="description" placeholder="Describe the purpose of this API configuration..."/> <field name="description" placeholder="Describe the purpose of this API configuration..."/>
</group> </group>
<group string="HTTP Methods"> <group string="HTTP Methods">
@ -49,11 +48,16 @@
<group string="Allowed Fields"> <group string="Allowed Fields">
<field name="allowed_fields" <field name="allowed_fields"
placeholder="field1, field2, field3... (leave empty for all fields)" placeholder="field1, field2, field3... (leave empty for all fields)"
widget="text"/> widget="text" nolabel="1"/>
<div class="text-muted">
<small>Lista de campos permitidos separados por comas.</small>
</div>
</group> </group>
<group string="Forbidden Fields"> <group string="Forbidden Fields">
<field name="forbidden_fields" <field name="forbidden_fields" widget="text" nolabel="1"/>
widget="text"/> <div class="text-muted">
<small>Lista de campos prohibidos separados por comas.</small>
</div>
</group> </group>
</group> </group>
</page> </page>
@ -77,6 +81,10 @@
<a href="/api/v1/docs" target="_blank" class="btn btn-info"> <a href="/api/v1/docs" target="_blank" class="btn btn-info">
<i class="fa fa-book"/> View Swagger Documentation <i class="fa fa-book"/> View Swagger Documentation
</a> </a>
<br/><br/>
<a href="/api/v1/openapi.json" target="_blank" class="btn btn-secondary">
<i class="fa fa-file-code-o"/> View OpenAPI Spec
</a>
</div> </div>
</group> </group>
</group> </group>
@ -84,15 +92,15 @@
<div class="alert alert-info" role="alert" invisible="not api_endpoint"> <div class="alert alert-info" role="alert" invisible="not api_endpoint">
<h4>Usage Examples:</h4> <h4>Usage Examples:</h4>
<p><strong>Get all records:</strong><br/> <p><strong>Get all records:</strong><br/>
<code>GET <field name="api_endpoint" readonly="1"/></code></p> <code>GET <field name="api_endpoint" readonly="1" nolabel="1"/></code></p>
<p><strong>Get specific record:</strong><br/> <p><strong>Get specific record:</strong><br/>
<code>GET <field name="api_endpoint" readonly="1"/>/123</code></p> <code>GET <field name="api_endpoint" readonly="1" nolabel="1"/>/123</code></p>
<p><strong>Create record:</strong><br/> <p><strong>Create record:</strong><br/>
<code>POST <field name="api_endpoint" readonly="1"/></code></p> <code>POST <field name="api_endpoint" readonly="1" nolabel="1"/></code></p>
<p><strong>Update record:</strong><br/> <p><strong>Update record:</strong><br/>
<code>PUT <field name="api_endpoint" readonly="1"/>/123</code></p> <code>PUT <field name="api_endpoint" readonly="1" nolabel="1"/>/123</code></p>
<p><strong>Delete record:</strong><br/> <p><strong>Delete record:</strong><br/>
<code>DELETE <field name="api_endpoint" readonly="1"/>/123</code></p> <code>DELETE <field name="api_endpoint" readonly="1" nolabel="1"/>/123</code></p>
<p><em>Remember to include your API Key in the header: <code>X-API-Key: your_api_key_here</code></em></p> <p><em>Remember to include your API Key in the header: <code>X-API-Key: your_api_key_here</code></em></p>
</div> </div>
</page> </page>
@ -102,13 +110,13 @@
</field> </field>
</record> </record>
<!-- List view mejorada para 'connection.api' --> <!-- List view para 'connection.api' -->
<record id="connection_api_view_list" model="ir.ui.view"> <record id="connection_api_view_list" model="ir.ui.view">
<field name="name">connection.api.view.list</field> <field name="name">connection.api.view.list</field>
<field name="model">connection.api</field> <field name="model">connection.api</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list decoration-muted="not active" create="true" edit="true" delete="true"> <list decoration-muted="not active" create="true" edit="true" delete="true">
<field name="active" invisible="1"/> <field name="active" column_invisible="1"/>
<field name="display_name"/> <field name="display_name"/>
<field name="model_id"/> <field name="model_id"/>
<field name="is_get" widget="boolean_toggle"/> <field name="is_get" widget="boolean_toggle"/>
@ -127,64 +135,7 @@
</field> </field>
</record> </record>
<!-- Vista Kanban para 'connection.api' --> <!-- Vista de búsqueda -->
<record id="connection_api_view_kanban" model="ir.ui.view">
<field name="name">connection.api.view.kanban</field>
<field name="model">connection.api</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" create="true" edit="true">
<field name="display_name"/>
<field name="model_id"/>
<field name="active"/>
<field name="is_get"/>
<field name="is_post"/>
<field name="is_put"/>
<field name="is_delete"/>
<field name="api_endpoint"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click #{record.active.raw_value ? '' : 'o_kanban_card_disabled'}">
<div class="o_kanban_content">
<div class="o_kanban_primary_left">
<div class="o_primary">
<strong><t t-esc="record.display_name.value"/></strong>
</div>
<div class="o_secondary">
<i class="fa fa-cube" title="Model"/>
<t t-esc="record.model_id.value"/>
</div>
</div>
<div class="o_kanban_primary_right">
<div class="badge-container">
<span t-if="record.is_get.raw_value" class="badge badge-success">GET</span>
<span t-if="record.is_post.raw_value" class="badge badge-primary">POST</span>
<span t-if="record.is_put.raw_value" class="badge badge-info">PUT</span>
<span t-if="record.is_delete.raw_value" class="badge badge-danger">DELETE</span>
</div>
</div>
</div>
<div class="o_kanban_footer">
<div class="o_kanban_footer_left">
<span t-if="record.active.raw_value" class="badge badge-success">Active</span>
<span t-if="!record.active.raw_value" class="badge badge-secondary">Inactive</span>
</div>
<div class="o_kanban_footer_right">
<button name="action_test_api_endpoint" type="object"
class="btn btn-sm btn-outline-primary"
title="Test Endpoint">
<i class="fa fa-external-link"/>
</button>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Vista de búsqueda para 'connection.api' -->
<record id="connection_api_view_search" model="ir.ui.view"> <record id="connection_api_view_search" model="ir.ui.view">
<field name="name">connection.api.view.search</field> <field name="name">connection.api.view.search</field>
<field name="model">connection.api</field> <field name="model">connection.api</field>
@ -196,11 +147,6 @@
<filter name="active" string="Active" domain="[('active', '=', True)]"/> <filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/> <filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/>
<separator/> <separator/>
<filter name="get_enabled" string="GET Enabled" domain="[('is_get', '=', True)]"/>
<filter name="post_enabled" string="POST Enabled" domain="[('is_post', '=', True)]"/>
<filter name="put_enabled" string="PUT Enabled" domain="[('is_put', '=', True)]"/>
<filter name="delete_enabled" string="DELETE Enabled" domain="[('is_delete', '=', True)]"/>
<separator/>
<group expand="0" string="Group By"> <group expand="0" string="Group By">
<filter name="group_by_model" string="Model" context="{'group_by': 'model_id'}"/> <filter name="group_by_model" string="Model" context="{'group_by': 'model_id'}"/>
<filter name="group_by_active" string="Status" context="{'group_by': 'active'}"/> <filter name="group_by_active" string="Status" context="{'group_by': 'active'}"/>
@ -209,12 +155,11 @@
</field> </field>
</record> </record>
<!-- Acción mejorada para 'connection.api' --> <!-- Acción principal -->
<record id="rest_api_root_action" model="ir.actions.act_window"> <record id="rest_api_root_action" model="ir.actions.act_window">
<field name="name">REST API Configuration</field> <field name="name">REST API Configuration</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">connection.api</field> <field name="res_model">connection.api</field>
<field name="view_mode">kanban,list,form</field> <field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field> <field name="context">{'search_default_active': 1}</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">
@ -222,40 +167,61 @@
</p> </p>
<p> <p>
Create API configurations to expose Odoo models through REST endpoints. Create API configurations to expose Odoo models through REST endpoints.
You can control which HTTP methods are allowed and configure field access. </p>
<p>
<a href="/api/v1/docs" target="_blank" class="btn btn-primary">
<i class="fa fa-book"/> View API Documentation
</a>
</p> </p>
</field> </field>
</record> </record>
<!-- Acción para crear configuraciones por defecto --> <!-- Server action SIN f-strings -->
<record id="action_create_default_api_configs" model="ir.actions.server"> <record id="action_create_default_api_configs" model="ir.actions.server">
<field name="name">Create Default API Configurations</field> <field name="name">Create Default API Configurations</field>
<field name="model_id" ref="model_connection_api"/> <field name="model_id" ref="model_connection_api"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code"> <field name="code"><![CDATA[
created = model.create_default_configurations() # Crear configuraciones básicas sin f-strings
default_models = ['res.partner', 'product.product', 'sale.order']
created = []
for model_name in default_models:
model_obj = env['ir.model'].search([('model', '=', model_name)], limit=1)
if model_obj:
existing = model.search([('model_id', '=', model_obj.id)])
if not existing:
config = model.create({
'model_id': model_obj.id,
'description': 'Default API configuration for ' + model_obj.name,
'is_get': True,
'is_post': True,
'is_put': True,
'is_delete': False
})
created.append(config)
if created: if created:
message = f"Created {len(created)} default API configurations: {', '.join([c.display_name for c in created])}" message = "Created " + str(len(created)) + " default configurations"
msg_type = 'success'
else: else:
message = "No new configurations created. Default configurations already exist." message = "Default configurations already exist"
msg_type = 'info'
action = { action = {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'display_notification', 'tag': 'display_notification',
'params': { 'params': {
'message': message, 'message': message,
'type': 'success' if created else 'info', 'type': msg_type,
'sticky': False, 'sticky': False,
} }
} }
</field> ]]></field>
</record> </record>
<!-- Menú items mejorados --> <!-- Menús -->
<menuitem id="rest_api_root" <menuitem id="rest_api_root" name="REST API" sequence="10"/>
name="REST API"
sequence="10"
web_icon="rest_api_odoo,static/description/icon.png"/>
<menuitem id="rest_api_config_menu" <menuitem id="rest_api_config_menu"
name="API Configuration" name="API Configuration"

113
rest_api_odoo/views/res_users_views.xml

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- Vista de usuario mejorada para gestión de API keys --> <!-- Vista de usuario para gestión de API keys -->
<record id="view_users_form_api_enhanced" model="ir.ui.view"> <record id="view_users_form_api_enhanced" model="ir.ui.view">
<field name="name">view.users.form.inherit.rest.api.enhanced</field> <field name="name">view.users.form.inherit.rest.api.enhanced</field>
<field name="inherit_id" ref="base.view_users_form"/> <field name="inherit_id" ref="base.view_users_form"/>
@ -10,8 +10,7 @@
<page string="REST API" name="rest-api" groups="base.group_system"> <page string="REST API" name="rest-api" groups="base.group_system">
<group> <group>
<group string="API Key Management"> <group string="API Key Management">
<field name="api_key" readonly="1" password="True" <field name="api_key" readonly="1" password="True"/>
placeholder="No API key generated yet"/>
<field name="api_key_created" readonly="1"/> <field name="api_key_created" readonly="1"/>
<field name="api_key_expiry" readonly="1"/> <field name="api_key_expiry" readonly="1"/>
</group> </group>
@ -21,30 +20,30 @@
</group> </group>
</group> </group>
<div class="oe_button_box" name="button_box_api"> <div class="oe_button_box">
<button name="generate_api_key" type="object" <button name="action_generate_api_key" type="object"
string="Generate API Key" string="Generate API Key"
class="btn-primary" class="btn-primary"
invisible="api_key" invisible="api_key"
confirm="This will generate a new API key. Continue?"/> confirm="This will generate a new API key. Continue?"/>
<button name="regenerate_api_key" type="object" <button name="action_regenerate_api_key" type="object"
string="Regenerate API Key" string="Regenerate API Key"
class="btn-warning" class="btn-warning"
invisible="not api_key" invisible="not api_key"
confirm="This will invalidate the current API key and generate a new one. All applications using the current key will stop working. Continue?"/> confirm="This will invalidate the current API key. Continue?"/>
<button name="revoke_api_key" type="object" <button name="action_revoke_api_key" type="object"
string="Revoke API Key" string="Revoke API Key"
class="btn-danger" class="btn-danger"
invisible="not api_key" invisible="not api_key"
confirm="This will permanently revoke the API key. All applications using this key will stop working. Continue?"/> confirm="This will permanently revoke the API key. Continue?"/>
</div> </div>
<div class="alert alert-info" role="alert" invisible="not api_key"> <div class="alert alert-info" role="alert" invisible="not api_key">
<h4><i class="fa fa-info-circle"/> API Usage Instructions</h4> <h4>API Usage Instructions</h4>
<p><strong>1. Authentication:</strong> Include your API key in the request header:</p> <p><strong>Authentication:</strong> Include your API key in the request header:</p>
<pre><code>X-API-Key: <field name="api_key" nolabel="1" readonly="1"/></code></pre> <pre><code>X-API-Key: [Your API Key Here]</code></pre>
<p><strong>2. Available Endpoints:</strong></p> <p><strong>Available Endpoints:</strong></p>
<ul> <ul>
<li><code>GET /api/v1/models</code> - List available models</li> <li><code>GET /api/v1/models</code> - List available models</li>
<li><code>GET /api/v1/{model}</code> - Get all records</li> <li><code>GET /api/v1/{model}</code> - Get all records</li>
@ -54,67 +53,75 @@
<li><code>DELETE /api/v1/{model}/{id}</code> - Delete record</li> <li><code>DELETE /api/v1/{model}/{id}</code> - Delete record</li>
</ul> </ul>
<p><strong>3. Example cURL request:</strong></p> <p><strong>Example:</strong></p>
<pre><code>curl -X GET http://your-odoo-server/api/v1/res.partner \ <pre><code>curl -X GET http://your-server/api/v1/res.partner -H "X-API-Key: [Key]"</code></pre>
-H "X-API-Key: <field name="api_key" nolabel="1" readonly="1"/>" \
-H "Content-Type: application/json"</code></pre>
</div> </div>
<div class="alert alert-warning" role="alert" invisible="api_key"> <div class="alert alert-warning" role="alert" invisible="api_key">
<h4><i class="fa fa-warning"/> No API Key Generated</h4> <h4>No API Key Generated</h4>
<p>Click "Generate API Key" to create an API key for this user. <p>Click "Generate API Key" to create an API key for this user.</p>
The API key is required to authenticate REST API requests.</p>
</div>
<div class="alert alert-danger" role="alert" invisible="not api_key_expiry or api_key_expiry &gt; context_today()">
<h4><i class="fa fa-exclamation-triangle"/> API Key Expired</h4>
<p>This API key has expired and will not work for authentication.
Please generate a new API key.</p>
</div> </div>
</page> </page>
</xpath> </xpath>
</field> </field>
</record> </record>
<!-- Vista de lista mejorada para usuarios con información de API --> <!-- Vista de lista corregida - Usando base.view_users_tree -->
<record id="view_users_tree_api_info" model="ir.ui.view"> <record id="view_users_list_api_info" model="ir.ui.view">
<field name="name">view.users.tree.api.info</field> <field name="name">view.users.list.api.info</field>
<field name="inherit_id" ref="base.view_users_tree"/> <field name="inherit_id" ref="base.view_users_tree"/>
<field name="model">res.users</field> <field name="model">res.users</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<field name="login_date" position="after"> <field name="login_date" position="after">
<field name="api_key" string="Has API Key" <field name="api_key" string="Has API Key" widget="boolean" optional="hide"/>
widget="boolean" optional="hide"/> <field name="api_requests_count" string="API Requests" optional="hide"/>
<field name="api_requests_count" string="API Requests" <field name="api_key_last_used" string="API Last Used" optional="hide"/>
optional="hide"/>
<field name="api_key_last_used" string="API Last Used"
optional="hide"/>
</field> </field>
</field> </field>
</record> </record>
<!-- Acción del servidor para limpiar API keys expiradas --> <!-- Server action SIMPLIFICADO sin f-strings -->
<record id="action_cleanup_expired_api_keys" model="ir.actions.server"> <record id="action_cleanup_expired_api_keys" model="ir.actions.server">
<field name="name">Cleanup Expired API Keys</field> <field name="name">Cleanup Expired API Keys</field>
<field name="model_id" ref="base.model_res_users"/> <field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code"> <field name="code"><![CDATA[
cleaned_count = model.cleanup_expired_api_keys() # Código simple sin imports prohibidos y sin f-strings
message = f"Cleaned {cleaned_count} expired API keys." if cleaned_count > 0 else "No expired API keys found." expired_users = model.search([
('api_key_expiry', '!=', False),
('api_key_expiry', '&lt;', fields.Datetime.now())
])
cleaned_count = 0
for user in expired_users:
user.write({
'api_key': False,
'api_key_expiry': False,
'api_key_created': False,
'api_key_last_used': False
})
cleaned_count += 1
if cleaned_count > 0:
message = "Cleaned " + str(cleaned_count) + " expired API keys."
msg_type = 'success'
else:
message = "No expired API keys found."
msg_type = 'info'
action = { action = {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'display_notification', 'tag': 'display_notification',
'params': { 'params': {
'message': message, 'message': message,
'type': 'success' if cleaned_count > 0 else 'info', 'type': msg_type,
'sticky': False, 'sticky': False,
} }
} }
</field> ]]></field>
</record> </record>
<!-- Menú para gestión de API keys (solo para administradores) --> <!-- Menús -->
<menuitem id="rest_api_users_menu" <menuitem id="rest_api_users_menu"
name="API Users Management" name="API Users Management"
parent="rest_api_root" parent="rest_api_root"
@ -129,15 +136,31 @@ action = {
sequence="40" sequence="40"
groups="base.group_system"/> groups="base.group_system"/>
<!-- Cron job para limpiar API keys expiradas automáticamente --> <!-- Cron job SIMPLIFICADO -->
<record id="ir_cron_cleanup_expired_api_keys" model="ir.cron"> <record id="ir_cron_cleanup_expired_api_keys" model="ir.cron">
<field name="name">Cleanup Expired API Keys</field> <field name="name">Cleanup Expired API Keys</field>
<field name="model_id" ref="base.model_res_users"/> <field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code">model.cleanup_expired_api_keys()</field> <field name="code"><![CDATA[
# Código simple para cron
try:
expired_users = model.search([
('api_key_expiry', '!=', False),
('api_key_expiry', '&lt;', fields.Datetime.now())
])
for user in expired_users:
user.write({
'api_key': False,
'api_key_expiry': False,
'api_key_created': False,
'api_key_last_used': False
})
except Exception:
pass
]]></field>
<field name="interval_number">1</field> <field name="interval_number">1</field>
<field name="interval_type">days</field> <field name="interval_type">days</field>
<field name="active">True</field> <field name="active">True</field>
</record> </record>
</odoo> </odoo>

Loading…
Cancel
Save