From fc5a0ad7abca87a7a6406abd7dfe7ebe3c275704 Mon Sep 17 00:00:00 2001 From: "bernat.roig" Date: Fri, 12 Sep 2025 09:10:55 +0200 Subject: [PATCH] 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 + +