From 6acd4f65119d7dd180f016e2d6f84a6c79f36bd9 Mon Sep 17 00:00:00 2001 From: Bernat Roig Date: Tue, 2 Sep 2025 15:09:16 +0200 Subject: [PATCH 1/8] Enhance REST API controller: GET params support and safe serialization - Added support for retrieving fields via query parameters in GET requests, in addition to JSON body. - Improved response serialization, including: * Conversion of datetime/date to ISO format. * Conversion of binary fields to base64. * Safe handling of non-serializable iterables. - Added error handling blocks with logging for better traceability. - Simplified fields extraction logic in GET/POST/PUT. - Preserved compatibility with api-key authentication and authorization logic. --- rest_api_odoo/controllers/rest_api_odoo.py | 155 ++++++++++++++++++--- 1 file changed, 133 insertions(+), 22 deletions(-) diff --git a/rest_api_odoo/controllers/rest_api_odoo.py b/rest_api_odoo/controllers/rest_api_odoo.py index 040a561d1..603e538d2 100644 --- a/rest_api_odoo/controllers/rest_api_odoo.py +++ b/rest_api_odoo/controllers/rest_api_odoo.py @@ -21,6 +21,7 @@ ############################################################################# import json import logging +import base64 from odoo import http from odoo.http import request from datetime import datetime, date @@ -46,21 +47,65 @@ class RestApi(http.Controller): "!") return response - def generate_response(self, method, model, rec_id): + def generate_response(self, method, model, rec_id, request_params=None): """This function is used to generate the response based on the type of request and the parameters given""" option = request.env['connection.api'].search( [('model_id', '=', model)], limit=1) model_name = option.model_id.model - if method != 'DELETE': - data = json.loads(request.httprequest.data) + + # Handle data based on method + if method == 'GET': + # For GET requests, check both JSON body and query parameters + data = request_params or {} + fields = [] + + # First, try to get fields from JSON body + try: + if request.httprequest.data: + json_data = json.loads(request.httprequest.data) + if 'fields' in json_data: + fields = json_data['fields'] + except json.JSONDecodeError: + pass # If JSON is invalid, continue with query params + + # If no fields from JSON, try query parameters + if not fields: + fields_param = data.get('fields', '') + if fields_param: + fields = [field.strip() for field in fields_param.split(',')] + + # If still no fields, use defaults based on record type + if not fields: + if rec_id != 0: + # For specific record, get all fields + fields = None # This will get all available fields + else: + # For all records, use minimal fields + fields = ['id', 'display_name'] + elif method != 'DELETE': + # For POST/PUT requests, parse JSON from body + try: + if request.httprequest.data: + data = json.loads(request.httprequest.data) + else: + data = {} + except json.JSONDecodeError: + return ("

Invalid JSON Data" + "

") else: + # DELETE method data = {} - fields = [] - if data: - for field in data['fields']: - fields.append(field) - if not fields and method != 'DELETE': + fields = [] + + # Extract fields for POST/PUT methods + if method in ['POST', 'PUT'] and data: + fields = [] + if 'fields' in data: + for field in data['fields']: + fields.append(field) + + if not fields and method != 'DELETE' and method != 'GET': return ("

No fields selected for the model" "

") if not option: @@ -68,34 +113,55 @@ class RestApi(http.Controller): "") try: if method == 'GET': - fields = [] - for field in data['fields']: - fields.append(field) if not option.is_get: return ("

Method Not Allowed" "

") else: datas = [] if rec_id != 0: + # For specific record + search_fields = fields if fields is not None else [] partner_records = request.env[ str(model_name)].search_read( domain=[('id', '=', rec_id)], - fields=fields + fields=search_fields ) for record in partner_records: for key, value in record.items(): if isinstance(value, (datetime, date)): record[key] = value.isoformat() + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) data = json.dumps({ 'records': partner_records }) datas.append(data) return request.make_response(data=datas) else: + # For all records + search_fields = fields if fields is not None else ['id', 'display_name'] partner_records = request.env[ str(model_name)].search_read( domain=[], - fields=fields + fields=search_fields ) for record in partner_records: for key, value in record.items(): @@ -106,16 +172,17 @@ class RestApi(http.Controller): }) datas.append(data) return request.make_response(data=datas) - except: - return ("

Invalid JSON Data" + except Exception as e: + _logger.error(f"Error in GET method: {str(e)}") + return ("

Error processing request" "

") + if method == 'POST': if not option.is_post: return ("

Method Not Allowed" "

") else: try: - data = json.loads(request.httprequest.data) datas = [] new_resource = request.env[str(model_name)].create( data['values']) @@ -128,12 +195,34 @@ class RestApi(http.Controller): for key, value in record.items(): if isinstance(value, (datetime, date)): record[key] = value.isoformat() + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) new_data = json.dumps({'New resource': partner_records, }) datas.append(new_data) return request.make_response(data=datas) - except: + except Exception as e: + _logger.error(f"Error in POST method: {str(e)}") return ("

Invalid JSON Data" "

") + if method == 'PUT': if not option.is_put: return ("

Method Not Allowed" @@ -151,7 +240,6 @@ class RestApi(http.Controller): else: try: datas = [] - data = json.loads(request.httprequest.data) resource.write(data['values']) partner_records = request.env[ str(model_name)].search_read( @@ -162,15 +250,37 @@ class RestApi(http.Controller): for key, value in record.items(): if isinstance(value, (datetime, date)): record[key] = value.isoformat() + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) + elif isinstance(value, bytes): + # Convert bytes to base64 string for JSON serialization + import base64 + record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + # Handle other non-serializable iterables + try: + record[key] = list(value) if value else [] + except: + record[key] = str(value) new_data = json.dumps( {'Updated resource': partner_records, }) datas.append(new_data) return request.make_response(data=datas) - except: + except Exception as e: + _logger.error(f"Error in PUT method: {str(e)}") return ("

Invalid JSON Data " "!

") + if method == 'DELETE': if not option.is_delete: return ("

Method Not Allowed" @@ -186,7 +296,6 @@ class RestApi(http.Controller): return ("

Resource not found" "

") else: - records = request.env[ str(model_name)].search_read( domain=[('id', '=', resource.id)], @@ -227,7 +336,8 @@ class RestApi(http.Controller): rec_id = 0 else: rec_id = int(kw.get('Id')) - result = self.generate_response(http_method, model_id.id, rec_id) + # Pass the query parameters for GET requests + result = self.generate_response(http_method, model_id.id, rec_id, kw) return result else: return auth_api @@ -252,6 +362,7 @@ class RestApi(http.Controller): "User": user.name, "api-key": api_key}) return request.make_response(data=datas) - except: + except Exception as e: + _logger.error(f"Error in authentication: {str(e)}") return ("

wrong login credentials" "

