oca-technical/odoo-bringout-oca-rest-framework-rest_log/rest_log/models/rest_log.py
2025-08-29 15:43:03 +02:00

196 lines
7.1 KiB
Python

# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# @author Guewen Baconnier <guewen.baconnier@camptocamp.com>
# @author Simone Orsi <simahawk@gmail.com>
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models, tools
_logger = logging.getLogger(__name__)
class RESTLog(models.Model):
_name = "rest.log"
_description = "REST API Logging"
_order = "id desc"
DEFAULT_RETENTION = 30 # days
EXCEPTION_SEVERITY_MAPPING = {
"odoo.exceptions.UserError": "functional",
"odoo.exceptions.ValidationError": "functional",
# something broken somewhere
"ValueError": "severe",
"AttributeError": "severe",
"UnboundLocalError": "severe",
}
collection = fields.Char(index=True)
collection_id = fields.Integer(index=True, string="Collection ID")
request_url = fields.Char(readonly=True, string="Request URL")
request_method = fields.Char(readonly=True)
params = fields.Text(readonly=True)
# TODO: make these fields serialized and use a computed field for displaying
headers = fields.Text(readonly=True)
result = fields.Text(readonly=True)
error = fields.Text(readonly=True)
exception_name = fields.Char(readonly=True, string="Exception")
exception_message = fields.Text(readonly=True)
state = fields.Selection(
selection=[("success", "Success"), ("failed", "Failed")], readonly=True
)
severity = fields.Selection(
selection=[
("functional", "Functional"),
("warning", "Warning"),
("severe", "Severe"),
],
compute="_compute_severity",
store=True,
# Grant specific override services' dispatch_exception override
# or via UI: user can classify errors as preferred on demand
# (maybe using mass_edit)
readonly=False,
)
@api.depends("state", "exception_name", "error")
def _compute_severity(self):
for rec in self:
rec.severity = rec.severity or rec._get_severity()
def _get_severity(self):
if not self.exception_name:
return False
mapping = self._get_exception_severity_mapping()
return mapping.get(self.exception_name, "warning")
def _get_exception_severity_mapping_param(self):
param = (
self.env["ir.config_parameter"]
.sudo()
.get_param("rest.log.severity.exception.mapping")
)
return param.strip() if param else ""
@tools.ormcache("self._get_exception_severity_mapping_param()")
def _get_exception_severity_mapping(self):
mapping = self.EXCEPTION_SEVERITY_MAPPING.copy()
param = self._get_exception_severity_mapping_param()
if not param:
return mapping
# param should be in the form
# `[module.dotted.path.]ExceptionName:severity,ExceptionName:severity`
for rule in param.split(","):
if not rule.strip():
continue
exc_name = severity = None
try:
exc_name, severity = [x.strip() for x in rule.split(":")]
if not exc_name or not severity:
raise ValueError
except ValueError:
_logger.info(
"Could not convert System Parameter"
" 'rest.log.severity.exception.mapping' to mapping."
" The following rule will be ignored: %s",
rule,
)
if exc_name and severity:
mapping[exc_name] = severity
return mapping
def _logs_retention_days(self):
retention = self.DEFAULT_RETENTION
param = (
self.env["ir.config_parameter"].sudo().get_param("rest.log.retention.days")
)
if param:
try:
retention = int(param)
except ValueError:
_logger.exception(
"Could not convert System Parameter"
" 'rest.log.retention.days' to integer,"
" reverting to the"
" default configuration."
)
return retention
def logging_active(self):
retention = self._logs_retention_days()
return retention > 0
def autovacuum(self):
"""Delete logs which have exceeded their retention duration
Called from a cron.
"""
deadline = datetime.now() - timedelta(days=self._logs_retention_days())
logs = self.search([("create_date", "<=", deadline)])
if logs:
logs.unlink()
return True
def _get_log_active_param(self):
param = self.env["ir.config_parameter"].sudo().get_param("rest.log.active")
return param.strip() if param else ""
@tools.ormcache("self._get_log_active_param()")
def _get_log_active_conf(self):
"""Compute log active configuration.
Possible configuration contains a CSV like this:
`collection_name` -> enable for all endpoints of the collection
`collection_name.usage` -> enable for specific endpoints
`collection_name.usage.endpoint` -> enable for specific endpoints
`collection_name*:state` -> enable only for specific state (success, failed)
By default matching keys are enabled for all states.
:return: mapping by matching key / enabled states
"""
param = self._get_log_active_param()
conf = {}
lines = [x.strip() for x in param.split(",") if x.strip()]
for line in lines:
bits = [x.strip() for x in line.split(":") if x.strip()]
if len(bits) > 1:
match_key = bits[0]
# fmt: off
states = (bits[1], )
# fmt: on
else:
match_key = line
states = ("success", "failed")
conf[match_key] = states
return conf
@api.model
def _get_matching_active_conf(self, collection, usage, method_name):
"""Retrieve conf matching current service and method."""
conf = self._get_log_active_conf()
candidates = (
collection + "." + usage + "." + method_name,
collection + "." + usage,
collection,
)
for candidate in candidates:
if conf.get(candidate):
return conf.get(candidate)
def action_view_collection(self):
"""Open collection if we have a real record.
NOTE: use an action instead of a `Reference` field with computed method
because it would force use to have glue modules to provide a selection
for every model we want to support.
"""
# TODO: on the next round, compute the collection name
# to be used for the button label.
# No ID or no real model
if self.collection not in self.env or not self.collection_id:
return
action = self.env[self.collection].get_formview_action()
action["res_id"] = self.collection_id
return action