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. 456
      rest_api_odoo/controllers/rest_api_odoo.py
  4. 1163
      rest_api_odoo/controllers/swagger_controller.py
  5. 150
      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 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

456
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(<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 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"""
try:
response = request.make_response(
json.dumps(data, ensure_ascii=False, indent=2),
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
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,23 +57,32 @@ class RestApi(http.Controller):
for record in records:
serialized_record = {}
for key, value in record.items():
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 hasattr(value, '__iter__') and not isinstance(value, (str, dict)):
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"""
try:
model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1)
if not model_obj:
return None, "Modelo no encontrado"
@ -129,72 +96,131 @@ class RestApi(http.Controller):
return None, "Modelo no configurado para API REST"
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
try:
if method == 'GET':
# Para GET, intentar obtener campos del query string o JSON body
query_params = dict(request.httprequest.args)
# 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()]
# 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)
json_data = json.loads(request.httprequest.data.decode('utf-8'))
data.update(json_data)
if 'fields' in json_data:
if 'fields' in json_data and not fields:
fields = json_data['fields']
except json.JSONDecodeError:
if 'domain' in json_data and not domain:
domain = json_data['domain']
except:
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)
data = json.loads(request.httprequest.data.decode('utf-8'))
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 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, None
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 <token>)"
return self._validate_jwt_token(auth_header)
@http.route(['/api/v1/<model_name>', '/api/v1/<model_name>/<int:record_id>'],
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"""
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)
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):
"""Maneja requests GET"""
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
# 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(serialized_records),
"data": serialized_records
"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 <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)}")
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"""
"""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",
"version": "1.0.0",
"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 <token>",
"expires_in_hours": "configurable (default: 24h)"
}
}
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)

1163
rest_api_odoo/controllers/swagger_controller.py

File diff suppressed because it is too large

150
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
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(),
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)"""
try:
expired_users = self.search([
('api_key_expiry', '!=', False),
('api_key_expiry', '<', datetime.now())
('api_key_expiry', '<', fields.Datetime.now())
])
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)
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
}

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
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"?>
<odoo>
<!-- Form view mejorada para 'connection.api' -->
<!-- Form view para 'connection.api' -->
<record id="connection_api_view_form" model="ir.ui.view">
<field name="name">connection.api.view.form</field>
<field name="model">connection.api</field>
@ -16,8 +16,7 @@
<button name="action_test_api_endpoint" type="object"
string="Test Endpoint" class="btn-info"
invisible="not model_id"/>
<field name="active" widget="boolean_toggle"
options="{'autosave': False}"/>
<field name="active" widget="boolean_toggle"/>
</header>
<sheet>
<div class="oe_title">
@ -32,7 +31,7 @@
<group>
<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..."/>
</group>
<group string="HTTP Methods">
@ -49,11 +48,16 @@
<group string="Allowed Fields">
<field name="allowed_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 string="Forbidden Fields">
<field name="forbidden_fields"
widget="text"/>
<field name="forbidden_fields" widget="text" nolabel="1"/>
<div class="text-muted">
<small>Lista de campos prohibidos separados por comas.</small>
</div>
</group>
</group>
</page>
@ -77,6 +81,10 @@
<a href="/api/v1/docs" target="_blank" class="btn btn-info">
<i class="fa fa-book"/> View Swagger Documentation
</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>
</group>
</group>
@ -84,15 +92,15 @@
<div class="alert alert-info" role="alert" invisible="not api_endpoint">
<h4>Usage Examples:</h4>
<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/>
<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/>
<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/>
<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/>
<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>
</div>
</page>
@ -102,13 +110,13 @@
</field>
</record>
<!-- List view mejorada para 'connection.api' -->
<!-- List view para 'connection.api' -->
<record id="connection_api_view_list" model="ir.ui.view">
<field name="name">connection.api.view.list</field>
<field name="model">connection.api</field>
<field name="arch" type="xml">
<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="model_id"/>
<field name="is_get" widget="boolean_toggle"/>
@ -127,64 +135,7 @@
</field>
</record>
<!-- Vista Kanban para 'connection.api' -->
<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' -->
<!-- Vista de búsqueda -->
<record id="connection_api_view_search" model="ir.ui.view">
<field name="name">connection.api.view.search</field>
<field name="model">connection.api</field>
@ -196,11 +147,6 @@
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/>
<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">
<filter name="group_by_model" string="Model" context="{'group_by': 'model_id'}"/>
<filter name="group_by_active" string="Status" context="{'group_by': 'active'}"/>
@ -209,12 +155,11 @@
</field>
</record>
<!-- Acción mejorada para 'connection.api' -->
<!-- Acción principal -->
<record id="rest_api_root_action" model="ir.actions.act_window">
<field name="name">REST API Configuration</field>
<field name="type">ir.actions.act_window</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="help" type="html">
<p class="o_view_nocontent_smiling_face">
@ -222,40 +167,61 @@
</p>
<p>
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>
</field>
</record>
<!-- Acción para crear configuraciones por defecto -->
<!-- Server action SIN f-strings -->
<record id="action_create_default_api_configs" model="ir.actions.server">
<field name="name">Create Default API Configurations</field>
<field name="model_id" ref="model_connection_api"/>
<field name="state">code</field>
<field name="code">
created = model.create_default_configurations()
<field name="code"><![CDATA[
# 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:
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:
message = "No new configurations created. Default configurations already exist."
message = "Default configurations already exist"
msg_type = 'info'
action = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': message,
'type': 'success' if created else 'info',
'type': msg_type,
'sticky': False,
}
}
</field>
]]></field>
</record>
<!-- Menú items mejorados -->
<menuitem id="rest_api_root"
name="REST API"
sequence="10"
web_icon="rest_api_odoo,static/description/icon.png"/>
<!-- Menús -->
<menuitem id="rest_api_root" name="REST API" sequence="10"/>
<menuitem id="rest_api_config_menu"
name="API Configuration"