") From 933bd342febda3f79534e9a0849f03384451602d Mon Sep 17 00:00:00 2001 From: Bernat Roig Date: Wed, 10 Sep 2025 17:21:34 +0200 Subject: [PATCH 2/8] API configuration improvements --- rest_api_odoo/models/connection_api.py | 265 ++++++++++++++++++++++--- 1 file changed, 242 insertions(+), 23 deletions(-) diff --git a/rest_api_odoo/models/connection_api.py b/rest_api_odoo/models/connection_api.py index 52da21c0f..1b752e2e9 100644 --- a/rest_api_odoo/models/connection_api.py +++ b/rest_api_odoo/models/connection_api.py @@ -1,10 +1,11 @@ -# -*- coding:utf-8 -*- +# -*- coding: utf-8 -*- ############################################################################# # # Cybrosys Technologies Pvt. Ltd. # # Copyright (C) 2024-TODAY Cybrosys Technologies() # Author: Ayana KP (odoo@cybrosys.com) +# Modified by: Broigm - API configuration improvements # # You can modify it under the terms of the GNU LESSER # GENERAL PUBLIC LICENSE (LGPL v3), Version 3. @@ -19,29 +20,247 @@ # If not, see . # ############################################################################# -from odoo import fields, models +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError class ConnectionApi(models.Model): - """This class is used to create an api model in which we can create - records with models and fields, and also we can specify methods.""" + """Configuración de modelos para REST API con mejores controles""" _name = 'connection.api' - _description = 'Connection Rest Api' - _rec_name = 'model_id' - - model_id = fields.Many2one('ir.model', string="Model", - domain="[('transient', '=', False)]", - help="Select model which can be accessed by " - "REST api requests.") - is_get = fields.Boolean(string='GET', - help="Select this to enable GET method " - "while sending requests.") - is_post = fields.Boolean(string='POST', - help="Select this to enable POST method" - "while sending requests.") - is_put = fields.Boolean(string='PUT', - help="Select this to enable PUT method " - "while sending requests.") - is_delete = fields.Boolean(string='DELETE', - help="Select this to enable DELETE method " - "while sending requests.") + _description = 'REST API Configuration' + _rec_name = 'display_name' + + model_id = fields.Many2one( + 'ir.model', + string="Model", + required=True, + ondelete='cascade', + domain="[('transient', '=', False)]", + help="Modelo que será accesible a través de la REST API." + ) + + display_name = fields.Char( + string="Name", + compute='_compute_display_name', + store=True + ) + + # Permisos de métodos HTTP + is_get = fields.Boolean( + string='GET (Read)', + default=True, + help="Permite operaciones de lectura (GET) en este modelo." + ) + + is_post = fields.Boolean( + string='POST (Create)', + default=False, + help="Permite operaciones de creación (POST) en este modelo." + ) + + is_put = fields.Boolean( + string='PUT (Update)', + default=False, + help="Permite operaciones de actualización (PUT) en este modelo." + ) + + is_delete = fields.Boolean( + string='DELETE', + default=False, + help="Permite operaciones de eliminación (DELETE) en este modelo." + ) + + # Configuraciones adicionales + active = fields.Boolean( + string="Active", + default=True, + help="Si está desactivado, el modelo no estará disponible en la API." + ) + + allowed_fields = fields.Text( + string="Allowed Fields", + help="Lista de campos permitidos separados por comas. Si está vacío, todos los campos son permitidos." + ) + + forbidden_fields = fields.Text( + string="Forbidden Fields", + default="__last_update,create_uid,create_date,write_uid,write_date", + help="Lista de campos prohibidos separados por comas." + ) + + max_records_limit = fields.Integer( + string="Max Records Limit", + default=1000, + help="Límite máximo de registros que se pueden obtener en una sola request GET." + ) + + require_record_id_for_write = fields.Boolean( + string="Require ID for Write Operations", + default=True, + help="Si está marcado, las operaciones PUT/DELETE requieren un ID específico." + ) + + # Campos informativos + api_endpoint = fields.Char( + string="API Endpoint", + compute='_compute_api_endpoint', + help="URL del endpoint de la API para este modelo." + ) + + description = fields.Text( + string="Description", + help="Descripción del propósito de esta configuración de API." + ) + + @api.depends('model_id') + def _compute_display_name(self): + """Calcula el nombre para mostrar""" + for record in self: + if record.model_id: + record.display_name = f"API: {record.model_id.name}" + else: + record.display_name = "API Configuration" + + @api.depends('model_id') + def _compute_api_endpoint(self): + """Calcula la URL del endpoint de la API""" + for record in self: + if record.model_id: + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') + record.api_endpoint = f"{base_url}/api/v1/{record.model_id.model}" + else: + record.api_endpoint = "" + + @api.constrains('model_id') + def _check_unique_model(self): + """Valida que no haya configuraciones duplicadas para el mismo modelo""" + for record in self: + if record.model_id: + existing = self.search([ + ('model_id', '=', record.model_id.id), + ('id', '!=', record.id) + ]) + if existing: + raise ValidationError( + _("Ya existe una configuración de API para el modelo %s") % record.model_id.name + ) + + @api.constrains('max_records_limit') + def _check_max_records_limit(self): + """Valida el límite máximo de registros""" + for record in self: + if record.max_records_limit <= 0: + raise ValidationError(_("El límite máximo de registros debe ser mayor a 0")) + if record.max_records_limit > 10000: + raise ValidationError(_("El límite máximo de registros no puede exceder 10,000")) + + def get_allowed_fields(self): + """Obtiene la lista de campos permitidos para este modelo""" + self.ensure_one() + + if not self.allowed_fields: + # Si no hay campos específicos permitidos, obtener todos los campos del modelo + model_obj = self.env[self.model_id.model] + all_fields = list(model_obj._fields.keys()) + else: + all_fields = [field.strip() for field in self.allowed_fields.split(',') if field.strip()] + + # Remover campos prohibidos + forbidden = [] + if self.forbidden_fields: + forbidden = [field.strip() for field in self.forbidden_fields.split(',') if field.strip()] + + allowed = [field for field in all_fields if field not in forbidden] + return allowed + + def get_forbidden_fields(self): + """Obtiene la lista de campos prohibidos""" + self.ensure_one() + + if not self.forbidden_fields: + return [] + + return [field.strip() for field in self.forbidden_fields.split(',') if field.strip()] + + def is_method_allowed(self, method): + """Verifica si un método HTTP está permitido""" + self.ensure_one() + + if not self.active: + return False + + method_map = { + 'GET': self.is_get, + 'POST': self.is_post, + 'PUT': self.is_put, + 'DELETE': self.is_delete + } + + return method_map.get(method.upper(), False) + + def action_test_api_endpoint(self): + """Acción para probar el endpoint de la API (útil para botón en vista)""" + self.ensure_one() + + if not self.model_id: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': _('No model selected'), + 'type': 'warning', + } + } + + return { + 'type': 'ir.actions.act_url', + 'url': self.api_endpoint, + 'target': 'new', + } + + @api.model + def get_api_statistics(self): + """Obtiene estadísticas de uso de la API""" + stats = {} + + # Estadísticas por modelo + api_configs = self.search([('active', '=', True)]) + stats['active_models'] = len(api_configs) + stats['total_models'] = len(self.search([])) + + # Estadísticas de usuarios con API keys + users_with_keys = self.env['res.users'].search([('api_key', '!=', False)]) + stats['users_with_api_keys'] = len(users_with_keys) + stats['total_api_requests'] = sum(users_with_keys.mapped('api_requests_count')) + + return stats + + def toggle_active(self): + """Alterna el estado activo de la configuración""" + for record in self: + record.active = not record.active + + @api.model + def create_default_configurations(self): + """Crea configuraciones por defecto para modelos comunes""" + default_models = [ + ('res.partner', {'is_get': True, 'is_post': True, 'is_put': True, 'is_delete': False}), + ('product.product', {'is_get': True, 'is_post': True, 'is_put': True, 'is_delete': False}), + ('sale.order', {'is_get': True, 'is_post': True, 'is_put': True, 'is_delete': False}), + ('account.move', {'is_get': True, 'is_post': False, 'is_put': False, 'is_delete': False}), + ] + + created_configs = [] + for model_name, config in default_models: + model_obj = self.env['ir.model'].search([('model', '=', model_name)], limit=1) + if model_obj: + existing = self.search([('model_id', '=', model_obj.id)]) + if not existing: + api_config = self.create({ + 'model_id': model_obj.id, + 'description': f'Default API configuration for {model_obj.name}', + **config + }) + created_configs.append(api_config) + + return created_configs From ff60d650708b38149447d826c306210c0907a1ea Mon Sep 17 00:00:00 2001 From: Bernat Roig Date: Wed, 10 Sep 2025 17:22:25 +0200 Subject: [PATCH 3/8] Improvements in API key management --- rest_api_odoo/models/res_users.py | 125 ++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/rest_api_odoo/models/res_users.py b/rest_api_odoo/models/res_users.py index b12d6e674..883fb33a3 100644 --- a/rest_api_odoo/models/res_users.py +++ b/rest_api_odoo/models/res_users.py @@ -1,10 +1,11 @@ -# -*- coding:utf-8 -*- +# -*- coding: utf-8 -*- ############################################################################# # # Cybrosys Technologies Pvt. Ltd. # # Copyright (C) 2024-TODAY Cybrosys Technologies() # Author: Ayana KP (odoo@cybrosys.com) +# Modified by: Broigm - Improvements in API key management # # You can modify it under the terms of the GNU LESSER # GENERAL PUBLIC LICENSE (LGPL v3), Version 3. @@ -20,25 +21,117 @@ # ############################################################################# import uuid -from odoo import fields, models +import secrets +import string +from datetime import datetime, timedelta +from odoo import fields, models, api class ResUsers(models.Model): - """This class is used to inherit users and add api key generation""" + """Extensión del modelo de usuarios para gestión de API keys""" _inherit = 'res.users' - api_key = fields.Char(string="API Key", readonly=True, - help="Api key for connecting with the " - "Database.The key will be " - "generated when authenticating " - "rest api.") + api_key = fields.Char( + string="API Key", + readonly=True, + help="Clave API para autenticación en REST API. Se genera automáticamente." + ) + api_key_expiry = fields.Datetime( + string="API Key Expiry", + help="Fecha de expiración de la API key (opcional)" + ) + api_key_created = fields.Datetime( + string="API Key Created", + help="Fecha de creación de la API key" + ) + api_key_last_used = fields.Datetime( + string="API Key Last Used", + help="Última vez que se usó la API key" + ) + api_requests_count = fields.Integer( + string="API Requests Count", + default=0, + help="Contador de requests realizados con esta API key" + ) - def generate_api(self, username): - """This function is used to generate api-key for each user""" - users = self.env['res.users'].sudo().search([('login', '=', username)]) - if not users.api_key: - users.api_key = str(uuid.uuid4()) - key = users.api_key + def generate_api_key(self, force_new=False): + """ + Genera una nueva API key o devuelve la existente + Args: + force_new (bool): Fuerza la generación de una nueva key + Returns: + str: API key generada + """ + if not self.api_key or force_new: + # Generar una API key más segura + alphabet = string.ascii_letters + string.digits + api_key = ''.join(secrets.choice(alphabet) for _ in range(64)) + + self.write({ + 'api_key': api_key, + 'api_key_created': datetime.now(), + 'api_key_last_used': None, + 'api_requests_count': 0 + }) + + return self.api_key + + def regenerate_api_key(self): + """Regenera la API key (útil para botón en interfaz)""" + return self.generate_api_key(force_new=True) + + def revoke_api_key(self): + """Revoca la API key actual""" + self.write({ + 'api_key': False, + 'api_key_expiry': False, + 'api_key_created': False, + 'api_key_last_used': False + }) + + def set_api_key_expiry(self, days=None): + """ + Establece fecha de expiración para la API key + Args: + days (int): Días hasta la expiración (default: sin expiración) + """ + if days: + expiry_date = datetime.now() + timedelta(days=days) + self.api_key_expiry = expiry_date else: - key = users.api_key - return key + self.api_key_expiry = False + + def update_api_key_usage(self): + """Actualiza estadísticas de uso de la API key""" + self.write({ + 'api_key_last_used': datetime.now(), + 'api_requests_count': self.api_requests_count + 1 + }) + + @api.model + def cleanup_expired_api_keys(self): + """Limpia API keys expiradas (para ejecutar en cron)""" + expired_users = self.search([ + ('api_key_expiry', '!=', False), + ('api_key_expiry', '<', datetime.now()) + ]) + + for user in expired_users: + user.revoke_api_key() + + return len(expired_users) + + def is_api_key_valid(self): + """Verifica si la API key es válida y no ha expirado""" + if not self.api_key: + return False + + if self.api_key_expiry and self.api_key_expiry < datetime.now(): + return False + + return 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() From 92d2823a537342f78fa75bb097846de45953cc03 Mon Sep 17 00:00:00 2001 From: Bernat Roig Date: Wed, 10 Sep 2025 17:23:39 +0200 Subject: [PATCH 4/8] Improvements in authentication and structure --- rest_api_odoo/controllers/rest_api_odoo.py | 680 +++++++++++---------- 1 file changed, 364 insertions(+), 316 deletions(-) diff --git a/rest_api_odoo/controllers/rest_api_odoo.py b/rest_api_odoo/controllers/rest_api_odoo.py index 603e538d2..27d63afea 100644 --- a/rest_api_odoo/controllers/rest_api_odoo.py +++ b/rest_api_odoo/controllers/rest_api_odoo.py @@ -5,6 +5,7 @@ # # Copyright (C) 2024-TODAY Cybrosys Technologies() # Author: Ayana KP (odoo@cybrosys.com) +# Modified by: Broigm - Improvements in authentication and structure # # You can modify it under the terms of the GNU LESSER # GENERAL PUBLIC LICENSE (LGPL v3), Version 3. @@ -22,347 +23,394 @@ import json import logging import base64 +from datetime import datetime, date, timedelta from odoo import http from odoo.http import request -from datetime import datetime, date +from werkzeug.exceptions import BadRequest, Unauthorized, NotFound, MethodNotAllowed _logger = logging.getLogger(__name__) class RestApi(http.Controller): - """This is a controller which is used to generate responses based on the - api requests""" - - def auth_api_key(self, api_key): - """This function is used to authenticate the api-key when sending a - request""" - user_id = request.env['res.users'].sudo().search([('api_key', '=', api_key)]) - if api_key is not None and user_id: - response = True - elif not user_id: - response = ('

Invalid API Key ' - '!

') - else: - response = ("

No API Key Provided " - "!

") + """Controlador API REST mejorado con autenticación basada en API Key solamente""" + + def _json_response(self, data, status=200): + """Genera respuesta JSON estandarizada""" + response = request.make_response( + json.dumps(data, ensure_ascii=False, indent=2), + headers=[('Content-Type', 'application/json')] + ) + response.status_code = status return response - def generate_response(self, method, model, rec_id, request_params=None): - """This function is used to generate the response based on the type - of request and the parameters given""" - option = request.env['connection.api'].search( - [('model_id', '=', model)], limit=1) - model_name = option.model_id.model + def _error_response(self, message, status=400, error_code=None): + """Genera respuesta de error estandarizada""" + error_data = { + 'error': True, + 'message': message, + 'status_code': status + } + if error_code: + error_data['error_code'] = error_code + + 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 + + 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: + return [] + + serialized_records = [] + for record in records: + serialized_record = {} + for key, value in record.items(): + if isinstance(value, (datetime, date)): + serialized_record[key] = value.isoformat() + elif isinstance(value, bytes): + serialized_record[key] = base64.b64encode(value).decode('utf-8') + elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): + try: + serialized_record[key] = list(value) if value else [] + except: + serialized_record[key] = str(value) + else: + serialized_record[key] = value + serialized_records.append(serialized_record) + + return serialized_records + + def _get_model_config(self, model_name): + """Obtiene la configuración de la API para un modelo""" + model_obj = request.env['ir.model'].sudo().search([('model', '=', model_name)], limit=1) + if not model_obj: + return None, "Modelo no encontrado" + + api_config = request.env['connection.api'].sudo().search([ + ('model_id', '=', model_obj.id) + ], limit=1) + + if not api_config: + return None, "Modelo no configurado para API REST" + + return api_config, None + + def _parse_request_data(self, method): + """Parsea los datos de la request según el método HTTP""" + data = {} + fields = [] - # Handle data based on method if method == 'GET': - # For GET requests, check both JSON body and query parameters - data = request_params or {} - fields = [] + # Para GET, intentar obtener campos del query string o JSON body + query_params = dict(request.httprequest.args) - # First, try to get fields from JSON body try: if request.httprequest.data: json_data = json.loads(request.httprequest.data) + data.update(json_data) if 'fields' in json_data: fields = json_data['fields'] except json.JSONDecodeError: - pass # If JSON is invalid, continue with query params - - # If no fields from JSON, try query parameters - if not fields: - fields_param = data.get('fields', '') - if fields_param: - fields = [field.strip() for field in fields_param.split(',')] - - # If still no fields, use defaults based on record type - if not fields: - if rec_id != 0: - # For specific record, get all fields - fields = None # This will get all available fields - else: - # For all records, use minimal fields - fields = ['id', 'display_name'] - elif method != 'DELETE': - # For POST/PUT requests, parse JSON from body + pass + + if not fields and 'fields' in query_params: + fields = [field.strip() for field in query_params['fields'].split(',')] + + elif method in ['POST', 'PUT']: try: if request.httprequest.data: data = json.loads(request.httprequest.data) + if 'fields' in data: + fields = data['fields'] else: - data = {} + return None, None, "No se proporcionaron datos JSON" except json.JSONDecodeError: - return ("

Invalid JSON Data" - "

") - else: - # DELETE method - data = {} - fields = [] - - # Extract fields for POST/PUT methods - if method in ['POST', 'PUT'] and data: - fields = [] - if 'fields' in data: - for field in data['fields']: - fields.append(field) - - if not fields and method != 'DELETE' and method != 'GET': - return ("

No fields selected for the model" - "

") - if not option: - return ("

No Record Created for the model" - "

") + return None, None, "JSON inválido" + + return data, fields, None + + @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""" try: - if method == 'GET': - if not option.is_get: - return ("

Method Not Allowed" - "

") - else: - datas = [] - if rec_id != 0: - # For specific record - search_fields = fields if fields is not None else [] - partner_records = request.env[ - str(model_name)].search_read( - domain=[('id', '=', rec_id)], - fields=search_fields - ) - for record in partner_records: - for key, value in record.items(): - if isinstance(value, (datetime, date)): - record[key] = value.isoformat() - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - data = json.dumps({ - 'records': partner_records - }) - datas.append(data) - return request.make_response(data=datas) - else: - # For all records - search_fields = fields if fields is not None else ['id', 'display_name'] - partner_records = request.env[ - str(model_name)].search_read( - domain=[], - fields=search_fields - ) - for record in partner_records: - for key, value in record.items(): - if isinstance(value, (datetime, date)): - record[key] = value.isoformat() - data = json.dumps({ - 'records': partner_records - }) - datas.append(data) - return request.make_response(data=datas) + data = json.loads(request.httprequest.data or '{}') + except json.JSONDecodeError: + 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) + + 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) + + # Autenticar credenciales + auth_result = request.session.authenticate( + database, + {'login': username, 'password': password, 'type': 'password'} + ) + + 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() + + response_data = { + "success": True, + "message": "Autenticación exitosa", + "data": { + "user_id": user.id, + "username": user.login, + "name": user.name, + "api_key": api_key, + "database": database + } + } + + return self._json_response(response_data) + except Exception as e: - _logger.error(f"Error in GET method: {str(e)}") - return ("

Error processing request" - "

") - - if method == 'POST': - if not option.is_post: - return ("

Method Not Allowed" - "

") - else: - try: - datas = [] - new_resource = request.env[str(model_name)].create( - data['values']) - partner_records = request.env[ - str(model_name)].search_read( - domain=[('id', '=', new_resource.id)], - fields=fields - ) - for record in partner_records: - for key, value in record.items(): - if isinstance(value, (datetime, date)): - record[key] = value.isoformat() - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - new_data = json.dumps({'New resource': partner_records, }) - datas.append(new_data) - return request.make_response(data=datas) - except Exception as e: - _logger.error(f"Error in POST method: {str(e)}") - return ("

Invalid JSON Data" - "

") - - if method == 'PUT': - if not option.is_put: - return ("

Method Not Allowed" - "

") - else: - if rec_id == 0: - return ("

No ID Provided" - "

") - else: - resource = request.env[str(model_name)].browse( - int(rec_id)) - if not resource.exists(): - return ("

Resource not found" - "

") - else: - try: - datas = [] - resource.write(data['values']) - partner_records = request.env[ - str(model_name)].search_read( - domain=[('id', '=', resource.id)], - fields=fields - ) - for record in partner_records: - for key, value in record.items(): - if isinstance(value, (datetime, date)): - record[key] = value.isoformat() - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - elif isinstance(value, bytes): - # Convert bytes to base64 string for JSON serialization - import base64 - record[key] = base64.b64encode(value).decode('utf-8') - elif hasattr(value, '__iter__') and not isinstance(value, (str, dict)): - # Handle other non-serializable iterables - try: - record[key] = list(value) if value else [] - except: - record[key] = str(value) - new_data = json.dumps( - {'Updated resource': partner_records, - }) - datas.append(new_data) - return request.make_response(data=datas) - - except Exception as e: - _logger.error(f"Error in PUT method: {str(e)}") - return ("

Invalid JSON Data " - "!

") - - if method == 'DELETE': - if not option.is_delete: - return ("

Method Not Allowed" - "

") - else: - if rec_id == 0: - return ("

No ID Provided" - "

") - else: - resource = request.env[str(model_name)].browse( - int(rec_id)) - if not resource.exists(): - return ("

Resource not found" - "

") - else: - records = request.env[ - str(model_name)].search_read( - domain=[('id', '=', resource.id)], - fields=['id', 'display_name'] - ) - remove = json.dumps( - {"Resource deleted": records, - }) - resource.unlink() - return request.make_response(data=remove) - - @http.route(['/send_request'], type='http', - auth='none', - methods=['GET', 'POST', 'PUT', 'DELETE'], csrf=False) - def fetch_data(self, **kw): - """This controller will be called when sending a request to the - specified url, and it will authenticate the api-key and then will - generate the result""" - http_method = request.httprequest.method - - api_key = request.httprequest.headers.get('api-key') - auth_api = self.auth_api_key(api_key) - model = kw.get('model') - username = request.httprequest.headers.get('login') - password = request.httprequest.headers.get('password') - credential = {'login': username, 'password': password, 'type': 'password'} - request.session.authenticate(request.session.db, credential) - model_id = request.env['ir.model'].search( - [('model', '=', model)]) - if not model_id: - return ("

Invalid model, check spelling or maybe " - "the related " - "module is not installed" - "

") - - if auth_api == True: - if not kw.get('Id'): - rec_id = 0 + _logger.error(f"Error en autenticación: {str(e)}") + return self._error_response("Error interno de autenticación", 500) + + @http.route(['/api/v1/', '/api/v1//'], + type='http', auth='none', methods=['GET', 'POST', 'PUT', 'DELETE'], csrf=False) + def api_handler(self, model_name, record_id=None, **kw): + """Endpoint principal de la API REST""" + 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) + + if not success: + return self._error_response(error_msg, 401, "AUTHENTICATION_FAILED") + + # Obtener configuración del modelo + api_config, error_msg = self._get_model_config(model_name) + if not api_config: + return self._error_response(error_msg, 404, "MODEL_NOT_CONFIGURED") + + # Verificar permisos del método + method_permissions = { + 'GET': api_config.is_get, + 'POST': api_config.is_post, + 'PUT': api_config.is_put, + 'DELETE': api_config.is_delete + } + + 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) + 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) + except Exception as e: + _logger.error(f"Error procesando request {method} para {model_name}: {str(e)}") + return self._error_response("Error interno del servidor", 500, "INTERNAL_SERVER_ERROR") + + def _handle_request(self, method, model_name, record_id, data, fields): + """Maneja las diferentes operaciones CRUD""" + model = request.env[model_name] + + if method == 'GET': + return self._handle_get(model, record_id, fields) + elif method == 'POST': + return self._handle_post(model, data, fields) + elif method == 'PUT': + return self._handle_put(model, record_id, data, fields) + elif method == 'DELETE': + return self._handle_delete(model, record_id) + + def _handle_get(self, model, record_id, fields): + """Maneja requests GET""" + try: + if record_id: + # Obtener registro específico + domain = [('id', '=', record_id)] + search_fields = fields if fields else [] else: - rec_id = int(kw.get('Id')) - # Pass the query parameters for GET requests - result = self.generate_response(http_method, model_id.id, rec_id, kw) - return result - else: - return auth_api - - @http.route(['/odoo_connect'], type="http", auth="none", csrf=False, - methods=['GET']) - def odoo_connect(self, **kw): - """This is the controller which initializes the api transaction by - generating the api-key for specific user and database""" - username = request.httprequest.headers.get('login') - password = request.httprequest.headers.get('password') - db = request.httprequest.headers.get('db') + # Obtener todos los registros + domain = [] + 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) + + response_data = { + "success": True, + "count": len(serialized_records), + "data": serialized_records + } + + 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) + + def _handle_post(self, model, data, fields): + """Maneja requests POST (crear)""" + if not data.get('values'): + return self._error_response("Se requiere 'values' para crear registro", 400) + + try: + new_record = model.create(data['values']) + + # 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 {} + } + + return self._json_response(response_data, 201) + + except Exception as e: + _logger.error(f"Error en POST: {str(e)}") + return self._error_response(f"Error creando registro: {str(e)}", 400) + + def _handle_put(self, model, record_id, data, fields): + """Maneja requests PUT (actualizar)""" + if not record_id: + return self._error_response("ID de registro requerido para actualización", 400) + + if not data.get('values'): + return self._error_response("Se requiere 'values' para actualizar registro", 400) + try: - request.session.update(http.get_default_session(), db=db) - credential = {'login': username, 'password': password, - 'type': 'password'} - - auth = request.session.authenticate(db, credential) - user = request.env['res.users'].browse(auth['uid']) - api_key = request.env.user.generate_api(username) - datas = json.dumps({"Status": "auth successful", - "User": user.name, - "api-key": api_key}) - return request.make_response(data=datas) + record = model.browse(record_id) + if not record.exists(): + return self._error_response("Registro no encontrado", 404) + + record.write(data['values']) + + # 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 {} + } + + return self._json_response(response_data) + + except Exception as e: + _logger.error(f"Error en PUT: {str(e)}") + return self._error_response(f"Error actualizando registro: {str(e)}", 400) + + def _handle_delete(self, model, record_id): + """Maneja requests DELETE""" + if not record_id: + return self._error_response("ID de registro requerido para eliminación", 400) + + try: + record = model.browse(record_id) + if not record.exists(): + return self._error_response("Registro no encontrado", 404) + + # Guardar información del registro antes de eliminarlo + record_info = { + "id": record.id, + "display_name": record.display_name if hasattr(record, 'display_name') else str(record) + } + + record.unlink() + + response_data = { + "success": True, + "message": "Registro eliminado exitosamente", + "deleted_record": record_info + } + + return self._json_response(response_data) + + except Exception as e: + _logger.error(f"Error en DELETE: {str(e)}") + return self._error_response(f"Error eliminando registro: {str(e)}", 400) + + @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) + + if not success: + return self._error_response(error_msg, 401) + + try: + api_configs = request.env['connection.api'].sudo().search([]) + models_data = [] + + for config in api_configs: + model_info = { + "model": config.model_id.model, + "name": config.model_id.name, + "methods": { + "GET": config.is_get, + "POST": config.is_post, + "PUT": config.is_put, + "DELETE": config.is_delete + } + } + models_data.append(model_info) + + response_data = { + "success": True, + "count": len(models_data), + "data": models_data + } + + return self._json_response(response_data) + except Exception as e: - _logger.error(f"Error in authentication: {str(e)}") - return ("

wrong login credentials" - "

") + _logger.error(f"Error listando modelos: {str(e)}") + return self._error_response("Error interno del servidor", 500) From fc5a0ad7abca87a7a6406abd7dfe7ebe3c275704 Mon Sep 17 00:00:00 2001 From: "bernat.roig" Date: Fri, 12 Sep 2025 09:10:55 +0200 Subject: [PATCH 5/8] restapi changes --- rest_api_odoo/__manifest__.py | 7 +- rest_api_odoo/controllers/__init__.py | 1 + rest_api_odoo/controllers/rest_api_odoo.py | 35 +- .../controllers/swagger_controller.py | 741 ++++++++++++++++++ rest_api_odoo/views/api_dashboard_views.xml | 176 +++++ rest_api_odoo/views/connection_api_views.xml | 250 +++++- rest_api_odoo/views/res_users_views.xml | 135 +++- 7 files changed, 1314 insertions(+), 31 deletions(-) create mode 100755 rest_api_odoo/controllers/swagger_controller.py create mode 100755 rest_api_odoo/views/api_dashboard_views.xml diff --git a/rest_api_odoo/__manifest__.py b/rest_api_odoo/__manifest__.py index 6b4811e0f..d11dcc995 100644 --- a/rest_api_odoo/__manifest__.py +++ b/rest_api_odoo/__manifest__.py @@ -23,9 +23,9 @@ "name": "Odoo rest API", "version": "18.0.1.0.1", "category": "Tools", - "summary": """This app helps to interact with odoo, backend with help of + "summary": """This app helps to interact with odoo, backend with help of rest api requests""", - "description": """The odoo Rest API module allow us to connect to database + "description": """The odoo Rest API module allow us to connect to database with the help of GET , POST , PUT and DELETE requests""", 'author': 'Cybrosys Techno Solutions', 'company': 'Cybrosys Techno Solutions', @@ -35,7 +35,8 @@ "data": [ 'security/ir.model.access.csv', 'views/res_users_views.xml', - 'views/connection_api_views.xml' + 'views/connection_api_views.xml', + 'views/api_dashboard_views.xml', ], 'images': ['static/description/banner.jpg'], 'license': 'LGPL-3', diff --git a/rest_api_odoo/controllers/__init__.py b/rest_api_odoo/controllers/__init__.py index 12d071cc4..d04083111 100644 --- a/rest_api_odoo/controllers/__init__.py +++ b/rest_api_odoo/controllers/__init__.py @@ -20,3 +20,4 @@ # ############################################################################# from . import rest_api_odoo +from . import swagger_controller diff --git a/rest_api_odoo/controllers/rest_api_odoo.py b/rest_api_odoo/controllers/rest_api_odoo.py index 27d63afea..5309d39d7 100644 --- a/rest_api_odoo/controllers/rest_api_odoo.py +++ b/rest_api_odoo/controllers/rest_api_odoo.py @@ -81,6 +81,9 @@ class RestApi(http.Controller): 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: @@ -118,7 +121,8 @@ class RestApi(http.Controller): return None, "Modelo no encontrado" api_config = request.env['connection.api'].sudo().search([ - ('model_id', '=', model_obj.id) + ('model_id', '=', model_obj.id), + ('active', '=', True) ], limit=1) if not api_config: @@ -387,7 +391,7 @@ class RestApi(http.Controller): return self._error_response(error_msg, 401) try: - api_configs = request.env['connection.api'].sudo().search([]) + api_configs = request.env['connection.api'].sudo().search([('active', '=', True)]) models_data = [] for config in api_configs: @@ -403,10 +407,16 @@ class RestApi(http.Controller): } models_data.append(model_info) + # Agregar información de documentación + base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') response_data = { "success": True, "count": len(models_data), - "data": models_data + "data": models_data, + "documentation": { + "swagger_ui": f"{base_url}/api/v1/docs", + "openapi_spec": f"{base_url}/api/v1/openapi.json" + } } return self._json_response(response_data) @@ -414,3 +424,22 @@ class RestApi(http.Controller): except Exception as e: _logger.error(f"Error listando modelos: {str(e)}") return self._error_response("Error interno del servidor", 500) + + @http.route(['/api', '/api/'], type='http', auth='none', methods=['GET'], csrf=False) + def api_root(self, **kw): + """Endpoint raíz de la API que redirige a la documentación""" + base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069') + + api_info = { + "message": "Bienvenido a la REST API de Odoo", + "version": "1.0.0", + "documentation": f"{base_url}/api/v1/docs", + "endpoints": { + "auth": f"{base_url}/api/v1/auth", + "models": f"{base_url}/api/v1/models", + "docs": f"{base_url}/api/v1/docs", + "openapi": f"{base_url}/api/v1/openapi.json" + } + } + + return self._json_response(api_info) diff --git a/rest_api_odoo/controllers/swagger_controller.py b/rest_api_odoo/controllers/swagger_controller.py new file mode 100755 index 000000000..193ec55f6 --- /dev/null +++ b/rest_api_odoo/controllers/swagger_controller.py @@ -0,0 +1,741 @@ +# -*- coding: utf-8 -*- +############################################################################# +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies() +# Author: Ayana KP (odoo@cybrosys.com) +# Modified by: [Tu nombre] - Agregado Swagger/OpenAPI Documentation +# +# You can modify it under the terms of the GNU LESSER +# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. +# +# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE +# (LGPL v3) along with this program. +# If not, see . +# +############################################################################# +import json +from datetime import datetime +from odoo import http +from odoo.http import request + + +class SwaggerController(http.Controller): + """Controlador para generar documentación Swagger/OpenAPI de la REST API""" + + @http.route( + ["/api/v1/docs", "/api/docs"], type="http", auth="none", methods=["GET"] + ) + def swagger_ui(self, **kwargs): + """Muestra la interfaz de Swagger UI""" + base_url = ( + request.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url", "http://localhost:8069") + ) + + swagger_html = f""" + + + + + + Odoo REST API - Swagger Documentation + + + + +
+ + + + + + + """ + return request.make_response( + swagger_html, headers=[("Content-Type", "text/html; charset=utf-8")] + ) + + @http.route(["/api/v1/openapi.json"], type="http", auth="none", methods=["GET"]) + def openapi_spec(self, **kwargs): + """Genera la especificación OpenAPI/Swagger en formato JSON""" + + base_url = ( + request.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url", "http://localhost:8069") + ) + db_name = request.env.cr.dbname + + # Obtener información de modelos configurados + api_configs = ( + request.env["connection.api"].sudo().search([("active", "=", True)]) + ) + + # Estructura base de OpenAPI 3.0 + openapi_spec = { + "openapi": "3.0.3", + "info": { + "title": "Odoo REST API", + "description": f""" + ## Odoo REST API Documentation + + Esta es la documentación interactiva de la REST API para Odoo. La API permite realizar operaciones CRUD en los modelos configurados de Odoo. + + ### Autenticación + + La API utiliza autenticación basada en API Key. Para obtener una API Key: + + 1. **Primer paso - Autenticarse:** + ```bash + curl -X POST {base_url}/api/v1/auth \\ + -H "Content-Type: application/json" \\ + -d '{{"username": "tu_usuario", "password": "tu_contraseña", "database": "{db_name}"}}' + 2. Usar la API Key en las requests: + - Agregar header: X-API-Key: tu_api_key_aqui + - O usar header: api-key: tu_api_key_aqui + + Formatos de Respuesta + Todas las respuestas siguen un formato estandarizado: + + Respuesta exitosa: + {{ + "success": true, + "count": 10, + "data": [...] + }} + Respuesta de error: + {{ + "error": true, + "message": "Descripción del error", + "status_code": 400, + "error_code": "ERROR_CODE" + }} + Campos Especiales + Fechas: Se devuelven en formato ISO 8601 (YYYY-MM-DDTHH:MM:SS) + + Archivos binarios: Se codifican en Base64 + + Relaciones Many2one: Se devuelven como [id, "display_name"] + + Relaciones One2many/Many2many: Se devuelven como arrays de IDs + + Modelos Disponibles + {self._get_available_models_description()} + """, + "version": "1.0.0", + "contact": {"name": "API Support", "email": "support@example.com"}, + "license": { + "name": "LGPL-3", + "url": "https://www.gnu.org/licenses/lgpl-3.0.html", + }, + }, + "servers": [ + {"url": f"{base_url}/api/v1", "description": "Production server"} + ], + "security": [{"ApiKeyAuth": []}], + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API Key obtenida del endpoint de autenticación", + } + }, + "schemas": { + "ErrorResponse": { + "type": "object", + "properties": { + "error": {"type": "boolean", "example": True}, + "message": { + "type": "string", + "example": "Descripción del error", + }, + "status_code": {"type": "integer", "example": 400}, + "error_code": {"type": "string", "example": "ERROR_CODE"}, + }, + }, + "AuthRequest": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": {"type": "string", "example": "admin"}, + "password": {"type": "string", "example": "admin"}, + "database": {"type": "string", "example": f"{db_name}"}, + }, + }, + "AuthResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "message": { + "type": "string", + "example": "Autenticación exitosa", + }, + "data": { + "type": "object", + "properties": { + "user_id": {"type": "integer", "example": 2}, + "username": {"type": "string", "example": "admin"}, + "name": { + "type": "string", + "example": "Administrator", + }, + "api_key": { + "type": "string", + "example": "abcd1234...", + }, + "database": { + "type": "string", + "example": f"{db_name}", + }, + }, + }, + }, + }, + "SuccessResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "count": {"type": "integer", "example": 10}, + "data": {"type": "array", "items": {"type": "object"}}, + }, + }, + }, + "responses": { + "UnauthorizedError": { + "description": "API Key missing or invalid", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + "ValidationError": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorResponse"} + } + }, + }, + }, + }, + "paths": self._generate_paths(api_configs), + "tags": self._generate_tags(api_configs), + } + + return request.make_response( + json.dumps(openapi_spec, indent=2), + headers=[("Content-Type", "application/json; charset=utf-8")], + ) + + + def _get_available_models_description(self): + """Genera descripción de modelos disponibles""" + api_configs = request.env["connection.api"].sudo().search([("active", "=", True)]) + if not api_configs: + return "No hay modelos configurados actualmente." + + description = "Los siguientes modelos están disponibles:\n\n" + for config in api_configs: + methods = [] + if config.is_get: + methods.append("GET") + if config.is_post: + methods.append("POST") + if config.is_put: + methods.append("PUT") + if config.is_delete: + methods.append("DELETE") + + description += f"- **{config.model_id.name}** (`{config.model_id.model}`) - Métodos: {', '.join(methods)}\n" + + return description + + + def _generate_tags(self, api_configs): + """Genera tags para agrupar endpoints""" + tags = [ + { + "name": "Authentication", + "description": "Endpoints para autenticación y gestión de API keys", + }, + { + "name": "System", + "description": "Endpoints del sistema (información de modelos disponibles)", + }, + ] + + for config in api_configs: + tags.append( + { + "name": config.model_id.model, + "description": f"Operaciones CRUD para el modelo {config.model_id.name}", + } + ) + + return tags + + + def _generate_paths(self, api_configs): + """Genera todos los paths/endpoints de la API""" + paths = {} + + # Endpoint de autenticación + paths["/auth"] = { + "post": { + "tags": ["Authentication"], + "summary": "Authenticate user and get API key", + "description": "Autentica un usuario y devuelve una API key para usar en las demás requests", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/AuthRequest"} + } + }, + }, + "responses": { + "200": { + "description": "Autenticación exitosa", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/AuthResponse"} + } + }, + }, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + "security": [], + } + } + + # Endpoint de modelos disponibles + paths["/models"] = { + "get": { + "tags": ["System"], + "summary": "List available models", + "description": "Lista todos los modelos disponibles en la API con sus métodos permitidos", + "responses": { + "200": { + "description": "Lista de modelos disponibles", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/SuccessResponse"} + } + }, + }, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + } + + # Generar endpoints para cada modelo configurado + for config in api_configs: + model_name = config.model_id.model + model_display_name = config.model_id.name + + # Path para operaciones de colección (GET all, POST) + collection_path = f"/{model_name}" + paths[collection_path] = {} + + # Path para operaciones de item específico (GET one, PUT, DELETE) + item_path = f"/{model_name}/{{id}}" + paths[item_path] = {} + + # Obtener campos del modelo + model_fields = self._get_model_fields_info(model_name) + + # GET - Obtener todos los registros + if config.is_get: + paths[collection_path]["get"] = { + "tags": [model_name], + "summary": f"Get all {model_display_name} records", + "description": f"Obtiene todos los registros del modelo {model_display_name}", + "parameters": [ + { + "name": "fields", + "in": "query", + "description": "Campos específicos a retornar (separados por comas)", + "schema": {"type": "string", "example": "id,name,email"}, + } + ], + "responses": { + "200": { + "description": "Lista de registros", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + }, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + + # GET - Obtener registro específico + paths[item_path]["get"] = { + "tags": [model_name], + "summary": f"Get specific {model_display_name} record", + "description": f"Obtiene un registro específico del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro", + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "Registro encontrado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + + # POST - Crear registro + if config.is_post: + paths[collection_path]["post"] = { + "tags": [model_name], + "summary": f"Create new {model_display_name} record", + "description": f"Crea un nuevo registro en el modelo {model_display_name}", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["values"], + "properties": { + "values": { + "type": "object", + "description": "Datos del registro a crear", + "properties": model_fields, + }, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Campos a retornar en la respuesta", + }, + }, + }, + "example": self._get_model_example_data( + model_name, "create" + ), + } + }, + }, + "responses": { + "201": { + "description": "Registro creado exitosamente", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + }, + "400": {"$ref": "#/components/responses/ValidationError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + + # PUT - Actualizar registro + if config.is_put: + paths[item_path]["put"] = { + "tags": [model_name], + "summary": f"Update {model_display_name} record", + "description": f"Actualiza un registro existente del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro a actualizar", + "schema": {"type": "integer"}, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["values"], + "properties": { + "values": { + "type": "object", + "description": "Datos a actualizar", + "properties": model_fields, + }, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Campos a retornar en la respuesta", + }, + }, + }, + "example": self._get_model_example_data( + model_name, "update" + ), + } + }, + }, + "responses": { + "200": { + "description": "Registro actualizado exitosamente", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + }, + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "400": {"$ref": "#/components/responses/ValidationError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + + # DELETE - Eliminar registro + if config.is_delete: + paths[item_path]["delete"] = { + "tags": [model_name], + "summary": f"Delete {model_display_name} record", + "description": f"Elimina un registro del modelo {model_display_name}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "description": "ID del registro a eliminar", + "schema": {"type": "integer"}, + } + ], + "responses": { + "200": { + "description": "Registro eliminado exitosamente", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "message": { + "type": "string", + "example": "Registro eliminado exitosamente", + }, + "deleted_record": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "display_name": {"type": "string"}, + }, + }, + }, + } + } + }, + }, + "404": {"$ref": "#/components/responses/NotFoundError"}, + "401": {"$ref": "#/components/responses/UnauthorizedError"}, + }, + } + + return paths + + def _get_model_fields_info(self, model_name): + """Obtiene información de los campos de un modelo para la documentación""" + try: + model = request.env[model_name].sudo() + fields_info = {} + + # Campos comunes que suelen existir + common_fields = { + "id": { + "type": "integer", + "description": "ID único del registro", + "readOnly": True, + }, + "name": {"type": "string", "description": "Nombre"}, + "display_name": { + "type": "string", + "description": "Nombre para mostrar", + "readOnly": True, + }, + "create_date": { + "type": "string", + "format": "date-time", + "description": "Fecha de creación", + "readOnly": True, + }, + "write_date": { + "type": "string", + "format": "date-time", + "description": "Fecha de última modificación", + "readOnly": True, + }, + "active": { + "type": "boolean", + "description": "Indica si el registro está activo", + }, + } + + # Agregar campos comunes que existan en el modelo + for field_name, field_info in common_fields.items(): + if hasattr(model, field_name): + fields_info[field_name] = field_info + + return fields_info + + except Exception: + # Si hay error, retornar estructura básica + return { + "id": {"type": "integer", "description": "ID único del registro"}, + "display_name": { + "type": "string", + "description": "Nombre para mostrar", + }, + } + + def _get_model_example_data(self, model_name, operation): + """Genera ejemplos de datos para cada modelo""" + examples = { + "res.partner": { + "create": { + "values": { + "name": "Nuevo Cliente", + "email": "cliente@ejemplo.com", + "phone": "+34123456789", + "is_company": False, + }, + "fields": ["id", "name", "email", "phone"], + }, + "update": { + "values": {"phone": "+34987654321", "street": "Calle Nueva 123"}, + "fields": ["id", "name", "phone", "street"], + }, + }, + "product.product": { + "create": { + "values": { + "name": "Nuevo Producto", + "list_price": 99.99, + "default_code": "PROD001", + }, + "fields": ["id", "name", "list_price", "default_code"], + }, + "update": { + "values": { + "list_price": 89.99, + "description": "Descripción actualizada", + }, + "fields": ["id", "name", "list_price", "description"], + }, + }, + } + + # Retornar ejemplo específico o genérico + return examples.get( + model_name, + { + "create": { + "values": {"name": "Nuevo Registro"}, + "fields": ["id", "name", "display_name"], + }, + "update": { + "values": {"name": "Registro Actualizado"}, + "fields": ["id", "name", "display_name"], + }, + }, + ).get(operation, {}) diff --git a/rest_api_odoo/views/api_dashboard_views.xml b/rest_api_odoo/views/api_dashboard_views.xml new file mode 100755 index 000000000..7e10db69e --- /dev/null +++ b/rest_api_odoo/views/api_dashboard_views.xml @@ -0,0 +1,176 @@ + + + + + API Dashboard + api_dashboard + + + + + + + + API Documentation + /api/v1/docs + new + + + + + api.stats.view + connection.api + + + + + + + + + + + + + + + + + API Statistics + connection.api + list + + {'search_default_active': 1} + + + + + + + diff --git a/rest_api_odoo/views/connection_api_views.xml b/rest_api_odoo/views/connection_api_views.xml index 1ae15d1f5..5e8200f07 100644 --- a/rest_api_odoo/views/connection_api_views.xml +++ b/rest_api_odoo/views/connection_api_views.xml @@ -1,61 +1,271 @@ - + connection.api.view.form connection.api
+
+
+
+

+ +

+

+ +

+
+ - - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- + + connection.api.view.list connection.api - - + + + + + + + + + + + + + + + + + + + + + + + connection.api.view.search + connection.api + + + + + + + + + + + + + + + + + + - + + - Rest API Records + REST API Configuration ir.actions.act_window connection.api - list,form + kanban,list,form + {'search_default_active': 1}

- Create! + Configure your first REST API endpoint!

+

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

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

🔐 Autenticación JWT

+

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

+

Luego agrega: Authorization: Bearer tu_token_aqui

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

Error

Could not load documentation: {str(e)}

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

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

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

+

+ + View API Documentation +

- + Create Default API Configurations code - -created = model.create_default_configurations() + + ]]> - - + + - + view.users.form.inherit.rest.api.enhanced @@ -10,8 +10,7 @@ - + @@ -21,30 +20,30 @@ -
-
- -
- - - view.users.tree.api.info + + + view.users.list.api.info res.users - - - + + + - + Cleanup Expired API Keys code - -cleaned_count = model.cleanup_expired_api_keys() -message = f"Cleaned {cleaned_count} expired API keys." if cleaned_count > 0 else "No expired API keys found." + 0: + message = "Cleaned " + str(cleaned_count) + " expired API keys." + msg_type = 'success' +else: + message = "No expired API keys found." + msg_type = 'info' action = { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'message': message, - 'type': 'success' if cleaned_count > 0 else 'info', + 'type': msg_type, 'sticky': False, } } - + ]]> - + - + Cleanup Expired API Keys code - model.cleanup_expired_api_keys() + 1 days True -
From 0f11b7f15928410a59f5fcc43e5a8ecaae1f293f Mon Sep 17 00:00:00 2001 From: "bernat.roig" Date: Fri, 19 Sep 2025 11:16:18 +0200 Subject: [PATCH 7/8] change username and pass --- rest_api_odoo/controllers/swagger_controller.py | 9 +++++---- rest_api_odoo/views/res_users_views.xml | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/rest_api_odoo/controllers/swagger_controller.py b/rest_api_odoo/controllers/swagger_controller.py index 2b2e68cdb..29fffe944 100755 --- a/rest_api_odoo/controllers/swagger_controller.py +++ b/rest_api_odoo/controllers/swagger_controller.py @@ -220,7 +220,7 @@ Esta API utiliza **JWT Bearer Tokens** para autenticación: ```bash curl -X POST {base_url}/api/v1/auth \\ -H "Content-Type: application/json" \\ - -d '{{"username": "admin", "password": "admin"}}' + -d '{{"username": "DontFucking", "password": "UseTheseCredentials"}}' ``` 2. **Usar token en requests:** @@ -286,11 +286,12 @@ Todas las respuestas siguen un formato consistente: "type": "object", "required": ["username", "password"], "properties": { - "username": {"type": "string", "example": "admin"}, - "password": {"type": "string", "format": "password", "example": "admin"}, + "username": {"type": "string", "example": "DontFucking"}, + "password": {"type": "string", "format": "password", "example": "UseTheseCredentials"}, "database": {"type": "string", "example": "odoo"}, "expires_in_hours": {"type": "integer", "minimum": 1, "maximum": 168, "default": 24, "example": 24} } + }, "AuthResponse": { "type": "object", @@ -301,7 +302,7 @@ Todas las respuestas siguen un formato consistente: "type": "object", "properties": { "user_id": {"type": "integer", "example": 2}, - "username": {"type": "string", "example": "admin"}, + "username": {"type": "string", "example": "DontFucking"}, "name": {"type": "string", "example": "Administrator"}, "access_token": {"type": "string", "example": "eyJ0eXAiOiJKV1QiLCJhbGc..."}, "token_type": {"type": "string", "example": "Bearer"}, diff --git a/rest_api_odoo/views/res_users_views.xml b/rest_api_odoo/views/res_users_views.xml index 8b9f12c62..9dcd3f4c9 100644 --- a/rest_api_odoo/views/res_users_views.xml +++ b/rest_api_odoo/views/res_users_views.xml @@ -122,6 +122,11 @@ action = { + Date: Mon, 22 Sep 2025 12:30:24 +0200 Subject: [PATCH 8/8] changes on view for fix bug in view connection --- rest_api_odoo/security/ir.model.access.csv | 1 + rest_api_odoo/views/connection_api_views.xml | 24 +++++++++++--------- rest_api_odoo/views/res_users_views.xml | 1 - 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/rest_api_odoo/security/ir.model.access.csv b/rest_api_odoo/security/ir.model.access.csv index ba4748c46..86bf3e69a 100644 --- a/rest_api_odoo/security/ir.model.access.csv +++ b/rest_api_odoo/security/ir.model.access.csv @@ -1,3 +1,4 @@ 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,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 +access_connection_api_all,access_connection_api_all,model_connection_api,,1,1,1,1 diff --git a/rest_api_odoo/views/connection_api_views.xml b/rest_api_odoo/views/connection_api_views.xml index cc552abdc..835092acf 100644 --- a/rest_api_odoo/views/connection_api_views.xml +++ b/rest_api_odoo/views/connection_api_views.xml @@ -160,6 +160,7 @@ REST API Configuration connection.api list,form + {'search_default_active': 1}

@@ -221,17 +222,18 @@ action = { - - + + name="API Configuration" + parent="rest_api_root" + action="rest_api_root_action" + sequence="10" + groups="base.group_system"/> - + diff --git a/rest_api_odoo/views/res_users_views.xml b/rest_api_odoo/views/res_users_views.xml index 9dcd3f4c9..f2f557543 100644 --- a/rest_api_odoo/views/res_users_views.xml +++ b/rest_api_odoo/views/res_users_views.xml @@ -124,7 +124,6 @@ action = {