# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # @author Guewen Baconnier # @author Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). import json import logging import traceback from psycopg2.errors import OperationalError from werkzeug.urls import url_encode, url_join from odoo import exceptions, registry from odoo.http import Response, request from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY from odoo.addons.base_rest.http import JSONEncoder from odoo.addons.component.core import AbstractComponent from ..exceptions import ( RESTServiceDispatchException, RESTServiceMissingErrorException, RESTServiceUserErrorException, RESTServiceValidationErrorException, ) _logger = logging.getLogger(__name__) def json_dump(data): """Encode data to JSON as we like.""" return json.dumps(data, cls=JSONEncoder, indent=4, sort_keys=True, default=str) class BaseRESTService(AbstractComponent): _inherit = "base.rest.service" # can be overridden to enable logging of requests to DB _log_calls_in_db = False def dispatch(self, method_name, *args, params=None): if not self._db_logging_active(method_name): return super().dispatch(method_name, *args, params=params) return self._dispatch_with_db_logging(method_name, *args, params=params) def _dispatch_with_db_logging(self, method_name, *args, params=None): try: with self.env.cr.savepoint(): result = super().dispatch(method_name, *args, params=params) except exceptions.MissingError as orig_exception: self._dispatch_exception( method_name, RESTServiceMissingErrorException, orig_exception, *args, params=params, ) except exceptions.ValidationError as orig_exception: self._dispatch_exception( method_name, RESTServiceValidationErrorException, orig_exception, *args, params=params, ) except exceptions.UserError as orig_exception: self._dispatch_exception( method_name, RESTServiceUserErrorException, orig_exception, *args, params=params, ) except Exception as orig_exception: self._dispatch_exception( method_name, RESTServiceDispatchException, orig_exception, *args, params=params, ) self._log_dispatch_success(method_name, result, *args, params) return result def _log_dispatch_success(self, method_name, result, *args, params=None): try: with self.env.cr.savepoint(): log_entry = self._log_call_in_db( self.env, request, method_name, *args, params, result=result ) if log_entry and not isinstance(result, Response): log_entry_url = self._get_log_entry_url(log_entry) result["log_entry_url"] = log_entry_url except Exception as e: _logger.exception("Rest Log Error Creation: %s", e) def _dispatch_exception( self, method_name, exception_klass, orig_exception, *args, params=None ): exc_msg, log_entry_url = None, None # in case it fails below try: exc_msg = self._get_exception_message(orig_exception) tb = traceback.format_exc() with registry(self.env.cr.dbname).cursor() as cr: log_entry = self._log_call_in_db( self.env(cr=cr), request, method_name, *args, params=params, traceback=tb, orig_exception=orig_exception, ) log_entry_url = self._get_log_entry_url(log_entry) except Exception as e: _logger.exception("Rest Log Error Creation: %s", e) # let the OperationalError bubble up to the retrying mechanism # We can't wrap the OperationalError because we want to let it # bubble up to the retrying mechanism, it will be handled by # the default handler at the end of the chain. if ( isinstance(orig_exception, OperationalError) and orig_exception.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY ): raise orig_exception raise exception_klass(exc_msg, log_entry_url) from orig_exception def _get_exception_message(self, exception): return exception.args and exception.args[0] or str(exception) def _get_log_entry_url(self, entry): base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") url_params = { "action": self.env.ref("rest_log.action_rest_log").id, "view_type": "form", "model": entry._name, "id": entry.id, } url = "/web?#%s" % url_encode(url_params) return url_join(base_url, url) @property def _log_call_header_strip(self): return ("Cookie", "Api-Key") def _log_call_in_db_values(self, _request, *args, params=None, **kw): httprequest = _request.httprequest headers = self._log_call_sanitize_headers(dict(httprequest.headers or [])) params = dict(params or {}) if args: params.update(args=args) params = self._log_call_sanitize_params(params) error, exception_name, exception_message = self._log_call_prepare_error(**kw) result, state = self._log_call_prepare_result(kw.get("result")) collection = self.work.collection return { "collection": collection._name, "collection_id": collection.id, "request_url": httprequest.url, "request_method": httprequest.method, "params": params, "headers": headers, "result": result, "error": error, "exception_name": exception_name, "exception_message": exception_message, "state": state, } def _log_call_prepare_result(self, result): # NB: ``result`` might be an object of class ``odoo.http.Response``, # for example when you try to download a file. In this case, we need to # handle it properly, without the assumption that ``result`` is a dict. if isinstance(result, Response): status_code = result.status_code result = { "status": status_code, "headers": self._log_call_sanitize_headers(dict(result.headers or [])), } state = "success" if status_code in range(200, 300) else "failed" else: state = "success" if result else "failed" return result, state def _log_call_prepare_error(self, traceback=None, orig_exception=None, **kw): exception_name = None exception_message = None if orig_exception: exception_name = orig_exception.__class__.__name__ if hasattr(orig_exception, "__module__"): exception_name = orig_exception.__module__ + "." + exception_name exception_message = self._get_exception_message(orig_exception) return traceback, exception_name, exception_message _log_call_in_db_keys_to_serialize = ("params", "headers", "result") def _log_call_in_db(self, env, _request, method_name, *args, params=None, **kw): values = self._log_call_in_db_values(_request, *args, params=params, **kw) for k in self._log_call_in_db_keys_to_serialize: values[k] = json_dump(values[k]) enabled_states = self._get_matching_active_conf(method_name) if not values or enabled_states and values["state"] not in enabled_states: return return env["rest.log"].sudo().create(values) def _log_call_sanitize_params(self, params: dict) -> dict: if "password" in params: params["password"] = "" return params def _log_call_sanitize_headers(self, headers: dict) -> dict: for header_key in self._log_call_header_strip: if header_key in headers: headers[header_key] = "" return headers def _db_logging_active(self, method_name): enabled = self._log_calls_in_db if not enabled: enabled = bool(self._get_matching_active_conf(method_name)) return request and enabled and self.env["rest.log"].logging_active() def _get_matching_active_conf(self, method_name): return self.env["rest.log"]._get_matching_active_conf( self._collection, self._usage, method_name )