113
rest_api_odoo/views/res_users_views.xml

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<field name="name">view.users.form.inherit.rest.api.enhanced</field>
<field name="inherit_id" ref="base.view_users_form"/>
@ -10,8 +10,7 @@
<page string="REST API" name="rest-api" groups="base.group_system">
<group>
<group string="API Key Management">
<field name="api_key" readonly="1" password="True"
placeholder="No API key generated yet"/>
<field name="api_key" readonly="1" password="True"/>
<field name="api_key_created" readonly="1"/>
<field name="api_key_expiry" readonly="1"/>
</group>
@ -21,30 +20,30 @@
</group>
</group>
<div class="oe_button_box" name="button_box_api">
<button name="generate_api_key" type="object"
<div class="oe_button_box">
<button name="action_generate_api_key" type="object"
string="Generate API Key"
class="btn-primary"
invisible="api_key"
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"
class="btn-warning"
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?"/>
<button name="revoke_api_key" type="object"
confirm="This will invalidate the current API key. Continue?"/>
<button name="action_revoke_api_key" type="object"
string="Revoke API Key"
class="btn-danger"
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 class="alert alert-info" role="alert" invisible="not api_key">
<h4><i class="fa fa-info-circle"/> API Usage Instructions</h4>
<p><strong>1. 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>
<h4>API Usage Instructions</h4>
<p><strong>Authentication:</strong> Include your API key in the request header:</p>
<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>
<li><code>GET /api/v1/models</code> - List available models</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>
</ul>
<p><strong>3. Example cURL request:</strong></p>
<pre><code>curl -X GET http://your-odoo-server/api/v1/res.partner \
-H "X-API-Key: <field name="api_key" nolabel="1" readonly="1"/>" \
-H "Content-Type: application/json"</code></pre>
<p><strong>Example:</strong></p>
<pre><code>curl -X GET http://your-server/api/v1/res.partner -H "X-API-Key: [Key]"</code></pre>
</div>
<div class="alert alert-warning" role="alert" invisible="api_key">
<h4><i class="fa fa-warning"/> No API Key Generated</h4>
<p>Click "Generate API Key" to create an API key for this user.
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>
<h4>No API Key Generated</h4>
<p>Click "Generate API Key" to create an API key for this user.</p>
</div>
</page>
</xpath>
</field>
</record>
<!-- Vista de lista mejorada para usuarios con información de API -->
<record id="view_users_tree_api_info" model="ir.ui.view">
<field name="name">view.users.tree.api.info</field>
<!-- Vista de lista corregida - Usando base.view_users_tree -->
<record id="view_users_list_api_info" model="ir.ui.view">
<field name="name">view.users.list.api.info</field>
<field name="inherit_id" ref="base.view_users_tree"/>
<field name="model">res.users</field>
<field name="arch" type="xml">
<field name="login_date" position="after">
<field name="api_key" string="Has API Key"
widget="boolean" optional="hide"/>
<field name="api_requests_count" string="API Requests"
optional="hide"/>
<field name="api_key_last_used" string="API Last Used"
optional="hide"/>
<field name="api_key" string="Has API Key" widget="boolean" optional="hide"/>
<field name="api_requests_count" string="API Requests" optional="hide"/>
<field name="api_key_last_used" string="API Last Used" optional="hide"/>
</field>
</field>
</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">
<field name="name">Cleanup Expired API Keys</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field>
<field name="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."
<field name="code"><![CDATA[
# Código simple sin imports prohibidos y sin f-strings
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 = {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': message,
'type': 'success' if cleaned_count > 0 else 'info',
'type': msg_type,
'sticky': False,
}
}
</field>
]]></field>
</record>
<!-- Menú para gestión de API keys (solo para administradores) -->
<!-- Menús -->
<menuitem id="rest_api_users_menu"
name="API Users Management"
parent="rest_api_root"
@ -129,15 +136,31 @@ action = {
sequence="40"
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">
<field name="name">Cleanup Expired API Keys</field>
<field name="model_id" ref="base.model_res_users"/>
<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_type">days</field>
<field name="active">True</field>
</record>
</odoo>

Loading…
Cancel
Save