From 973c5ef79a46881c4cfecea9fe6c58022e844824 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 15 Jul 2025 10:33:50 +0200 Subject: [PATCH 01/17] [ADD] fastapi_log --- fastapi_log/README.rst | 99 +++++ fastapi_log/__init__.py | 2 + fastapi_log/__manifest__.py | 23 + fastapi_log/fastapi_dispatcher.py | 76 ++++ fastapi_log/models/__init__.py | 2 + fastapi_log/models/fastapi_endpoint.py | 35 ++ fastapi_log/models/fastapi_log.py | 227 ++++++++++ fastapi_log/readme/CONTRIBUTORS.md | 1 + fastapi_log/readme/DESCRIPTION.md | 3 + fastapi_log/readme/USAGE.md | 6 + fastapi_log/security/ir_model_access.xml | 17 + fastapi_log/security/res_groups.xml | 17 + fastapi_log/static/description/index.html | 438 +++++++++++++++++++ fastapi_log/tests/__init__.py | 1 + fastapi_log/tests/test_fastapi_log.py | 161 +++++++ fastapi_log/views/fastapi_endpoint_views.xml | 42 ++ fastapi_log/views/fastapi_log_views.xml | 124 ++++++ setup/fastapi_log/odoo/addons/fastapi_log | 1 + setup/fastapi_log/setup.py | 6 + 19 files changed, 1281 insertions(+) create mode 100644 fastapi_log/README.rst create mode 100644 fastapi_log/__init__.py create mode 100644 fastapi_log/__manifest__.py create mode 100644 fastapi_log/fastapi_dispatcher.py create mode 100644 fastapi_log/models/__init__.py create mode 100644 fastapi_log/models/fastapi_endpoint.py create mode 100644 fastapi_log/models/fastapi_log.py create mode 100644 fastapi_log/readme/CONTRIBUTORS.md create mode 100644 fastapi_log/readme/DESCRIPTION.md create mode 100644 fastapi_log/readme/USAGE.md create mode 100644 fastapi_log/security/ir_model_access.xml create mode 100644 fastapi_log/security/res_groups.xml create mode 100644 fastapi_log/static/description/index.html create mode 100644 fastapi_log/tests/__init__.py create mode 100644 fastapi_log/tests/test_fastapi_log.py create mode 100644 fastapi_log/views/fastapi_endpoint_views.xml create mode 100644 fastapi_log/views/fastapi_log_views.xml create mode 120000 setup/fastapi_log/odoo/addons/fastapi_log create mode 100644 setup/fastapi_log/setup.py diff --git a/fastapi_log/README.rst b/fastapi_log/README.rst new file mode 100644 index 000000000..6464683d2 --- /dev/null +++ b/fastapi_log/README.rst @@ -0,0 +1,99 @@ +=========== +Fastapi Log +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi_log + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi_log + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows an endpoint to activate full request logging in a +database model. + +It is useful to debug production issues or to monitor the usage of a +specific endpoint. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To activate logging for an endpoint, you have to check the +``Log Requests`` checkbox in the endpoint's configuration. This will log +all requests and responses for that endpoint. + +A smart button will be displayed in the endpoint's form view to access +the endpoint logs. A global log view is also available in the +``FastAPI Logs`` menu. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_log/__init__.py b/fastapi_log/__init__.py new file mode 100644 index 000000000..d54296502 --- /dev/null +++ b/fastapi_log/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import fastapi_dispatcher diff --git a/fastapi_log/__manifest__.py b/fastapi_log/__manifest__.py new file mode 100644 index 000000000..c10eaf057 --- /dev/null +++ b/fastapi_log/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Log", + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Log Fastapi requests in database", + "category": "Tools", + "depends": ["fastapi"], + "website": "https://github.com/OCA/rest-framework", + "data": [ + "security/res_groups.xml", + "security/ir_model_access.xml", + "views/fastapi_endpoint_views.xml", + "views/fastapi_log_views.xml", + ], + "maintainers": ["paradoxxxzero"], + "demo": [], + "installable": True, + "license": "AGPL-3", +} diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py new file mode 100644 index 000000000..3e0a68f26 --- /dev/null +++ b/fastapi_log/fastapi_dispatcher.py @@ -0,0 +1,76 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import registry, tools +from odoo.http import _dispatchers + +from odoo.addons.fastapi.fastapi_dispatcher import ( + FastApiDispatcher as BaseFastApiDispatcher, +) + +_logger = logging.getLogger(__name__) + + +# Inherit from last registered fastapi dispatcher +# This handles multiple overload of dispatchers +class FastApiDispatcher(_dispatchers.get("fastapi", BaseFastApiDispatcher)): + routing_type = "fastapi" + + def dispatch(self, endpoint, args): + self.request.params = {} + environ = self._get_environ() + root_path = "/" + environ["PATH_INFO"].split("/")[1] + fastapi_endpoint = ( + self.request.env["fastapi.endpoint"] + .sudo() + .search([("root_path", "=", root_path)]) + ) + if fastapi_endpoint.log_requests: + log = None + try: + if tools.config["test_enable"]: + cr = getattr( + self.request.env.registry, "test_log_cr", self.request.env.cr + ) + else: + # Create an independent cursor + cr = registry(self.request.env.cr.dbname).cursor() + + env = self.request.env(cr=cr, su=True) + try: + # cf fastapi _get_environ + request = self.request.httprequest._HTTPRequest__wrapped + except AttributeError: + request = self.request.httprequest + + log = env["fastapi.log"].log_request( + request, environ, fastapi_endpoint.id + ) + except Exception as e: + _logger.warning("Failed to log request", exc_info=e) + + try: + response = super().dispatch(endpoint, args) + except Exception as e: + try: + log and log.log_exception(e) + except Exception as e: + _logger.warning("Failed to log exception", exc_info=e) + raise e + else: + try: + log and log.log_response(response) + except Exception as e: + _logger.warning("Failed to log response", exc_info=e) + finally: + if not tools.config["test_enable"]: + try: + cr.commit() # pylint: disable=E8102 + finally: + cr.close() + return response + + else: + return super().dispatch(endpoint, args) diff --git a/fastapi_log/models/__init__.py b/fastapi_log/models/__init__.py new file mode 100644 index 000000000..cddd4099d --- /dev/null +++ b/fastapi_log/models/__init__.py @@ -0,0 +1,2 @@ +from . import fastapi_endpoint +from . import fastapi_log diff --git a/fastapi_log/models/fastapi_endpoint.py b/fastapi_log/models/fastapi_endpoint.py new file mode 100644 index 000000000..e2649f812 --- /dev/null +++ b/fastapi_log/models/fastapi_endpoint.py @@ -0,0 +1,35 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + log_requests = fields.Boolean( + help="Log requests in database.", + ) + + fastapi_log_ids = fields.One2many( + "fastapi.log", + "endpoint_id", + string="Logs", + ) + + fastapi_log_count = fields.Integer( + compute="_compute_fastapi_log_count", + string="Logs Count", + ) + + @api.depends("fastapi_log_ids") + def _compute_fastapi_log_count(self): + data = self.env["fastapi.log"].read_group( + [("endpoint_id", "in", self.ids)], + ["endpoint_id"], + ["endpoint_id"], + ) + mapped_data = {m["endpoint_id"][0]: m["endpoint_id_count"] for m in data} + for record in self: + record.fastapi_log_count = mapped_data.get(record.id, 0) diff --git a/fastapi_log/models/fastapi_log.py b/fastapi_log/models/fastapi_log.py new file mode 100644 index 000000000..526d38213 --- /dev/null +++ b/fastapi_log/models/fastapi_log.py @@ -0,0 +1,227 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 +import json +import time +from traceback import format_exception + +from starlette.exceptions import HTTPException as StarletteHTTPException +from werkzeug.exceptions import HTTPException as WerkzeugHTTPException + +from odoo import api, fields, models + + +class FastapiLog(models.Model): + _name = "fastapi.log" + _description = "Fastapi Log" + _order = "id desc" + + endpoint_id = fields.Many2one( + "fastapi.endpoint", + string="Endpoint", + required=True, + ondelete="cascade", + index=True, + ) + + # Request + request_url = fields.Char() + request_method = fields.Char() + request_headers = fields.Json() + request_body = fields.Binary(attachment=False) + request_date = fields.Datetime() + request_time = fields.Float() + + # Response + response_status_code = fields.Integer() + response_headers = fields.Json() + response_body = fields.Binary(attachment=False) + response_date = fields.Datetime() + response_time = fields.Float() + + stack_trace = fields.Text() + + # Derived fields + name = fields.Char(compute="_compute_name", store=True) + time = fields.Float(compute="_compute_time", store=True) + request_preview = fields.Text(compute="_compute_request_preview") + response_preview = fields.Text(compute="_compute_response_preview") + request_b64 = fields.Binary( + string="Request Content", compute="_compute_request_b64" + ) + response_b64 = fields.Binary( + string="Response Content", compute="_compute_response_b64" + ) + request_headers_preview = fields.Text(compute="_compute_headers_preview") + response_headers_preview = fields.Text(compute="_compute_headers_preview") + request_content_type = fields.Char( + compute="_compute_request_headers_derived", store=True + ) + request_content_length = fields.Integer( + compute="_compute_request_headers_derived", store=True + ) + referrer = fields.Char(compute="_compute_request_headers_derived", store=True) + response_content_type = fields.Char( + compute="_compute_response_headers_derived", store=True + ) + response_content_length = fields.Integer( + compute="_compute_response_headers_derived", store=True + ) + + def _headers_to_dict(self, headers): + try: + return {key.lower(): value for key, value in headers.items()} + except AttributeError: + return {} + + def _current_time(self): + return time.time_ns() / 1e9 + + @api.model + def log_request(self, request, environ, endpoint_id): + body = None + # Be careful to not consume the request body if it hasn't been wrapped + stream = environ.get("wsgi.input") + if stream and stream.seekable(): + body = stream.read() + stream.seek(0) + + return self.create( + { + "endpoint_id": endpoint_id, + "request_url": request.url, + "request_method": request.method, + "request_headers": self._headers_to_dict(request.headers), + "request_body": body, + "request_date": fields.Datetime.now(), + "request_time": self._current_time(), + } + ) + + @api.model + def log_response(self, response): + return self.write( + { + "response_status_code": response.status_code, + "response_headers": self._headers_to_dict(response.headers), + "response_body": response.data, + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + ) + + @api.model + def log_exception(self, exception): + self.write( + { + "stack_trace": "".join(format_exception(exception)), + } + ) + if isinstance(exception, StarletteHTTPException): + return self.write( + { + "response_status_code": exception.status_code, + "response_headers": self._headers_to_dict(exception.headers), + "response_body": exception.detail, + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + ) + if isinstance(exception, WerkzeugHTTPException): + return self.write( + { + "response_status_code": exception.code, + "response_headers": self._headers_to_dict(exception.get_headers()), + "response_body": exception.get_body(), + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + ) + try: + return self.log_response( + self.env.registry["ir.http"]._handle_error(exception) + ) + except Exception: + return self.write( + { + "response_status_code": 599, + "response_body": str(exception), + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + ) + + @api.depends("request_url", "request_method", "request_date") + def _compute_name(self): + for record in self: + record.name = ( + f"{record.request_date.isoformat()} - " + f"[{record.request_method} {record.request_url}" + ) + + @api.depends("request_time", "response_time") + def _compute_time(self): + for record in self: + if record.request_time and record.response_time: + record.time = record.response_time - record.request_time + else: + record.time = 0 + + @api.depends("request_headers") + def _compute_request_headers_derived(self): + for record in self: + headers = record.request_headers or {} + record.request_content_type = headers.get("content-type", "") + record.request_content_length = headers.get("content-length", 0) + record.referrer = headers.get("referer", "") + + @api.depends("response_headers") + def _compute_response_headers_derived(self): + for record in self: + headers = record.response_headers or {} + record.response_content_type = headers.get("content-type", "") + record.response_content_length = headers.get("content-length", 0) + + @api.depends("request_body") + def _compute_request_preview(self): + for record in self.with_context(bin_size=False): + record.request_preview = record._body_preview(record.request_body) + + @api.depends("response_body") + def _compute_response_preview(self): + for record in self.with_context(bin_size=False): + record.response_preview = record._body_preview(record.response_body) + + def _body_preview(self, body): + # Display the first 1000 characters of the body if it's a text content + body_preview = False + if body: + try: + body_preview = body.decode("utf-8", errors="ignore") + if len(body_preview) > 1000: + body_preview = body_preview[:1000] + "...\n(...)" + except UnicodeDecodeError: + body_preview = False + return body_preview + + @api.depends("request_headers", "response_headers") + def _compute_headers_preview(self): + for record in self: + record.request_headers_preview = record._headers_preview( + record.request_headers + ) + record.response_headers_preview = record._headers_preview( + record.response_headers + ) + + def _headers_preview(self, headers): + return json.dumps(headers, sort_keys=True, indent=4) if headers else False + + @api.depends("request_body") + def _compute_request_b64(self): + self.request_b64 = base64.b64encode(self.request_body or b"") + + @api.depends("response_body") + def _compute_response_b64(self): + self.response_b64 = base64.b64encode(self.response_body or b"") diff --git a/fastapi_log/readme/CONTRIBUTORS.md b/fastapi_log/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/fastapi_log/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/fastapi_log/readme/DESCRIPTION.md b/fastapi_log/readme/DESCRIPTION.md new file mode 100644 index 000000000..60edac6e4 --- /dev/null +++ b/fastapi_log/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module allows an endpoint to activate full request logging in a database model. + +It is useful to debug production issues or to monitor the usage of a specific endpoint. diff --git a/fastapi_log/readme/USAGE.md b/fastapi_log/readme/USAGE.md new file mode 100644 index 000000000..420859a01 --- /dev/null +++ b/fastapi_log/readme/USAGE.md @@ -0,0 +1,6 @@ +To activate logging for an endpoint, you have to check the `Log Requests` checkbox in +the endpoint's configuration. This will log all requests and responses for that +endpoint. + +A smart button will be displayed in the endpoint's form view to access the endpoint +logs. A global log view is also available in the `FastAPI Logs` menu. diff --git a/fastapi_log/security/ir_model_access.xml b/fastapi_log/security/ir_model_access.xml new file mode 100644 index 000000000..ea4cd5edf --- /dev/null +++ b/fastapi_log/security/ir_model_access.xml @@ -0,0 +1,17 @@ + + + + + Fastapi Log: Read access + + + + + + + + diff --git a/fastapi_log/security/res_groups.xml b/fastapi_log/security/res_groups.xml new file mode 100644 index 000000000..3eec366d1 --- /dev/null +++ b/fastapi_log/security/res_groups.xml @@ -0,0 +1,17 @@ + + + + + + Fastapi Log Access + + + + diff --git a/fastapi_log/static/description/index.html b/fastapi_log/static/description/index.html new file mode 100644 index 000000000..0a76e9f5d --- /dev/null +++ b/fastapi_log/static/description/index.html @@ -0,0 +1,438 @@ + + + + + +Fastapi Log + + + +
+

Fastapi Log

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module allows an endpoint to activate full request logging in a +database model.

+

It is useful to debug production issues or to monitor the usage of a +specific endpoint.

+

Table of contents

+ +
+

Usage

+

To activate logging for an endpoint, you have to check the +Log Requests checkbox in the endpoint’s configuration. This will log +all requests and responses for that endpoint.

+

A smart button will be displayed in the endpoint’s form view to access +the endpoint logs. A global log view is also available in the +FastAPI Logs menu.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

paradoxxxzero

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fastapi_log/tests/__init__.py b/fastapi_log/tests/__init__.py new file mode 100644 index 000000000..41a525a04 --- /dev/null +++ b/fastapi_log/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_log diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py new file mode 100644 index 000000000..77df06ee9 --- /dev/null +++ b/fastapi_log/tests/test_fastapi_log.py @@ -0,0 +1,161 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import os +import threading +import unittest +from contextlib import contextmanager + +from odoo.sql_db import TestCursor +from odoo.tests.common import HttpCase, RecordCapturer + +from odoo.addons.fastapi.schemas import DemoExceptionType + +from fastapi import status + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "FastAPIEncryptedErrorsCase skipped") +class FastAPIEncryptedErrorsCase(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.fastapi_demo_app._handle_registry_sync() + cls.fastapi_demo_app.write({"log_requests": True}) + lang = ( + cls.env["res.lang"] + .with_context(active_test=False) + .search([("code", "=", "fr_BE")]) + ) + lang.active = True + + def setUp(self): + super().setUp() + # Use a side test cursor to be able to get exception logs + reg = self.env.registry + reg.test_log_lock = threading.RLock() + reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock) + + def tearDown(self): + reg = self.env.registry + reg.test_log_cr.rollback() + reg.test_log_cr.close() + reg.test_log_cr = None + reg.test_log_lock = None + super().tearDown() + + @contextmanager + def log_capturer(self): + with RecordCapturer( + self.env(cr=self.env.registry.test_log_cr)["fastapi.log"], + [("endpoint_id", "=", self.fastapi_demo_app.id)], + ) as capturer: + yield capturer + + def test_no_log_if_disabled(self): + self.fastapi_demo_app.write({"log_requests": False}) + + with self.log_capturer() as capturer: + response = self.url_open("/fastapi_demo/demo", timeout=200) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertFalse(capturer.records) + + def test_log_simple(self): + with self.log_capturer() as capturer: + response = self.url_open("/fastapi_demo/demo", timeout=200) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(len(capturer.records), 1) + log = capturer.records[0] + self.assertTrue(log.request_url.endswith("/fastapi_demo/demo")) + self.assertEqual(log.request_method, "GET") + self.assertEqual(log.response_status_code, 200) + self.assertTrue(log.time > 0) + + def test_log_exception(self): + with self.log_capturer() as capturer: + route = ( + "/fastapi_demo/demo/exception?" + f"exception_type={DemoExceptionType.user_error.value}" + "&error_message=User Error" + ) + response = self.url_open(route, timeout=200) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(len(capturer.records), 1) + log = capturer.records[0] + self.assertIn("/fastapi_demo/demo/exception", log.request_url) + self.assertEqual(log.request_method, "GET") + self.assertEqual(log.response_status_code, 400) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b"User Error", log.response_body) + self.assertIn("odoo.exceptions.UserError: User Error\n", log.stack_trace) + + def test_log_bare_exception(self): + with self.log_capturer() as capturer: + route = ( + "/fastapi_demo/demo/exception?" + f"exception_type={DemoExceptionType.bare_exception.value}" + "&error_message=Internal Server Error" + ) + response = self.url_open(route, timeout=200) + self.assertEqual( + response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + self.assertEqual(len(capturer.records), 1) + log = capturer.records[0] + self.assertIn("/fastapi_demo/demo/exception", log.request_url) + self.assertEqual(log.request_method, "GET") + self.assertEqual(log.response_status_code, 500) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b"Internal Server Error", log.response_body) + self.assertIn("NotImplementedError: Internal Server Error\n", log.stack_trace) + + def test_log_retrying_post(self): + with self.log_capturer() as capturer: + nbr_retries = 2 + route = f"/fastapi_demo/demo/retrying?nbr_retries={nbr_retries}" + response = self.url_open( + route, timeout=20, files={"file": ("test.txt", b"test")} + ) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.json(), {"retries": nbr_retries, "file": "test"} + ) + + self.assertEqual(len(capturer.records), 3) + log = capturer.records[0] + self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertEqual(log.request_method, "POST") + self.assertEqual(log.response_status_code, 200) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b'"retries":2', log.response_body) + self.assertIn(b'"file":"test"', log.response_body) + self.assertFalse(log.stack_trace) + log = capturer.records[1] + self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertEqual(log.request_method, "POST") + self.assertEqual(log.response_status_code, 500) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b'{"detail": "Internal Server Error"}', log.response_body) + self.assertIn( + "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", + log.stack_trace, + ) + log = capturer.records[2] + self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertEqual(log.request_method, "POST") + self.assertEqual(log.response_status_code, 500) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b'{"detail": "Internal Server Error"}', log.response_body) + self.assertIn( + "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", + log.stack_trace, + ) diff --git a/fastapi_log/views/fastapi_endpoint_views.xml b/fastapi_log/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..7997cd9c0 --- /dev/null +++ b/fastapi_log/views/fastapi_endpoint_views.xml @@ -0,0 +1,42 @@ + + + + + + Fastapi Log + fastapi.log + tree,form + [('endpoint_id', '=', active_id)] + {'default_endpoint_id': active_id} + + + + + fastapi.endpoint + + + +
+ +
+ + + + + + +
+ +
diff --git a/fastapi_log/views/fastapi_log_views.xml b/fastapi_log/views/fastapi_log_views.xml new file mode 100644 index 000000000..021442ffc --- /dev/null +++ b/fastapi_log/views/fastapi_log_views.xml @@ -0,0 +1,124 @@ + + + + + Fastapi Log + fastapi.log + tree,form + + + + fastapi.log.form + fastapi.log + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + fastapi.log.tree + fastapi.log + + + + + + + + + + + + + + + + fastapi.log.search + fastapi.log + + + + + + + + + + + + + + + + + + +
diff --git a/setup/fastapi_log/odoo/addons/fastapi_log b/setup/fastapi_log/odoo/addons/fastapi_log new file mode 120000 index 000000000..4996c1e31 --- /dev/null +++ b/setup/fastapi_log/odoo/addons/fastapi_log @@ -0,0 +1 @@ +../../../../fastapi_log \ No newline at end of file diff --git a/setup/fastapi_log/setup.py b/setup/fastapi_log/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_log/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From f9692bb835f650a59d3f36a1558ebc8660700c3c Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Tue, 15 Apr 2025 11:50:18 +0200 Subject: [PATCH 02/17] [REF] fastapi_log: Extract common features to `api_log` This way other APIs might use the new module `api_log` to store logs. --- api_log/README.rst | 89 ++++ api_log/__init__.py | 1 + api_log/__manifest__.py | 20 + api_log/models/__init__.py | 1 + api_log/models/api_log.py | 212 +++++++++ api_log/readme/CONTRIBUTORS.md | 5 + api_log/readme/DESCRIPTION.md | 1 + .../security/ir_model_access.xml | 8 +- .../security/res_groups.xml | 4 +- api_log/static/description/index.html | 431 ++++++++++++++++++ api_log/tests/__init__.py | 1 + api_log/tests/common.py | 8 + api_log/tests/test_api_log.py | 30 ++ api_log/views/api_log_views.xml | 110 +++++ fastapi_log/README.rst | 2 +- fastapi_log/__manifest__.py | 7 +- fastapi_log/fastapi_dispatcher.py | 38 +- fastapi_log/models/__init__.py | 2 +- fastapi_log/models/api_log.py | 58 +++ fastapi_log/models/fastapi_endpoint.py | 20 +- fastapi_log/models/fastapi_log.py | 227 --------- fastapi_log/tests/test_fastapi_log.py | 43 +- fastapi_log/views/fastapi_endpoint_views.xml | 10 +- fastapi_log/views/fastapi_log_views.xml | 128 ++---- setup/api_log/odoo/addons/api_log | 1 + setup/api_log/setup.py | 6 + 26 files changed, 1068 insertions(+), 395 deletions(-) create mode 100644 api_log/README.rst create mode 100644 api_log/__init__.py create mode 100644 api_log/__manifest__.py create mode 100644 api_log/models/__init__.py create mode 100644 api_log/models/api_log.py create mode 100644 api_log/readme/CONTRIBUTORS.md create mode 100644 api_log/readme/DESCRIPTION.md rename {fastapi_log => api_log}/security/ir_model_access.xml (65%) rename {fastapi_log => api_log}/security/res_groups.xml (78%) create mode 100644 api_log/static/description/index.html create mode 100644 api_log/tests/__init__.py create mode 100644 api_log/tests/common.py create mode 100644 api_log/tests/test_api_log.py create mode 100644 api_log/views/api_log_views.xml create mode 100644 fastapi_log/models/api_log.py delete mode 100644 fastapi_log/models/fastapi_log.py create mode 120000 setup/api_log/odoo/addons/api_log create mode 100644 setup/api_log/setup.py diff --git a/api_log/README.rst b/api_log/README.rst new file mode 100644 index 000000000..84b9d1e11 --- /dev/null +++ b/api_log/README.rst @@ -0,0 +1,89 @@ +======= +API Log +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/api_log + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-api_log + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to store request and response logs for any API. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com +- Guewen Baconnier guewen.baconnier@camptocamp.com +- Simone Orsi simahawk@gmail.com +- `PyTech `__: + + - Simone Rubino + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/api_log/__init__.py b/api_log/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/api_log/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/api_log/__manifest__.py b/api_log/__manifest__.py new file mode 100644 index 000000000..84193a908 --- /dev/null +++ b/api_log/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "API Log", + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "summary": "Log API requests in database", + "category": "Tools", + "depends": ["web"], + "website": "https://github.com/OCA/rest-framework", + "data": [ + "security/res_groups.xml", + "security/ir_model_access.xml", + "views/api_log_views.xml", + ], + "maintainers": ["paradoxxxzero"], +} diff --git a/api_log/models/__init__.py b/api_log/models/__init__.py new file mode 100644 index 000000000..0f340289c --- /dev/null +++ b/api_log/models/__init__.py @@ -0,0 +1 @@ +from . import api_log diff --git a/api_log/models/api_log.py b/api_log/models/api_log.py new file mode 100644 index 000000000..c2cb5bf0d --- /dev/null +++ b/api_log/models/api_log.py @@ -0,0 +1,212 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import json +import time +from traceback import format_exception + +from werkzeug.exceptions import HTTPException as WerkzeugHTTPException + +from odoo import api, fields, models + + +class APILog(models.Model): + _name = "api.log" + _description = "Log for API" + _order = "id desc" + + # Request + request_url = fields.Char() + request_method = fields.Char() + request_headers = fields.Json() + request_body = fields.Binary(attachment=False) + request_date = fields.Datetime() + request_time = fields.Float() + + # Response + response_status_code = fields.Integer() + response_headers = fields.Json() + response_body = fields.Binary(attachment=False) + response_date = fields.Datetime() + response_time = fields.Float() + + stack_trace = fields.Text() + + # Derived fields + name = fields.Char(compute="_compute_name", store=True) + time = fields.Float(compute="_compute_time", store=True) + request_preview = fields.Text(compute="_compute_request_preview") + response_preview = fields.Text(compute="_compute_response_preview") + # Binary fields are useful to download the payload in case of file download/upload + request_b64 = fields.Binary( + string="Request Content", compute="_compute_request_b64" + ) + response_b64 = fields.Binary( + string="Response Content", compute="_compute_response_b64" + ) + request_headers_preview = fields.Text(compute="_compute_headers_preview") + response_headers_preview = fields.Text(compute="_compute_headers_preview") + request_content_type = fields.Char( + compute="_compute_request_headers_derived", store=True + ) + request_content_length = fields.Integer( + compute="_compute_request_headers_derived", store=True + ) + referrer = fields.Char(compute="_compute_request_headers_derived", store=True) + response_content_type = fields.Char( + compute="_compute_response_headers_derived", store=True + ) + response_content_length = fields.Integer( + compute="_compute_response_headers_derived", store=True + ) + + def _headers_to_dict(self, headers): + return {key.lower(): value for key, value in headers.items()} + + def _current_time(self): + return time.time_ns() / 1e9 + + @api.model + def _get_http_request(self, request): + return request.httprequest + + @api.model + def _get_request_body(self, request): + """Take extra care with the request's body because it might get consumed.""" + httprequest = self._get_http_request(request) + return httprequest.data + + @api.model + def _prepare_log_request(self, request): + httprequest = self._get_http_request(request) + log_request_values = { + "request_url": httprequest.url, + "request_method": httprequest.method, + "request_headers": self._headers_to_dict(httprequest.headers), + "request_body": self._get_request_body(request), + "request_date": fields.Datetime.now(), + "request_time": self._current_time(), + } + return log_request_values + + @api.model + def log_request(self, request, override_log_values=None): + log_request_values = self._prepare_log_request(request) + log_request_values.update(override_log_values or {}) + return self.sudo().create(log_request_values) + + def _prepare_log_response(self, response): + return { + "response_status_code": response.status_code, + "response_headers": self._headers_to_dict(response.headers), + "response_body": response.data, + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + + def log_response(self, response): + log_response_values = self._prepare_log_response(response) + return self.sudo().write(log_response_values) + + def _prepare_log_exception(self, exception): + values = { + "stack_trace": "".join(format_exception(exception)), + "response_body": str(exception), + "response_date": fields.Datetime.now(), + "response_time": self._current_time(), + } + + if isinstance(exception, WerkzeugHTTPException): + values.update( + { + "response_status_code": exception.code, + "response_headers": self._headers_to_dict(exception.get_headers()), + "response_body": exception.get_body(), + } + ) + return values + + def log_exception(self, exception): + try: + exc_handling_response = self.env.registry["ir.http"]._handle_error( + exception + ) + self.log_response(exc_handling_response) + except Exception as handling_exception: + exception = handling_exception + log_exception_values = self._prepare_log_exception(exception) + return self.sudo().write(log_exception_values) + + @api.depends("request_url", "request_method", "request_date") + def _compute_name(self): + for log in self: + log.name = ( + f"{log.request_date.isoformat()} - " + f"[{log.request_method}] {log.request_url}" + ) + + @api.depends("request_time", "response_time") + def _compute_time(self): + for log in self: + if log.request_time and log.response_time: + log.time = log.response_time - log.request_time + else: + log.time = 0 + + @api.depends("request_headers") + def _compute_request_headers_derived(self): + for log in self: + headers = log.request_headers or {} + log.request_content_type = headers.get("content-type", "") + log.request_content_length = headers.get("content-length", 0) + log.referrer = headers.get("referer", "") + + @api.depends("response_headers") + def _compute_response_headers_derived(self): + for log in self: + headers = log.response_headers or {} + log.response_content_type = headers.get("content-type", "") + log.response_content_length = headers.get("content-length", 0) + + @api.depends("request_body") + def _compute_request_preview(self): + for log in self.with_context(bin_size=False): + log.request_preview = log._body_preview(log.request_body) + + @api.depends("response_body") + def _compute_response_preview(self): + for log in self.with_context(bin_size=False): + log.response_preview = log._body_preview(log.response_body) + + def _body_preview(self, body): + # Display the first 1000 characters of the body if it's a text content + body_preview = False + if body: + try: + body_preview = body.decode("utf-8", errors="ignore") + if len(body_preview) > 1000: + body_preview = body_preview[:1000] + "...\n(...)" + except UnicodeDecodeError: + body_preview = False + return body_preview + + @api.depends("request_headers", "response_headers") + def _compute_headers_preview(self): + for log in self: + log.request_headers_preview = log._headers_preview(log.request_headers) + log.response_headers_preview = log._headers_preview(log.response_headers) + + def _headers_preview(self, headers): + return json.dumps(headers, sort_keys=True, indent=4) if headers else False + + @api.depends("request_body") + def _compute_request_b64(self): + for log in self: + log.request_b64 = base64.b64encode(log.request_body or b"") + + @api.depends("response_body") + def _compute_response_b64(self): + for log in self: + log.response_b64 = base64.b64encode(log.response_body or b"") diff --git a/api_log/readme/CONTRIBUTORS.md b/api_log/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..599c28bb2 --- /dev/null +++ b/api_log/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- Florian Mounier +- Guewen Baconnier +- Simone Orsi +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> diff --git a/api_log/readme/DESCRIPTION.md b/api_log/readme/DESCRIPTION.md new file mode 100644 index 000000000..6018fc343 --- /dev/null +++ b/api_log/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to store request and response logs for any API. diff --git a/fastapi_log/security/ir_model_access.xml b/api_log/security/ir_model_access.xml similarity index 65% rename from fastapi_log/security/ir_model_access.xml rename to api_log/security/ir_model_access.xml index ea4cd5edf..a092c0d3a 100644 --- a/fastapi_log/security/ir_model_access.xml +++ b/api_log/security/ir_model_access.xml @@ -5,10 +5,10 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - - Fastapi Log: Read access - - + + API Log: Read access + + diff --git a/fastapi_log/security/res_groups.xml b/api_log/security/res_groups.xml similarity index 78% rename from fastapi_log/security/res_groups.xml rename to api_log/security/res_groups.xml index 3eec366d1..8b9ddf38b 100644 --- a/fastapi_log/security/res_groups.xml +++ b/api_log/security/res_groups.xml @@ -6,8 +6,8 @@ --> - - Fastapi Log Access + + API Log Access + + + + +API Log + + + +
+

API Log

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module allows to store request and response logs for any API.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

paradoxxxzero

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/api_log/tests/__init__.py b/api_log/tests/__init__.py new file mode 100644 index 000000000..7f84a8e4f --- /dev/null +++ b/api_log/tests/__init__.py @@ -0,0 +1 @@ +from . import test_api_log diff --git a/api_log/tests/common.py b/api_log/tests/common.py new file mode 100644 index 000000000..e54316db6 --- /dev/null +++ b/api_log/tests/common.py @@ -0,0 +1,8 @@ +from odoo.tests.common import HttpCase + + +class CommonAPILog(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.log_model = cls.env["api.log"] diff --git a/api_log/tests/test_api_log.py b/api_log/tests/test_api_log.py new file mode 100644 index 000000000..abed32678 --- /dev/null +++ b/api_log/tests/test_api_log.py @@ -0,0 +1,30 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import requests + +from odoo.http import Request, Response + +from odoo.addons.api_log.tests.common import CommonAPILog + + +class TestAPILog(CommonAPILog): + def test_log_request(self): + base_url = self.base_url() + httprequest = requests.Request( + url=base_url, + method="GET", + ) + request = Request(httprequest) + log = self.log_model.log_request(request) + + self.assertEqual(log.request_url, base_url) + self.assertEqual(log.request_method, "GET") + + def test_log_response(self): + response = Response() + log = self.log_model.create({}) + log.log_response(response) + + self.assertEqual(log.response_status_code, 200) diff --git a/api_log/views/api_log_views.xml b/api_log/views/api_log_views.xml new file mode 100644 index 000000000..cc09bbdaf --- /dev/null +++ b/api_log/views/api_log_views.xml @@ -0,0 +1,110 @@ + + + + + API Log + api.log + tree,form + + + + api.log.form + api.log + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + api.log.tree + api.log + + + + + + + + + + + + + + + api.log.search + api.log + + + + + + + + + + + + + + + + +
diff --git a/fastapi_log/README.rst b/fastapi_log/README.rst index 6464683d2..96624a991 100644 --- a/fastapi_log/README.rst +++ b/fastapi_log/README.rst @@ -71,7 +71,7 @@ Authors Contributors ------------ -- Florian Mounier florian.mounier@akretion.com +- Florian Mounier florian.mounier@akretion.com Maintainers ----------- diff --git a/fastapi_log/__manifest__.py b/fastapi_log/__manifest__.py index c10eaf057..8334dc5c2 100644 --- a/fastapi_log/__manifest__.py +++ b/fastapi_log/__manifest__.py @@ -8,11 +8,12 @@ "author": "Akretion, Odoo Community Association (OCA)", "summary": "Log Fastapi requests in database", "category": "Tools", - "depends": ["fastapi"], + "depends": [ + "api_log", + "fastapi", + ], "website": "https://github.com/OCA/rest-framework", "data": [ - "security/res_groups.xml", - "security/ir_model_access.xml", "views/fastapi_endpoint_views.xml", "views/fastapi_log_views.xml", ], diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py index 3e0a68f26..6a762cd51 100644 --- a/fastapi_log/fastapi_dispatcher.py +++ b/fastapi_log/fastapi_dispatcher.py @@ -28,37 +28,30 @@ def dispatch(self, endpoint, args): .search([("root_path", "=", root_path)]) ) if fastapi_endpoint.log_requests: - log = None - try: - if tools.config["test_enable"]: - cr = getattr( - self.request.env.registry, "test_log_cr", self.request.env.cr - ) - else: - # Create an independent cursor - cr = registry(self.request.env.cr.dbname).cursor() - - env = self.request.env(cr=cr, su=True) - try: - # cf fastapi _get_environ - request = self.request.httprequest._HTTPRequest__wrapped - except AttributeError: - request = self.request.httprequest - - log = env["fastapi.log"].log_request( - request, environ, fastapi_endpoint.id + if tools.config["test_enable"]: + cr = getattr( + self.request.env.registry, "test_log_cr", self.request.env.cr ) + else: + # Create an independent cursor + cr = registry(self.request.env.cr.dbname).cursor() + + env = self.request.env(cr=cr, su=True) + request = self.request + try: + log = env["api.log"].log_request(request) except Exception as e: _logger.warning("Failed to log request", exc_info=e) + log = None try: response = super().dispatch(endpoint, args) - except Exception as e: + except Exception as response_exc: try: - log and log.log_exception(e) + log and log.log_exception(response_exc) except Exception as e: _logger.warning("Failed to log exception", exc_info=e) - raise e + raise response_exc else: try: log and log.log_response(response) @@ -71,6 +64,5 @@ def dispatch(self, endpoint, args): finally: cr.close() return response - else: return super().dispatch(endpoint, args) diff --git a/fastapi_log/models/__init__.py b/fastapi_log/models/__init__.py index cddd4099d..23ac9cf0b 100644 --- a/fastapi_log/models/__init__.py +++ b/fastapi_log/models/__init__.py @@ -1,2 +1,2 @@ +from . import api_log from . import fastapi_endpoint -from . import fastapi_log diff --git a/fastapi_log/models/api_log.py b/fastapi_log/models/api_log.py new file mode 100644 index 000000000..ee28db599 --- /dev/null +++ b/fastapi_log/models/api_log.py @@ -0,0 +1,58 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from starlette.exceptions import HTTPException as StarletteHTTPException + +from odoo import api, fields, models + + +class FastapiLog(models.Model): + _inherit = "api.log" + + fastapi_endpoint_id = fields.Many2one( + comodel_name="fastapi.endpoint", + string="Endpoint", + ondelete="cascade", + index=True, + ) + + @api.model + def _get_request_body(self, request): + # Be careful to not consume the request body if it hasn't been wrapped + dispatcher = request.dispatcher + if dispatcher.routing_type == "fastapi": + environ = dispatcher._get_environ() + stream = environ.get("wsgi.input") + if stream and stream.seekable(): + request_body = stream.read() + stream.seek(0) + else: + request_body = super()._get_request_body(request) + return request_body + + @api.model + def _prepare_log_request(self, request): + log_request_values = super()._prepare_log_request(request) + dispatcher = request.dispatcher + if dispatcher.routing_type == "fastapi": + environ = dispatcher._get_environ() + endpoint = ( + request.env["fastapi.endpoint"] + .sudo() + ._get_endpoint(environ["PATH_INFO"]) + ) + log_request_values["fastapi_endpoint_id"] = endpoint.id + return log_request_values + + def _prepare_log_exception(self, exception): + values = super()._prepare_log_exception(exception) + if isinstance(exception, StarletteHTTPException): + values.update( + { + "response_status_code": exception.status_code, + "response_headers": self._headers_to_dict(exception.headers), + "response_body": exception.detail, + } + ) + return values diff --git a/fastapi_log/models/fastapi_endpoint.py b/fastapi_log/models/fastapi_endpoint.py index e2649f812..62789fc4c 100644 --- a/fastapi_log/models/fastapi_endpoint.py +++ b/fastapi_log/models/fastapi_endpoint.py @@ -13,8 +13,8 @@ class FastapiEndpoint(models.Model): ) fastapi_log_ids = fields.One2many( - "fastapi.log", - "endpoint_id", + comodel_name="api.log", + inverse_name="fastapi_endpoint_id", string="Logs", ) @@ -25,11 +25,13 @@ class FastapiEndpoint(models.Model): @api.depends("fastapi_log_ids") def _compute_fastapi_log_count(self): - data = self.env["fastapi.log"].read_group( - [("endpoint_id", "in", self.ids)], - ["endpoint_id"], - ["endpoint_id"], + groups = self.env["api.log"].read_group( + [("fastapi_endpoint_id", "in", self.ids)], + ["fastapi_endpoint_id"], + ["fastapi_endpoint_id"], ) - mapped_data = {m["endpoint_id"][0]: m["endpoint_id_count"] for m in data} - for record in self: - record.fastapi_log_count = mapped_data.get(record.id, 0) + mapped_data = { + g["fastapi_endpoint_id"][0]: g["fastapi_endpoint_id_count"] for g in groups + } + for endpoint in self: + endpoint.fastapi_log_count = mapped_data.get(endpoint.id, 0) diff --git a/fastapi_log/models/fastapi_log.py b/fastapi_log/models/fastapi_log.py deleted file mode 100644 index 526d38213..000000000 --- a/fastapi_log/models/fastapi_log.py +++ /dev/null @@ -1,227 +0,0 @@ -# Copyright 2025 Akretion (http://www.akretion.com). -# @author Florian Mounier -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import base64 -import json -import time -from traceback import format_exception - -from starlette.exceptions import HTTPException as StarletteHTTPException -from werkzeug.exceptions import HTTPException as WerkzeugHTTPException - -from odoo import api, fields, models - - -class FastapiLog(models.Model): - _name = "fastapi.log" - _description = "Fastapi Log" - _order = "id desc" - - endpoint_id = fields.Many2one( - "fastapi.endpoint", - string="Endpoint", - required=True, - ondelete="cascade", - index=True, - ) - - # Request - request_url = fields.Char() - request_method = fields.Char() - request_headers = fields.Json() - request_body = fields.Binary(attachment=False) - request_date = fields.Datetime() - request_time = fields.Float() - - # Response - response_status_code = fields.Integer() - response_headers = fields.Json() - response_body = fields.Binary(attachment=False) - response_date = fields.Datetime() - response_time = fields.Float() - - stack_trace = fields.Text() - - # Derived fields - name = fields.Char(compute="_compute_name", store=True) - time = fields.Float(compute="_compute_time", store=True) - request_preview = fields.Text(compute="_compute_request_preview") - response_preview = fields.Text(compute="_compute_response_preview") - request_b64 = fields.Binary( - string="Request Content", compute="_compute_request_b64" - ) - response_b64 = fields.Binary( - string="Response Content", compute="_compute_response_b64" - ) - request_headers_preview = fields.Text(compute="_compute_headers_preview") - response_headers_preview = fields.Text(compute="_compute_headers_preview") - request_content_type = fields.Char( - compute="_compute_request_headers_derived", store=True - ) - request_content_length = fields.Integer( - compute="_compute_request_headers_derived", store=True - ) - referrer = fields.Char(compute="_compute_request_headers_derived", store=True) - response_content_type = fields.Char( - compute="_compute_response_headers_derived", store=True - ) - response_content_length = fields.Integer( - compute="_compute_response_headers_derived", store=True - ) - - def _headers_to_dict(self, headers): - try: - return {key.lower(): value for key, value in headers.items()} - except AttributeError: - return {} - - def _current_time(self): - return time.time_ns() / 1e9 - - @api.model - def log_request(self, request, environ, endpoint_id): - body = None - # Be careful to not consume the request body if it hasn't been wrapped - stream = environ.get("wsgi.input") - if stream and stream.seekable(): - body = stream.read() - stream.seek(0) - - return self.create( - { - "endpoint_id": endpoint_id, - "request_url": request.url, - "request_method": request.method, - "request_headers": self._headers_to_dict(request.headers), - "request_body": body, - "request_date": fields.Datetime.now(), - "request_time": self._current_time(), - } - ) - - @api.model - def log_response(self, response): - return self.write( - { - "response_status_code": response.status_code, - "response_headers": self._headers_to_dict(response.headers), - "response_body": response.data, - "response_date": fields.Datetime.now(), - "response_time": self._current_time(), - } - ) - - @api.model - def log_exception(self, exception): - self.write( - { - "stack_trace": "".join(format_exception(exception)), - } - ) - if isinstance(exception, StarletteHTTPException): - return self.write( - { - "response_status_code": exception.status_code, - "response_headers": self._headers_to_dict(exception.headers), - "response_body": exception.detail, - "response_date": fields.Datetime.now(), - "response_time": self._current_time(), - } - ) - if isinstance(exception, WerkzeugHTTPException): - return self.write( - { - "response_status_code": exception.code, - "response_headers": self._headers_to_dict(exception.get_headers()), - "response_body": exception.get_body(), - "response_date": fields.Datetime.now(), - "response_time": self._current_time(), - } - ) - try: - return self.log_response( - self.env.registry["ir.http"]._handle_error(exception) - ) - except Exception: - return self.write( - { - "response_status_code": 599, - "response_body": str(exception), - "response_date": fields.Datetime.now(), - "response_time": self._current_time(), - } - ) - - @api.depends("request_url", "request_method", "request_date") - def _compute_name(self): - for record in self: - record.name = ( - f"{record.request_date.isoformat()} - " - f"[{record.request_method} {record.request_url}" - ) - - @api.depends("request_time", "response_time") - def _compute_time(self): - for record in self: - if record.request_time and record.response_time: - record.time = record.response_time - record.request_time - else: - record.time = 0 - - @api.depends("request_headers") - def _compute_request_headers_derived(self): - for record in self: - headers = record.request_headers or {} - record.request_content_type = headers.get("content-type", "") - record.request_content_length = headers.get("content-length", 0) - record.referrer = headers.get("referer", "") - - @api.depends("response_headers") - def _compute_response_headers_derived(self): - for record in self: - headers = record.response_headers or {} - record.response_content_type = headers.get("content-type", "") - record.response_content_length = headers.get("content-length", 0) - - @api.depends("request_body") - def _compute_request_preview(self): - for record in self.with_context(bin_size=False): - record.request_preview = record._body_preview(record.request_body) - - @api.depends("response_body") - def _compute_response_preview(self): - for record in self.with_context(bin_size=False): - record.response_preview = record._body_preview(record.response_body) - - def _body_preview(self, body): - # Display the first 1000 characters of the body if it's a text content - body_preview = False - if body: - try: - body_preview = body.decode("utf-8", errors="ignore") - if len(body_preview) > 1000: - body_preview = body_preview[:1000] + "...\n(...)" - except UnicodeDecodeError: - body_preview = False - return body_preview - - @api.depends("request_headers", "response_headers") - def _compute_headers_preview(self): - for record in self: - record.request_headers_preview = record._headers_preview( - record.request_headers - ) - record.response_headers_preview = record._headers_preview( - record.response_headers - ) - - def _headers_preview(self, headers): - return json.dumps(headers, sort_keys=True, indent=4) if headers else False - - @api.depends("request_body") - def _compute_request_b64(self): - self.request_b64 = base64.b64encode(self.request_body or b"") - - @api.depends("response_body") - def _compute_response_b64(self): - self.response_b64 = base64.b64encode(self.response_body or b"") diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index 77df06ee9..c862669cd 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -7,15 +7,16 @@ from contextlib import contextmanager from odoo.sql_db import TestCursor -from odoo.tests.common import HttpCase, RecordCapturer +from odoo.tests.common import RecordCapturer +from odoo.addons.api_log.tests.common import CommonAPILog from odoo.addons.fastapi.schemas import DemoExceptionType from fastapi import status @unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "FastAPIEncryptedErrorsCase skipped") -class FastAPIEncryptedErrorsCase(HttpCase): +class FastAPIEncryptedErrorsCase(CommonAPILog): @classmethod def setUpClass(cls): super().setUpClass() @@ -47,8 +48,8 @@ def tearDown(self): @contextmanager def log_capturer(self): with RecordCapturer( - self.env(cr=self.env.registry.test_log_cr)["fastapi.log"], - [("endpoint_id", "=", self.fastapi_demo_app.id)], + self.env(cr=self.env.registry.test_log_cr)[self.log_model._name], + [("fastapi_endpoint_id", "=", self.fastapi_demo_app.id)], ) as capturer: yield capturer @@ -128,6 +129,18 @@ def test_log_retrying_post(self): ) self.assertEqual(len(capturer.records), 3) + for log in capturer.records[1:]: + self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertEqual(log.request_method, "POST") + self.assertEqual(log.response_status_code, 500) + self.assertTrue(log.time > 0) + self.assertTrue(log.response_body) + self.assertIn(b"fake error", log.response_body) + self.assertIn( + "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", + log.stack_trace, + ) + log = capturer.records[0] self.assertIn("/fastapi_demo/demo/retrying", log.request_url) self.assertEqual(log.request_method, "POST") @@ -137,25 +150,3 @@ def test_log_retrying_post(self): self.assertIn(b'"retries":2', log.response_body) self.assertIn(b'"file":"test"', log.response_body) self.assertFalse(log.stack_trace) - log = capturer.records[1] - self.assertIn("/fastapi_demo/demo/retrying", log.request_url) - self.assertEqual(log.request_method, "POST") - self.assertEqual(log.response_status_code, 500) - self.assertTrue(log.time > 0) - self.assertTrue(log.response_body) - self.assertIn(b'{"detail": "Internal Server Error"}', log.response_body) - self.assertIn( - "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", - log.stack_trace, - ) - log = capturer.records[2] - self.assertIn("/fastapi_demo/demo/retrying", log.request_url) - self.assertEqual(log.request_method, "POST") - self.assertEqual(log.response_status_code, 500) - self.assertTrue(log.time > 0) - self.assertTrue(log.response_body) - self.assertIn(b'{"detail": "Internal Server Error"}', log.response_body) - self.assertIn( - "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", - log.stack_trace, - ) diff --git a/fastapi_log/views/fastapi_endpoint_views.xml b/fastapi_log/views/fastapi_endpoint_views.xml index 7997cd9c0..bf90e7ea1 100644 --- a/fastapi_log/views/fastapi_endpoint_views.xml +++ b/fastapi_log/views/fastapi_endpoint_views.xml @@ -8,10 +8,10 @@ Fastapi Log - fastapi.log + api.log tree,form - [('endpoint_id', '=', active_id)] - {'default_endpoint_id': active_id} + [('fastapi_endpoint_id', '=', active_id)] + {'default_fastapi_endpoint_id': active_id} @@ -25,7 +25,7 @@ type="action" name="%(fastapi_log.fastapi_log_action_from_endpoint)s" icon="fa-book" - groups="fastapi_log.group_fastapi_log" + groups="api_log.group_api_log" attrs="{'invisible': [('fastapi_log_count', '=', 0)]}" > @@ -34,7 +34,7 @@ - +
diff --git a/fastapi_log/views/fastapi_log_views.xml b/fastapi_log/views/fastapi_log_views.xml index 021442ffc..1f33e422f 100644 --- a/fastapi_log/views/fastapi_log_views.xml +++ b/fastapi_log/views/fastapi_log_views.xml @@ -5,115 +5,55 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - - Fastapi Log - fastapi.log - tree,form - - - - fastapi.log.form - fastapi.log + + Add Fastapi fields to API log form view + api.log + -
- -
-

- -

-
- - - - - - - - - - - - - - - - - - - - - -
-
+ + +
- - fastapi.log.tree - fastapi.log + + Add Fastapi fields to API log tree view + api.log + - - - - - - - - - + + - - fastapi.log.search - fastapi.log + + Add Fastapi fields to API log search view + api.log + - - - - - + + + + - - - - - - - + + + Fastapi Logs + api.log + [ + ("fastapi_endpoint_id", "!=", False) + ] + tree,form + + Date: Tue, 22 Apr 2025 13:16:56 +0200 Subject: [PATCH 03/17] [ADD] fastapi_log_mail --- fastapi_log_mail/README.rst | 93 ++++ fastapi_log_mail/__init__.py | 1 + fastapi_log_mail/__manifest__.py | 22 + fastapi_log_mail/models/__init__.py | 4 + fastapi_log_mail/models/api_log.py | 16 + fastapi_log_mail/models/fastapi_endpoint.py | 15 + fastapi_log_mail/readme/CONFIGURE.md | 1 + fastapi_log_mail/readme/CONTRIBUTORS.md | 2 + fastapi_log_mail/readme/DESCRIPTION.md | 1 + .../static/description/index.html | 435 ++++++++++++++++++ fastapi_log_mail/tests/__init__.py | 1 + .../tests/test_fastapi_log_mail.py | 50 ++ .../views/fastapi_endpoint_views.xml | 24 + .../odoo/addons/fastapi_log_mail | 1 + setup/fastapi_log_mail/setup.py | 6 + 15 files changed, 672 insertions(+) create mode 100644 fastapi_log_mail/README.rst create mode 100644 fastapi_log_mail/__init__.py create mode 100644 fastapi_log_mail/__manifest__.py create mode 100644 fastapi_log_mail/models/__init__.py create mode 100644 fastapi_log_mail/models/api_log.py create mode 100644 fastapi_log_mail/models/fastapi_endpoint.py create mode 100644 fastapi_log_mail/readme/CONFIGURE.md create mode 100644 fastapi_log_mail/readme/CONTRIBUTORS.md create mode 100644 fastapi_log_mail/readme/DESCRIPTION.md create mode 100644 fastapi_log_mail/static/description/index.html create mode 100644 fastapi_log_mail/tests/__init__.py create mode 100644 fastapi_log_mail/tests/test_fastapi_log_mail.py create mode 100644 fastapi_log_mail/views/fastapi_endpoint_views.xml create mode 120000 setup/fastapi_log_mail/odoo/addons/fastapi_log_mail create mode 100644 setup/fastapi_log_mail/setup.py diff --git a/fastapi_log_mail/README.rst b/fastapi_log_mail/README.rst new file mode 100644 index 000000000..2f3303c13 --- /dev/null +++ b/fastapi_log_mail/README.rst @@ -0,0 +1,93 @@ +=========== +Fastapi Log +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi_log_mail + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi_log_mail + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to send an email when an exception occurs in an +endpoint. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +In any endpoint that has logging enabled, insert an email template in +"Error E-mail Template". + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* PyTech + +Contributors +------------ + +- `PyTech `__: + + - Simone Rubino + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-SirPyTech| image:: https://github.com/SirPyTech.png?size=40px + :target: https://github.com/SirPyTech + :alt: SirPyTech + +Current `maintainer `__: + +|maintainer-SirPyTech| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_log_mail/__init__.py b/fastapi_log_mail/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fastapi_log_mail/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_log_mail/__manifest__.py b/fastapi_log_mail/__manifest__.py new file mode 100644 index 000000000..a402dce47 --- /dev/null +++ b/fastapi_log_mail/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Log", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "PyTech, Odoo Community Association (OCA)", + "maintainers": [ + "SirPyTech", + ], + "website": "https://github.com/OCA/rest-framework", + "summary": "Email exceptions of Endpoints.", + "category": "Tools", + "depends": [ + "fastapi_log", + "mail", + ], + "data": [ + "views/fastapi_endpoint_views.xml", + ], +} diff --git a/fastapi_log_mail/models/__init__.py b/fastapi_log_mail/models/__init__.py new file mode 100644 index 000000000..89f5ea517 --- /dev/null +++ b/fastapi_log_mail/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import api_log +from . import fastapi_endpoint diff --git a/fastapi_log_mail/models/api_log.py b/fastapi_log_mail/models/api_log.py new file mode 100644 index 000000000..257bd642f --- /dev/null +++ b/fastapi_log_mail/models/api_log.py @@ -0,0 +1,16 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import models + + +class FastapiLog(models.Model): + _inherit = "api.log" + + def log_exception(self, exception): + res = super().log_exception(exception) + mail_template = self.fastapi_endpoint_id.fastapi_log_mail_template_id + if mail_template: + mail_template.sudo().send_mail(self.id) + return res diff --git a/fastapi_log_mail/models/fastapi_endpoint.py b/fastapi_log_mail/models/fastapi_endpoint.py new file mode 100644 index 000000000..0aef2c454 --- /dev/null +++ b/fastapi_log_mail/models/fastapi_endpoint.py @@ -0,0 +1,15 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + fastapi_log_mail_template_id = fields.Many2one( + comodel_name="mail.template", + domain=[("model_id.model", "=", "api.log")], + string="Error E-mail Template", + help="Select the email template that will be sent when an error is logged.", + ) diff --git a/fastapi_log_mail/readme/CONFIGURE.md b/fastapi_log_mail/readme/CONFIGURE.md new file mode 100644 index 000000000..fd221d770 --- /dev/null +++ b/fastapi_log_mail/readme/CONFIGURE.md @@ -0,0 +1 @@ +In any endpoint that has logging enabled, insert an email template in "Error E-mail Template". \ No newline at end of file diff --git a/fastapi_log_mail/readme/CONTRIBUTORS.md b/fastapi_log_mail/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..6e720b67d --- /dev/null +++ b/fastapi_log_mail/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> diff --git a/fastapi_log_mail/readme/DESCRIPTION.md b/fastapi_log_mail/readme/DESCRIPTION.md new file mode 100644 index 000000000..8eccf80b8 --- /dev/null +++ b/fastapi_log_mail/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to send an email when an exception occurs in an endpoint. diff --git a/fastapi_log_mail/static/description/index.html b/fastapi_log_mail/static/description/index.html new file mode 100644 index 000000000..1ec0ff159 --- /dev/null +++ b/fastapi_log_mail/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Fastapi Log + + + +
+

Fastapi Log

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module allows to send an email when an exception occurs in an +endpoint.

+

Table of contents

+ +
+

Configuration

+

In any endpoint that has logging enabled, insert an email template in +“Error E-mail Template”.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • PyTech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

SirPyTech

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fastapi_log_mail/tests/__init__.py b/fastapi_log_mail/tests/__init__.py new file mode 100644 index 000000000..0d3e465bc --- /dev/null +++ b/fastapi_log_mail/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_log_mail diff --git a/fastapi_log_mail/tests/test_fastapi_log_mail.py b/fastapi_log_mail/tests/test_fastapi_log_mail.py new file mode 100644 index 000000000..74cad8ba8 --- /dev/null +++ b/fastapi_log_mail/tests/test_fastapi_log_mail.py @@ -0,0 +1,50 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import os +import unittest + +from odoo.addons.api_log.tests.common import CommonAPILog +from odoo.addons.fastapi.schemas import DemoExceptionType +from odoo.addons.mail.tests.common import MailCase + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "FastAPIEncryptedErrorsCase skipped") +class FastAPIEncryptedErrorsCase(CommonAPILog, MailCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + + cls.fastapi_demo_app._handle_registry_sync() + cls.fastapi_demo_app.log_requests = True + cls.fastapi_demo_app.fastapi_log_mail_template_id = cls.env[ + "mail.template" + ].create( + { + "name": "Test exception email template", + "model_id": cls.env.ref("api_log.model_api_log").id, + } + ) + + def test_endpoint_exception_send_email(self): + """If an endpoint has an email template, + when an exception occurs an email is sent using the configured template. + """ + # Arrange + mail_template = self.fastapi_demo_app.fastapi_log_mail_template_id + route = ( + "/fastapi_demo/demo/exception?" + f"exception_type={DemoExceptionType.user_error.value}" + "&error_message=User Error" + ) + # pre-condition + self.assertTrue(mail_template) + + # Act + with self.mock_mail_gateway(): + self.url_open(route, timeout=200) + + # Assert + sent_email = self._filter_mail() + self.assertTrue(sent_email) diff --git a/fastapi_log_mail/views/fastapi_endpoint_views.xml b/fastapi_log_mail/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..fdd6deaaa --- /dev/null +++ b/fastapi_log_mail/views/fastapi_endpoint_views.xml @@ -0,0 +1,24 @@ + + + + + fastapi.endpoint + + + + + + + + diff --git a/setup/fastapi_log_mail/odoo/addons/fastapi_log_mail b/setup/fastapi_log_mail/odoo/addons/fastapi_log_mail new file mode 120000 index 000000000..0708fcac1 --- /dev/null +++ b/setup/fastapi_log_mail/odoo/addons/fastapi_log_mail @@ -0,0 +1 @@ +../../../../fastapi_log_mail \ No newline at end of file diff --git a/setup/fastapi_log_mail/setup.py b/setup/fastapi_log_mail/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_log_mail/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 2394468786efad93b56fd18e891559d82705a94a Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Mon, 28 Apr 2025 12:00:02 +0200 Subject: [PATCH 04/17] [FIX] fastapi_log: Manage multi-slash endpoints --- fastapi_log/fastapi_dispatcher.py | 3 +-- fastapi_log/tests/test_fastapi_log.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py index 6a762cd51..a870ed407 100644 --- a/fastapi_log/fastapi_dispatcher.py +++ b/fastapi_log/fastapi_dispatcher.py @@ -21,11 +21,10 @@ class FastApiDispatcher(_dispatchers.get("fastapi", BaseFastApiDispatcher)): def dispatch(self, endpoint, args): self.request.params = {} environ = self._get_environ() - root_path = "/" + environ["PATH_INFO"].split("/")[1] fastapi_endpoint = ( self.request.env["fastapi.endpoint"] .sudo() - .search([("root_path", "=", root_path)]) + ._get_endpoint(environ["PATH_INFO"]) ) if fastapi_endpoint.log_requests: if tools.config["test_enable"]: diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index c862669cd..e75333dc5 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -21,6 +21,7 @@ class FastAPIEncryptedErrorsCase(CommonAPILog): def setUpClass(cls): super().setUpClass() cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.fastapi_demo_app.root_path += "/test" cls.fastapi_demo_app._handle_registry_sync() cls.fastapi_demo_app.write({"log_requests": True}) lang = ( @@ -57,19 +58,19 @@ def test_no_log_if_disabled(self): self.fastapi_demo_app.write({"log_requests": False}) with self.log_capturer() as capturer: - response = self.url_open("/fastapi_demo/demo", timeout=200) + response = self.url_open("/fastapi_demo/test/demo", timeout=200) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(capturer.records) def test_log_simple(self): with self.log_capturer() as capturer: - response = self.url_open("/fastapi_demo/demo", timeout=200) + response = self.url_open("/fastapi_demo/test/demo", timeout=200) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(capturer.records), 1) log = capturer.records[0] - self.assertTrue(log.request_url.endswith("/fastapi_demo/demo")) + self.assertTrue(log.request_url.endswith("/fastapi_demo/test/demo")) self.assertEqual(log.request_method, "GET") self.assertEqual(log.response_status_code, 200) self.assertTrue(log.time > 0) @@ -77,7 +78,7 @@ def test_log_simple(self): def test_log_exception(self): with self.log_capturer() as capturer: route = ( - "/fastapi_demo/demo/exception?" + "/fastapi_demo/test/demo/exception?" f"exception_type={DemoExceptionType.user_error.value}" "&error_message=User Error" ) @@ -86,7 +87,7 @@ def test_log_exception(self): self.assertEqual(len(capturer.records), 1) log = capturer.records[0] - self.assertIn("/fastapi_demo/demo/exception", log.request_url) + self.assertIn("/fastapi_demo/test/demo/exception", log.request_url) self.assertEqual(log.request_method, "GET") self.assertEqual(log.response_status_code, 400) self.assertTrue(log.time > 0) @@ -97,7 +98,7 @@ def test_log_exception(self): def test_log_bare_exception(self): with self.log_capturer() as capturer: route = ( - "/fastapi_demo/demo/exception?" + "/fastapi_demo/test/demo/exception?" f"exception_type={DemoExceptionType.bare_exception.value}" "&error_message=Internal Server Error" ) @@ -108,7 +109,7 @@ def test_log_bare_exception(self): self.assertEqual(len(capturer.records), 1) log = capturer.records[0] - self.assertIn("/fastapi_demo/demo/exception", log.request_url) + self.assertIn("/fastapi_demo/test/demo/exception", log.request_url) self.assertEqual(log.request_method, "GET") self.assertEqual(log.response_status_code, 500) self.assertTrue(log.time > 0) @@ -119,7 +120,7 @@ def test_log_bare_exception(self): def test_log_retrying_post(self): with self.log_capturer() as capturer: nbr_retries = 2 - route = f"/fastapi_demo/demo/retrying?nbr_retries={nbr_retries}" + route = f"/fastapi_demo/test/demo/retrying?nbr_retries={nbr_retries}" response = self.url_open( route, timeout=20, files={"file": ("test.txt", b"test")} ) @@ -130,7 +131,7 @@ def test_log_retrying_post(self): self.assertEqual(len(capturer.records), 3) for log in capturer.records[1:]: - self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertIn("/fastapi_demo/test/demo/retrying", log.request_url) self.assertEqual(log.request_method, "POST") self.assertEqual(log.response_status_code, 500) self.assertTrue(log.time > 0) @@ -142,7 +143,7 @@ def test_log_retrying_post(self): ) log = capturer.records[0] - self.assertIn("/fastapi_demo/demo/retrying", log.request_url) + self.assertIn("/fastapi_demo/test/demo/retrying", log.request_url) self.assertEqual(log.request_method, "POST") self.assertEqual(log.response_status_code, 200) self.assertTrue(log.time > 0) From 0c154fa56e0dcb71871f8aef69bd1f0ec42fe83a Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Mon, 28 Apr 2025 12:42:53 +0200 Subject: [PATCH 05/17] [IMP] api_log: Hide sensitive headers --- api_log/models/api_log.py | 23 ++++++++++++++++++++++- api_log/tests/test_api_log.py | 12 ++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/api_log/models/api_log.py b/api_log/models/api_log.py index c2cb5bf0d..b1d9dbb5f 100644 --- a/api_log/models/api_log.py +++ b/api_log/models/api_log.py @@ -62,8 +62,29 @@ class APILog(models.Model): compute="_compute_response_headers_derived", store=True ) + @api.model + def _headers_hidden_keys(self): + """Header keys that should not be logged. + + They might contains sensitive data. + """ + return ( + "Api-Key", + "Cookie", + ) + + @api.model + def _sanitize_headers_dict(self, headers_dict): + keys_to_hide = self._headers_hidden_keys() + for key in headers_dict: + if key in keys_to_hide: + headers_dict[key] = "" + return headers_dict + + @api.model def _headers_to_dict(self, headers): - return {key.lower(): value for key, value in headers.items()} + headers_dict = {key: value for key, value in headers.items()} + return self._sanitize_headers_dict(headers_dict) def _current_time(self): return time.time_ns() / 1e9 diff --git a/api_log/tests/test_api_log.py b/api_log/tests/test_api_log.py index abed32678..52035a4c9 100644 --- a/api_log/tests/test_api_log.py +++ b/api_log/tests/test_api_log.py @@ -12,7 +12,15 @@ class TestAPILog(CommonAPILog): def test_log_request(self): base_url = self.base_url() + secret_api_key = "my-secret-api-key" + secret_cookie = "my-secret-biscuit" + public_header_value = "public_header_value" httprequest = requests.Request( + headers={ + "Api-Key": secret_api_key, + "Cookie": secret_cookie, + "Public-Header": public_header_value, + }, url=base_url, method="GET", ) @@ -21,6 +29,10 @@ def test_log_request(self): self.assertEqual(log.request_url, base_url) self.assertEqual(log.request_method, "GET") + headers_values = log.request_headers.values() + self.assertNotIn(secret_api_key, headers_values) + self.assertNotIn(secret_cookie, headers_values) + self.assertIn(public_header_value, headers_values) def test_log_response(self): response = Response() From 4d10c51730c7245d95bd9c2556e0ae1e08c99512 Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Wed, 25 Jun 2025 11:42:48 +0200 Subject: [PATCH 06/17] [IMP] api_log: Add collection of logs --- api_log/models/__init__.py | 1 + api_log/models/api_log.py | 34 +++++++++++++++++ api_log/models/api_log_collection.py | 55 +++++++++++++++++++++++++++ api_log/tests/common.py | 5 ++- api_log/tests/test_api_log.py | 5 ++- api_log/views/api_log_views.xml | 8 ++++ fastapi_log/tests/test_fastapi_log.py | 16 ++++++++ 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 api_log/models/api_log_collection.py diff --git a/api_log/models/__init__.py b/api_log/models/__init__.py index 0f340289c..2f4388e55 100644 --- a/api_log/models/__init__.py +++ b/api_log/models/__init__.py @@ -1 +1,2 @@ +from . import api_log_collection from . import api_log diff --git a/api_log/models/api_log.py b/api_log/models/api_log.py index b1d9dbb5f..71206ea8f 100644 --- a/api_log/models/api_log.py +++ b/api_log/models/api_log.py @@ -1,5 +1,6 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import base64 @@ -17,6 +18,20 @@ class APILog(models.Model): _description = "Log for API" _order = "id desc" + collection_ref = fields.Reference( + selection="_selection_collection_ref", + ) + collection_model = fields.Char( + compute="_compute_collection", + store=True, + index=True, + ) + collection_id = fields.Integer( + compute="_compute_collection", + store=True, + index=True, + ) + # Request request_url = fields.Char() request_method = fields.Char() @@ -62,6 +77,25 @@ class APILog(models.Model): compute="_compute_response_headers_derived", store=True ) + @api.model + def _selection_collection_ref(self): + return [] + + @api.depends( + "collection_ref", + ) + def _compute_collection(self): + for log in self: + collection = log.collection_ref + if collection: + collection_model = collection._name + collection_id = collection.id + else: + collection_model = False + collection_id = False + log.collection_model = collection_model + log.collection_id = collection_id + @api.model def _headers_hidden_keys(self): """Header keys that should not be logged. diff --git a/api_log/models/api_log_collection.py b/api_log/models/api_log_collection.py new file mode 100644 index 000000000..a094b6e0e --- /dev/null +++ b/api_log/models/api_log_collection.py @@ -0,0 +1,55 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class APILogCollection(models.AbstractModel): + _name = "api.log_collection.mixin" + _description = "Collection of API logs" + + log_requests = fields.Boolean( + help="Log requests in database.", + ) + + log_ids = fields.One2many( + comodel_name="api.log", + compute="_compute_log_ids", + string="Logs", + ) + + def _get_logs_domain(self): + """Domain to find the logs in `self`.""" + return [ + ("collection_model", "=", self._name), + ("collection_id", "in", self.ids), + ] + + def _compute_log_ids(self): + all_logs = self.env["api.log"].search_read( + domain=self._get_logs_domain(), + fields=[ + "collection_id", + ], + load=None, + ) + log_ids_by_collection_id = {} + for log in all_logs: + log_ids_by_collection_id.setdefault(log["collection_id"], []).append( + log["id"] + ) + + for collection in self: + collection.log_ids = log_ids_by_collection_id.get(collection.id) + + def action_logs(self): + return { + "type": "ir.actions.act_window", + "res_model": "api.log", + "name": "Logs", + "view_type": "form", + "view_mode": "tree,form", + "target": "current", + "domain": self._get_logs_domain(), + "context": dict(self.env.context), + } diff --git a/api_log/tests/common.py b/api_log/tests/common.py index e54316db6..e02138286 100644 --- a/api_log/tests/common.py +++ b/api_log/tests/common.py @@ -1,7 +1,10 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + from odoo.tests.common import HttpCase -class CommonAPILog(HttpCase): +class Common(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() diff --git a/api_log/tests/test_api_log.py b/api_log/tests/test_api_log.py index 52035a4c9..3a3868231 100644 --- a/api_log/tests/test_api_log.py +++ b/api_log/tests/test_api_log.py @@ -1,15 +1,16 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import requests from odoo.http import Request, Response -from odoo.addons.api_log.tests.common import CommonAPILog +from odoo.addons.api_log.tests.common import Common -class TestAPILog(CommonAPILog): +class TestAPILog(Common): def test_log_request(self): base_url = self.base_url() secret_api_key = "my-secret-api-key" diff --git a/api_log/views/api_log_views.xml b/api_log/views/api_log_views.xml index cc09bbdaf..4e8ba689f 100644 --- a/api_log/views/api_log_views.xml +++ b/api_log/views/api_log_views.xml @@ -45,6 +45,7 @@ + api.log + @@ -78,6 +80,7 @@ + + Date: Wed, 25 Jun 2025 11:44:12 +0200 Subject: [PATCH 07/17] [ADD] api_log_mail: Notify user about logged exceptions --- api_log_mail/README.rst | 93 +++++ api_log_mail/__init__.py | 1 + api_log_mail/__manifest__.py | 19 + api_log_mail/models/__init__.py | 4 + api_log_mail/models/api_log.py | 41 ++ api_log_mail/models/api_log_collection.py | 21 + api_log_mail/readme/CONFIGURE.md | 1 + api_log_mail/readme/CONTRIBUTORS.md | 2 + api_log_mail/readme/DESCRIPTION.md | 1 + api_log_mail/static/description/index.html | 435 ++++++++++++++++++++ setup/api_log_mail/odoo/addons/api_log_mail | 1 + setup/api_log_mail/setup.py | 6 + 12 files changed, 625 insertions(+) create mode 100644 api_log_mail/README.rst create mode 100644 api_log_mail/__init__.py create mode 100644 api_log_mail/__manifest__.py create mode 100644 api_log_mail/models/__init__.py create mode 100644 api_log_mail/models/api_log.py create mode 100644 api_log_mail/models/api_log_collection.py create mode 100644 api_log_mail/readme/CONFIGURE.md create mode 100644 api_log_mail/readme/CONTRIBUTORS.md create mode 100644 api_log_mail/readme/DESCRIPTION.md create mode 100644 api_log_mail/static/description/index.html create mode 120000 setup/api_log_mail/odoo/addons/api_log_mail create mode 100644 setup/api_log_mail/setup.py diff --git a/api_log_mail/README.rst b/api_log_mail/README.rst new file mode 100644 index 000000000..57e228500 --- /dev/null +++ b/api_log_mail/README.rst @@ -0,0 +1,93 @@ +==================== +API Log notification +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/api_log_mail + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-api_log_mail + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to create an activity when an exception is logged in +an API logs collection. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +In any log collection that has logging enabled, insert an activity type +in "Error Activity type". + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* PyTech + +Contributors +------------ + +- `PyTech `__: + + - Simone Rubino + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-SirPyTech| image:: https://github.com/SirPyTech.png?size=40px + :target: https://github.com/SirPyTech + :alt: SirPyTech + +Current `maintainer `__: + +|maintainer-SirPyTech| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/api_log_mail/__init__.py b/api_log_mail/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/api_log_mail/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/api_log_mail/__manifest__.py b/api_log_mail/__manifest__.py new file mode 100644 index 000000000..d334b7f9c --- /dev/null +++ b/api_log_mail/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "API Log notification", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "PyTech, Odoo Community Association (OCA)", + "maintainers": [ + "SirPyTech", + ], + "website": "https://github.com/OCA/rest-framework", + "summary": "Notify logged exceptions.", + "category": "Tools", + "depends": [ + "api_log", + "mail", + ], +} diff --git a/api_log_mail/models/__init__.py b/api_log_mail/models/__init__.py new file mode 100644 index 000000000..13ae7379a --- /dev/null +++ b/api_log_mail/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import api_log_collection +from . import api_log diff --git a/api_log_mail/models/api_log.py b/api_log_mail/models/api_log.py new file mode 100644 index 000000000..36ca3ec90 --- /dev/null +++ b/api_log_mail/models/api_log.py @@ -0,0 +1,41 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class APILog(models.Model): + _name = "api.log" + _inherit = [ + "api.log", + "mail.activity.mixin", + # mail.thread is needed + # because message_subscribe is called + # during activity creation + "mail.thread", + ] + _mail_post_access = "read" # Access required to open an activity + + @api.model + def log_request(self, request, override_log_values=None): + return super( + APILog, + self.with_context(tracking_disable=True), + ).log_request(request, override_log_values=override_log_values) + + def _notify_api_log_exception(self): + if collection := self.collection_ref: + activity_type = collection.api_log_mail_exception_activity_type_id + if activity_type: + self.sudo().activity_schedule( + activity_type_id=activity_type.id, + ) + + mail_template = collection.api_log_mail_exception_template_id + if mail_template: + mail_template.sudo().send_mail(self.id) + + def log_exception(self, exception): + res = super().log_exception(exception) + self._notify_api_log_exception() + return res diff --git a/api_log_mail/models/api_log_collection.py b/api_log_mail/models/api_log_collection.py new file mode 100644 index 000000000..cf7a87336 --- /dev/null +++ b/api_log_mail/models/api_log_collection.py @@ -0,0 +1,21 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class APILogCollection(models.AbstractModel): + _inherit = "api.log_collection.mixin" + + api_log_mail_exception_template_id = fields.Many2one( + comodel_name="mail.template", + domain=[("model_id.model", "=", "api.log")], + string="Error E-mail Template", + help="An email based on this template will be sent when an error is logged.", + ) + api_log_mail_exception_activity_type_id = fields.Many2one( + comodel_name="mail.activity.type", + domain=[("res_model", "=", "api.log")], + string="Error Activity type", + help="An activity of this type will be created when an error is logged.", + ) diff --git a/api_log_mail/readme/CONFIGURE.md b/api_log_mail/readme/CONFIGURE.md new file mode 100644 index 000000000..8c16db49d --- /dev/null +++ b/api_log_mail/readme/CONFIGURE.md @@ -0,0 +1 @@ +In any log collection that has logging enabled, insert an activity type in "Error Activity type". diff --git a/api_log_mail/readme/CONTRIBUTORS.md b/api_log_mail/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..6e720b67d --- /dev/null +++ b/api_log_mail/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> diff --git a/api_log_mail/readme/DESCRIPTION.md b/api_log_mail/readme/DESCRIPTION.md new file mode 100644 index 000000000..f1207db61 --- /dev/null +++ b/api_log_mail/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows to create an activity when an exception is logged in an API logs collection. diff --git a/api_log_mail/static/description/index.html b/api_log_mail/static/description/index.html new file mode 100644 index 000000000..2d5a03422 --- /dev/null +++ b/api_log_mail/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +API Log notification + + + +
+

API Log notification

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module allows to create an activity when an exception is logged in +an API logs collection.

+

Table of contents

+ +
+

Configuration

+

In any log collection that has logging enabled, insert an activity type +in “Error Activity type”.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • PyTech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

SirPyTech

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/setup/api_log_mail/odoo/addons/api_log_mail b/setup/api_log_mail/odoo/addons/api_log_mail new file mode 120000 index 000000000..987cf27bc --- /dev/null +++ b/setup/api_log_mail/odoo/addons/api_log_mail @@ -0,0 +1 @@ +../../../../api_log_mail \ No newline at end of file diff --git a/setup/api_log_mail/setup.py b/setup/api_log_mail/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/api_log_mail/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From ce3931f71cd9c05ae010745ba36610ccbae4d57e Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Wed, 25 Jun 2025 11:45:23 +0200 Subject: [PATCH 08/17] [IMP] fastapi_log: Adapt to log collection --- fastapi_log/README.rst | 3 ++ fastapi_log/__manifest__.py | 2 +- fastapi_log/fastapi_dispatcher.py | 24 +++------ .../migrations/16.0.1.1.0/post-migration.py | 32 ++++++++++++ .../migrations/16.0.1.1.0/pre-migration.py | 20 ++++++++ fastapi_log/models/api_log.py | 23 ++++++--- fastapi_log/models/fastapi_endpoint.py | 37 +++----------- fastapi_log/readme/CONTRIBUTORS.md | 2 + fastapi_log/static/description/index.html | 4 ++ fastapi_log/tests/common.py | 35 +++++++++++++ fastapi_log/tests/test_fastapi_log.py | 50 ++----------------- fastapi_log/views/fastapi_endpoint_views.xml | 25 +++++----- fastapi_log/views/fastapi_log_views.xml | 43 +--------------- 13 files changed, 146 insertions(+), 154 deletions(-) create mode 100644 fastapi_log/migrations/16.0.1.1.0/post-migration.py create mode 100644 fastapi_log/migrations/16.0.1.1.0/pre-migration.py create mode 100644 fastapi_log/tests/common.py diff --git a/fastapi_log/README.rst b/fastapi_log/README.rst index 96624a991..fdb54e937 100644 --- a/fastapi_log/README.rst +++ b/fastapi_log/README.rst @@ -72,6 +72,9 @@ Contributors ------------ - Florian Mounier florian.mounier@akretion.com +- `PyTech `__: + + - Simone Rubino Maintainers ----------- diff --git a/fastapi_log/__manifest__.py b/fastapi_log/__manifest__.py index 8334dc5c2..27938d00e 100644 --- a/fastapi_log/__manifest__.py +++ b/fastapi_log/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Fastapi Log", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "author": "Akretion, Odoo Community Association (OCA)", "summary": "Log Fastapi requests in database", "category": "Tools", diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py index a870ed407..ba5071083 100644 --- a/fastapi_log/fastapi_dispatcher.py +++ b/fastapi_log/fastapi_dispatcher.py @@ -1,9 +1,10 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + import logging -from odoo import registry, tools from odoo.http import _dispatchers from odoo.addons.fastapi.fastapi_dispatcher import ( @@ -27,16 +28,8 @@ def dispatch(self, endpoint, args): ._get_endpoint(environ["PATH_INFO"]) ) if fastapi_endpoint.log_requests: - if tools.config["test_enable"]: - cr = getattr( - self.request.env.registry, "test_log_cr", self.request.env.cr - ) - else: - # Create an independent cursor - cr = registry(self.request.env.cr.dbname).cursor() - - env = self.request.env(cr=cr, su=True) request = self.request + env = request.env(su=True) try: log = env["api.log"].log_request(request) except Exception as e: @@ -50,18 +43,17 @@ def dispatch(self, endpoint, args): log and log.log_exception(response_exc) except Exception as e: _logger.warning("Failed to log exception", exc_info=e) + else: + # Be sure to commit/save the exception's log + env.cr.commit() + raise response_exc else: try: log and log.log_response(response) except Exception as e: _logger.warning("Failed to log response", exc_info=e) - finally: - if not tools.config["test_enable"]: - try: - cr.commit() # pylint: disable=E8102 - finally: - cr.close() + return response else: return super().dispatch(endpoint, args) diff --git a/fastapi_log/migrations/16.0.1.1.0/post-migration.py b/fastapi_log/migrations/16.0.1.1.0/post-migration.py new file mode 100644 index 000000000..b95831de3 --- /dev/null +++ b/fastapi_log/migrations/16.0.1.1.0/post-migration.py @@ -0,0 +1,32 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + endpoint_id_column = openupgrade.get_legacy_name("fastapi_endpoint_id") + openupgrade.logged_query( + env.cr, + """ + UPDATE api_log SET + collection_id=%(endpoint_id_column)s, + collection_model='fastapi.endpoint', + collection_ref='fastapi.endpoint,' || %(endpoint_id_column)s + WHERE %(endpoint_id_column)s IS NOT NULL + """ + % { + "endpoint_id_column": endpoint_id_column, + }, + ) + openupgrade.logged_query( + env.cr, + """ + ALTER TABLE api_log + DROP COLUMN %(endpoint_id_column)s + """ + % { + "endpoint_id_column": endpoint_id_column, + }, + ) diff --git a/fastapi_log/migrations/16.0.1.1.0/pre-migration.py b/fastapi_log/migrations/16.0.1.1.0/pre-migration.py new file mode 100644 index 000000000..9cdaa9143 --- /dev/null +++ b/fastapi_log/migrations/16.0.1.1.0/pre-migration.py @@ -0,0 +1,20 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.copy_columns( + env.cr, + { + "api_log": [ + ( + "fastapi_endpoint_id", + None, + None, + ), + ], + }, + ) diff --git a/fastapi_log/models/api_log.py b/fastapi_log/models/api_log.py index ee28db599..086e656b4 100644 --- a/fastapi_log/models/api_log.py +++ b/fastapi_log/models/api_log.py @@ -1,21 +1,24 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from starlette.exceptions import HTTPException as StarletteHTTPException -from odoo import api, fields, models +from odoo import api, models class FastapiLog(models.Model): _inherit = "api.log" - fastapi_endpoint_id = fields.Many2one( - comodel_name="fastapi.endpoint", - string="Endpoint", - ondelete="cascade", - index=True, - ) + @api.model + def _selection_collection_ref(self): + collections = super()._selection_collection_ref() + fastapi_endpoint_model = self.env["fastapi.endpoint"] + collections.append( + (fastapi_endpoint_model._name, fastapi_endpoint_model._description) + ) + return collections @api.model def _get_request_body(self, request): @@ -42,7 +45,11 @@ def _prepare_log_request(self, request): .sudo() ._get_endpoint(environ["PATH_INFO"]) ) - log_request_values["fastapi_endpoint_id"] = endpoint.id + log_request_values["collection_ref"] = "%s,%s" % ( + endpoint._name, + endpoint.id, + ) + return log_request_values def _prepare_log_exception(self, exception): diff --git a/fastapi_log/models/fastapi_endpoint.py b/fastapi_log/models/fastapi_endpoint.py index 62789fc4c..4770dcf24 100644 --- a/fastapi_log/models/fastapi_endpoint.py +++ b/fastapi_log/models/fastapi_endpoint.py @@ -1,37 +1,14 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +from odoo import models class FastapiEndpoint(models.Model): - _inherit = "fastapi.endpoint" - - log_requests = fields.Boolean( - help="Log requests in database.", - ) - - fastapi_log_ids = fields.One2many( - comodel_name="api.log", - inverse_name="fastapi_endpoint_id", - string="Logs", - ) - - fastapi_log_count = fields.Integer( - compute="_compute_fastapi_log_count", - string="Logs Count", - ) - - @api.depends("fastapi_log_ids") - def _compute_fastapi_log_count(self): - groups = self.env["api.log"].read_group( - [("fastapi_endpoint_id", "in", self.ids)], - ["fastapi_endpoint_id"], - ["fastapi_endpoint_id"], - ) - mapped_data = { - g["fastapi_endpoint_id"][0]: g["fastapi_endpoint_id_count"] for g in groups - } - for endpoint in self: - endpoint.fastapi_log_count = mapped_data.get(endpoint.id, 0) + _name = "fastapi.endpoint" + _inherit = [ + "api.log_collection.mixin", + "fastapi.endpoint", + ] diff --git a/fastapi_log/readme/CONTRIBUTORS.md b/fastapi_log/readme/CONTRIBUTORS.md index 328a37da8..1e935bfb5 100644 --- a/fastapi_log/readme/CONTRIBUTORS.md +++ b/fastapi_log/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ - Florian Mounier +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> diff --git a/fastapi_log/static/description/index.html b/fastapi_log/static/description/index.html index 0a76e9f5d..b0b206a30 100644 --- a/fastapi_log/static/description/index.html +++ b/fastapi_log/static/description/index.html @@ -416,6 +416,10 @@

Authors

Contributors

diff --git a/fastapi_log/tests/common.py b/fastapi_log/tests/common.py new file mode 100644 index 000000000..186c8542e --- /dev/null +++ b/fastapi_log/tests/common.py @@ -0,0 +1,35 @@ +# Copyright 2025 Akretion (http://www.akretion.com). +# @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager + +from odoo.tests.common import RecordCapturer + +from odoo.addons.api_log.tests.common import Common as CommonAPILog + + +class Common(CommonAPILog): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") + cls.fastapi_demo_app.root_path += "/test" + cls.fastapi_demo_app._handle_registry_sync() + cls.fastapi_demo_app.write({"log_requests": True}) + lang = ( + cls.env["res.lang"] + .with_context(active_test=False) + .search([("code", "=", "fr_BE")]) + ) + lang.active = True + + @contextmanager + def log_capturer(self): + app = self.fastapi_demo_app + with RecordCapturer( + self.env[self.log_model._name], + [("collection_ref", "=", "%s,%s" % (app._name, app.id))], + ) as capturer: + yield capturer diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index 7a72b4637..bcf7feb46 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -1,59 +1,19 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + import os -import threading import unittest -from contextlib import contextmanager - -from odoo.sql_db import TestCursor -from odoo.tests.common import RecordCapturer -from odoo.addons.api_log.tests.common import CommonAPILog from odoo.addons.fastapi.schemas import DemoExceptionType +from odoo.addons.fastapi_log.tests.common import Common from fastapi import status -@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "FastAPIEncryptedErrorsCase skipped") -class FastAPIEncryptedErrorsCase(CommonAPILog): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") - cls.fastapi_demo_app.root_path += "/test" - cls.fastapi_demo_app._handle_registry_sync() - cls.fastapi_demo_app.write({"log_requests": True}) - lang = ( - cls.env["res.lang"] - .with_context(active_test=False) - .search([("code", "=", "fr_BE")]) - ) - lang.active = True - - def setUp(self): - super().setUp() - # Use a side test cursor to be able to get exception logs - reg = self.env.registry - reg.test_log_lock = threading.RLock() - reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock) - - def tearDown(self): - reg = self.env.registry - reg.test_log_cr.rollback() - reg.test_log_cr.close() - reg.test_log_cr = None - reg.test_log_lock = None - super().tearDown() - - @contextmanager - def log_capturer(self): - with RecordCapturer( - self.env(cr=self.env.registry.test_log_cr)[self.log_model._name], - [("fastapi_endpoint_id", "=", self.fastapi_demo_app.id)], - ) as capturer: - yield capturer - +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "TestFastapiLog skipped") +class TestFastapiLog(Common): def test_no_log_if_disabled(self): self.fastapi_demo_app.write({"log_requests": False}) diff --git a/fastapi_log/views/fastapi_endpoint_views.xml b/fastapi_log/views/fastapi_endpoint_views.xml index bf90e7ea1..ac50560e8 100644 --- a/fastapi_log/views/fastapi_endpoint_views.xml +++ b/fastapi_log/views/fastapi_endpoint_views.xml @@ -2,33 +2,32 @@ - - Fastapi Log - api.log - tree,form - [('fastapi_endpoint_id', '=', active_id)] - {'default_fastapi_endpoint_id': active_id} - - - fastapi.endpoint
+
diff --git a/fastapi_log/views/fastapi_log_views.xml b/fastapi_log/views/fastapi_log_views.xml index 1f33e422f..3c19ba737 100644 --- a/fastapi_log/views/fastapi_log_views.xml +++ b/fastapi_log/views/fastapi_log_views.xml @@ -2,54 +2,15 @@ - - Add Fastapi fields to API log form view - api.log - - - - - - - - - - Add Fastapi fields to API log tree view - api.log - - - - - - - - - - Add Fastapi fields to API log search view - api.log - - - - - - - - - - - Fastapi Logs api.log [ - ("fastapi_endpoint_id", "!=", False) + ("collection_model", "=", "fastapi.endpoint"), ] tree,form From 1ecfa12770d55f69f1102a1b84f99eb21f0d0521 Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Wed, 25 Jun 2025 11:46:38 +0200 Subject: [PATCH 09/17] [IMP] fastapi_log_mail: Adapt to log collection --- fastapi_log_mail/README.rst | 13 ++--- fastapi_log_mail/__init__.py | 1 - fastapi_log_mail/__manifest__.py | 8 +-- .../migrations/16.0.1.1.0/post-migration.py | 32 +++++++++++ .../migrations/16.0.1.1.0/pre-migration.py | 20 +++++++ fastapi_log_mail/models/__init__.py | 4 -- fastapi_log_mail/models/api_log.py | 16 ------ fastapi_log_mail/models/fastapi_endpoint.py | 15 ----- fastapi_log_mail/readme/CONFIGURE.md | 2 +- fastapi_log_mail/readme/DESCRIPTION.md | 2 +- .../static/description/index.html | 13 ++--- .../tests/test_fastapi_log_mail.py | 56 +++++++++++++++---- .../views/fastapi_endpoint_views.xml | 12 +++- 13 files changed, 126 insertions(+), 68 deletions(-) create mode 100644 fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py create mode 100644 fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py delete mode 100644 fastapi_log_mail/models/__init__.py delete mode 100644 fastapi_log_mail/models/api_log.py delete mode 100644 fastapi_log_mail/models/fastapi_endpoint.py diff --git a/fastapi_log_mail/README.rst b/fastapi_log_mail/README.rst index 2f3303c13..af99233ca 100644 --- a/fastapi_log_mail/README.rst +++ b/fastapi_log_mail/README.rst @@ -1,6 +1,6 @@ -=========== -Fastapi Log -=========== +======================== +FastAPI Log notification +======================== .. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -28,8 +28,8 @@ Fastapi Log |badge1| |badge2| |badge3| |badge4| |badge5| -This module allows to send an email when an exception occurs in an -endpoint. +This module allows to create an activity when an exception is logged in +a fastapi endpoint. **Table of contents** @@ -39,8 +39,7 @@ endpoint. Configuration ============= -In any endpoint that has logging enabled, insert an email template in -"Error E-mail Template". +Configure a fastapi endpoint as explained in ``api_log_mail``. Bug Tracker =========== diff --git a/fastapi_log_mail/__init__.py b/fastapi_log_mail/__init__.py index 0650744f6..e69de29bb 100644 --- a/fastapi_log_mail/__init__.py +++ b/fastapi_log_mail/__init__.py @@ -1 +0,0 @@ -from . import models diff --git a/fastapi_log_mail/__manifest__.py b/fastapi_log_mail/__manifest__.py index a402dce47..8bcbc0621 100644 --- a/fastapi_log_mail/__manifest__.py +++ b/fastapi_log_mail/__manifest__.py @@ -2,19 +2,19 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). { - "name": "Fastapi Log", - "version": "16.0.1.0.0", + "name": "FastAPI Log notification", + "version": "16.0.1.1.0", "license": "AGPL-3", "author": "PyTech, Odoo Community Association (OCA)", "maintainers": [ "SirPyTech", ], "website": "https://github.com/OCA/rest-framework", - "summary": "Email exceptions of Endpoints.", + "summary": "Notify logged exceptions.", "category": "Tools", "depends": [ "fastapi_log", - "mail", + "api_log_mail", ], "data": [ "views/fastapi_endpoint_views.xml", diff --git a/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py b/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py new file mode 100644 index 000000000..1903d6e19 --- /dev/null +++ b/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py @@ -0,0 +1,32 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + template_id_column = openupgrade.get_legacy_name( + "fastapi_log_mail_template_id", + ) + openupgrade.logged_query( + env.cr, + """ + UPDATE fastapi_endpoint SET + api_log_mail_exception_template_id=%(template_id_column)s + WHERE %(template_id_column)s IS NOT NULL + """ + % { + "template_id_column": template_id_column, + }, + ) + openupgrade.logged_query( + env.cr, + """ + ALTER TABLE fastapi_endpoint + DROP COLUMN %(template_id_column)s + """ + % { + "template_id_column": template_id_column, + }, + ) diff --git a/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py b/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py new file mode 100644 index 000000000..71f919a98 --- /dev/null +++ b/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py @@ -0,0 +1,20 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.copy_columns( + env.cr, + { + "fastapi_endpoint": [ + ( + "fastapi_log_mail_template_id", + None, + None, + ), + ], + }, + ) diff --git a/fastapi_log_mail/models/__init__.py b/fastapi_log_mail/models/__init__.py deleted file mode 100644 index 89f5ea517..000000000 --- a/fastapi_log_mail/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from . import api_log -from . import fastapi_endpoint diff --git a/fastapi_log_mail/models/api_log.py b/fastapi_log_mail/models/api_log.py deleted file mode 100644 index 257bd642f..000000000 --- a/fastapi_log_mail/models/api_log.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - - -from odoo import models - - -class FastapiLog(models.Model): - _inherit = "api.log" - - def log_exception(self, exception): - res = super().log_exception(exception) - mail_template = self.fastapi_endpoint_id.fastapi_log_mail_template_id - if mail_template: - mail_template.sudo().send_mail(self.id) - return res diff --git a/fastapi_log_mail/models/fastapi_endpoint.py b/fastapi_log_mail/models/fastapi_endpoint.py deleted file mode 100644 index 0aef2c454..000000000 --- a/fastapi_log_mail/models/fastapi_endpoint.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo import fields, models - - -class FastapiEndpoint(models.Model): - _inherit = "fastapi.endpoint" - - fastapi_log_mail_template_id = fields.Many2one( - comodel_name="mail.template", - domain=[("model_id.model", "=", "api.log")], - string="Error E-mail Template", - help="Select the email template that will be sent when an error is logged.", - ) diff --git a/fastapi_log_mail/readme/CONFIGURE.md b/fastapi_log_mail/readme/CONFIGURE.md index fd221d770..ca1622a8b 100644 --- a/fastapi_log_mail/readme/CONFIGURE.md +++ b/fastapi_log_mail/readme/CONFIGURE.md @@ -1 +1 @@ -In any endpoint that has logging enabled, insert an email template in "Error E-mail Template". \ No newline at end of file +Configure a fastapi endpoint as explained in `api_log_mail`. diff --git a/fastapi_log_mail/readme/DESCRIPTION.md b/fastapi_log_mail/readme/DESCRIPTION.md index 8eccf80b8..e92d7f261 100644 --- a/fastapi_log_mail/readme/DESCRIPTION.md +++ b/fastapi_log_mail/readme/DESCRIPTION.md @@ -1 +1 @@ -This module allows to send an email when an exception occurs in an endpoint. +This module allows to create an activity when an exception is logged in a fastapi endpoint. diff --git a/fastapi_log_mail/static/description/index.html b/fastapi_log_mail/static/description/index.html index 1ec0ff159..026bfe3b8 100644 --- a/fastapi_log_mail/static/description/index.html +++ b/fastapi_log_mail/static/description/index.html @@ -3,7 +3,7 @@ -Fastapi Log +FastAPI Log notification -
-

Fastapi Log

+
+

FastAPI Log notification

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

-

This module allows to send an email when an exception occurs in an -endpoint.

+

This module allows to create an activity when an exception is logged in +a fastapi endpoint.

Table of contents

    @@ -387,8 +387,7 @@

    Fastapi Log

Configuration

-

In any endpoint that has logging enabled, insert an email template in -“Error E-mail Template”.

+

Configure a fastapi endpoint as explained in api_log_mail.

Bug Tracker

diff --git a/fastapi_log_mail/tests/test_fastapi_log_mail.py b/fastapi_log_mail/tests/test_fastapi_log_mail.py index 74cad8ba8..e4bde480a 100644 --- a/fastapi_log_mail/tests/test_fastapi_log_mail.py +++ b/fastapi_log_mail/tests/test_fastapi_log_mail.py @@ -1,24 +1,32 @@ # Copyright 2025 Akretion (http://www.akretion.com). # @author Florian Mounier +# Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + import os import unittest -from odoo.addons.api_log.tests.common import CommonAPILog from odoo.addons.fastapi.schemas import DemoExceptionType +from odoo.addons.fastapi_log.tests.common import Common from odoo.addons.mail.tests.common import MailCase +from fastapi import status + -@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "FastAPIEncryptedErrorsCase skipped") -class FastAPIEncryptedErrorsCase(CommonAPILog, MailCase): +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "TestFastapiLogMail skipped") +class TestFastapiLogMail(Common, MailCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo") - - cls.fastapi_demo_app._handle_registry_sync() - cls.fastapi_demo_app.log_requests = True - cls.fastapi_demo_app.fastapi_log_mail_template_id = cls.env[ + cls.fastapi_demo_app.api_log_mail_exception_activity_type_id = cls.env[ + "mail.activity.type" + ].create( + { + "name": "Test exception activity type", + "res_model": "api.log", + } + ) + cls.fastapi_demo_app.api_log_mail_exception_template_id = cls.env[ "mail.template" ].create( { @@ -27,16 +35,42 @@ def setUpClass(cls): } ) + def test_endpoint_exception_create_activity(self): + """If an endpoint has an activity type, + when an exception occurs an activity of the configured type is created. + """ + # Arrange + app = self.fastapi_demo_app + activity_type = app.api_log_mail_exception_activity_type_id + route = ( + "/fastapi_demo/test/demo/exception?" + f"exception_type={DemoExceptionType.user_error.value}" + "&error_message=An error happened" + ) + # pre-condition + self.assertTrue(activity_type) + + # Act + with self.log_capturer() as capturer: + response = self.url_open(route, timeout=200) + + # Assert + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + log = capturer.records + self.assertEqual(len(log), 1) + self.assertTrue(log.activity_ids) + def test_endpoint_exception_send_email(self): """If an endpoint has an email template, when an exception occurs an email is sent using the configured template. """ # Arrange - mail_template = self.fastapi_demo_app.fastapi_log_mail_template_id + app = self.fastapi_demo_app + mail_template = app.api_log_mail_exception_template_id route = ( - "/fastapi_demo/demo/exception?" + "/fastapi_demo/test/demo/exception?" f"exception_type={DemoExceptionType.user_error.value}" - "&error_message=User Error" + "&error_message=An error happened" ) # pre-condition self.assertTrue(mail_template) diff --git a/fastapi_log_mail/views/fastapi_endpoint_views.xml b/fastapi_log_mail/views/fastapi_endpoint_views.xml index fdd6deaaa..6e1d27886 100644 --- a/fastapi_log_mail/views/fastapi_endpoint_views.xml +++ b/fastapi_log_mail/views/fastapi_endpoint_views.xml @@ -5,12 +5,22 @@ --> + Add log mail fields to endpoint form view fastapi.endpoint + API Log !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

This module allows to store request and response logs for any API.

+

When a response is logged, the header API_LOG_ENTRY_ID is injected +in the response header. This header stores the identifier of the log +record produced from the response.

Table of contents

    diff --git a/api_log/tests/test_api_log.py b/api_log/tests/test_api_log.py index 3a3868231..45ddd5958 100644 --- a/api_log/tests/test_api_log.py +++ b/api_log/tests/test_api_log.py @@ -41,3 +41,11 @@ def test_log_response(self): log.log_response(response) self.assertEqual(log.response_status_code, 200) + self.assertEqual(log.response_headers["API-Log-Entry-ID"], str(log.id)) + self.assertEqual(response.headers["API-Log-Entry-ID"], str(log.id)) + + def test_log_exception(self): + log = self.log_model.create({}) + log.log_exception(Exception()) + + self.assertEqual(log.response_headers["API-Log-Entry-ID"], str(log.id)) From 1c222614c3010e9fc3a9a3ac012808d96b7e72a3 Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Tue, 9 Sep 2025 12:06:24 +0200 Subject: [PATCH 11/17] [IMP] fastapi_log: Use dedicated cursor for logs Co-authored-by: Florian Mounier --- fastapi_log/fastapi_dispatcher.py | 64 ++++++++++++++++++--------- fastapi_log/tests/common.py | 27 ++++++++++- fastapi_log/tests/test_fastapi_log.py | 2 +- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py index ba5071083..a6978bc57 100644 --- a/fastapi_log/fastapi_dispatcher.py +++ b/fastapi_log/fastapi_dispatcher.py @@ -4,7 +4,9 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +from contextlib import contextmanager +from odoo import registry from odoo.http import _dispatchers from odoo.addons.fastapi.fastapi_dispatcher import ( @@ -19,6 +21,28 @@ class FastApiDispatcher(_dispatchers.get("fastapi", BaseFastApiDispatcher)): routing_type = "fastapi" + @contextmanager + def _create_log_env(self, request_env): + request_registry = request_env.registry + if request_registry.in_test_mode(): + # During tests, use the dedicated test's cursor + cr = request_registry.test_log_cr + else: + # Create an independent cursor + # so the logs are committed despite any endpoint's exceptions + cr = registry(request_registry.db_name).cursor() + + try: + yield request_env(cr=cr, su=True) + finally: + # While executing tests, + # the cursor is already managed in the tests + if not request_registry.in_test_mode(): + try: + cr.commit() # pylint: disable=invalid-commit + finally: + cr.close() + def dispatch(self, endpoint, args): self.request.params = {} environ = self._get_environ() @@ -28,32 +52,28 @@ def dispatch(self, endpoint, args): ._get_endpoint(environ["PATH_INFO"]) ) if fastapi_endpoint.log_requests: - request = self.request - env = request.env(su=True) - try: - log = env["api.log"].log_request(request) - except Exception as e: - _logger.warning("Failed to log request", exc_info=e) - log = None - - try: - response = super().dispatch(endpoint, args) - except Exception as response_exc: + with self._create_log_env(self.request.env) as log_env: try: - log and log.log_exception(response_exc) + log = log_env["api.log"].log_request(self.request) except Exception as e: - _logger.warning("Failed to log exception", exc_info=e) - else: - # Be sure to commit/save the exception's log - env.cr.commit() + _logger.warning("Failed to log request", exc_info=e) + log = None - raise response_exc - else: try: - log and log.log_response(response) - except Exception as e: - _logger.warning("Failed to log response", exc_info=e) + response = super().dispatch(endpoint, args) + except Exception as response_exc: + try: + log and log.log_exception(response_exc) + except Exception as e: + _logger.warning("Failed to log exception", exc_info=e) + + raise response_exc + else: + try: + log and log.log_response(response) + except Exception as e: + _logger.warning("Failed to log response", exc_info=e) - return response + return response else: return super().dispatch(endpoint, args) diff --git a/fastapi_log/tests/common.py b/fastapi_log/tests/common.py index 186c8542e..013dba216 100644 --- a/fastapi_log/tests/common.py +++ b/fastapi_log/tests/common.py @@ -3,8 +3,10 @@ # Copyright 2025 Simone Rubino - PyTech # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import threading from contextlib import contextmanager +from odoo.sql_db import TestCursor from odoo.tests.common import RecordCapturer from odoo.addons.api_log.tests.common import Common as CommonAPILog @@ -25,11 +27,34 @@ def setUpClass(cls): ) lang.active = True + def setUp(self): + super().setUp() + # Use a side test cursor to be able to get exception logs + reg = self.env.registry + reg.test_log_lock = threading.RLock() + reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock) + + def tearDown(self): + reg = self.env.registry + reg.test_log_cr.rollback() + reg.test_log_cr.close() + reg.test_log_cr = None + reg.test_log_lock = None + super().tearDown() + + def _get_log_env(self): + return self.env(cr=self.env.registry.test_log_cr) + + def _get_log_env_records(self, records): + log_env = self._get_log_env() + return log_env[records._name].browse(records.ids) + @contextmanager def log_capturer(self): app = self.fastapi_demo_app + log_env = self._get_log_env() with RecordCapturer( - self.env[self.log_model._name], + log_env[self.log_model._name], [("collection_ref", "=", "%s,%s" % (app._name, app.id))], ) as capturer: yield capturer diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index bcf7feb46..5c6fcae1b 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -126,4 +126,4 @@ def test_collection_ref(self): # Assert log = capturer.records[-1] self.assertEqual(log.collection_ref, endpoint) - self.assertIn(log, endpoint.log_ids) + self.assertIn(log, log.collection_ref.log_ids) From d16d556e52e798d0a46404080d859f550574a5f2 Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Tue, 9 Sep 2025 12:08:15 +0200 Subject: [PATCH 12/17] [IMP] fastapi_log_mail: Use dedicated cursor in tests --- .../tests/test_fastapi_log_mail.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/fastapi_log_mail/tests/test_fastapi_log_mail.py b/fastapi_log_mail/tests/test_fastapi_log_mail.py index e4bde480a..c4e83cb02 100644 --- a/fastapi_log_mail/tests/test_fastapi_log_mail.py +++ b/fastapi_log_mail/tests/test_fastapi_log_mail.py @@ -15,10 +15,8 @@ @unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "TestFastapiLogMail skipped") class TestFastapiLogMail(Common, MailCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.fastapi_demo_app.api_log_mail_exception_activity_type_id = cls.env[ + def _set_mail_exception_activity_type(self, app): + app.api_log_mail_exception_activity_type_id = app.env[ "mail.activity.type" ].create( { @@ -26,12 +24,12 @@ def setUpClass(cls): "res_model": "api.log", } ) - cls.fastapi_demo_app.api_log_mail_exception_template_id = cls.env[ - "mail.template" - ].create( + + def _set_mail_exception_template(self, app): + app.api_log_mail_exception_template_id = app.env["mail.template"].create( { "name": "Test exception email template", - "model_id": cls.env.ref("api_log.model_api_log").id, + "model_id": app.env.ref("api_log.model_api_log").id, } ) @@ -40,7 +38,8 @@ def test_endpoint_exception_create_activity(self): when an exception occurs an activity of the configured type is created. """ # Arrange - app = self.fastapi_demo_app + app = self._get_log_env_records(self.fastapi_demo_app) + self._set_mail_exception_activity_type(app) activity_type = app.api_log_mail_exception_activity_type_id route = ( "/fastapi_demo/test/demo/exception?" @@ -65,7 +64,8 @@ def test_endpoint_exception_send_email(self): when an exception occurs an email is sent using the configured template. """ # Arrange - app = self.fastapi_demo_app + app = self._get_log_env_records(self.fastapi_demo_app) + self._set_mail_exception_template(app) mail_template = app.api_log_mail_exception_template_id route = ( "/fastapi_demo/test/demo/exception?" From 5117b5890ce187c3badcc05eefa0dd6456ea299f Mon Sep 17 00:00:00 2001 From: Simone Rubino Date: Mon, 20 Oct 2025 12:56:19 +0200 Subject: [PATCH 13/17] [IMP] fastapi_log: In tests, check Log identifier in response --- fastapi_log/tests/test_fastapi_log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index 5c6fcae1b..c5d7bf169 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -47,6 +47,8 @@ def test_log_exception(self): self.assertEqual(len(capturer.records), 1) log = capturer.records[0] + self.assertEqual(response.headers["API-Log-Entry-ID"], str(log.id)) + self.assertIn("/fastapi_demo/test/demo/exception", log.request_url) self.assertEqual(log.request_method, "GET") self.assertEqual(log.response_status_code, 400) From d0aeb41e2d681c1207b8db5f4ccb6b30c95c9443 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 14 Jan 2026 17:31:53 +0100 Subject: [PATCH 14/17] [MIG] api_log --- api_log/README.rst | 10 +++++----- api_log/__manifest__.py | 2 +- api_log/models/api_log_collection.py | 2 +- api_log/pyproject.toml | 3 +++ api_log/security/res_groups.xml | 2 -- api_log/static/description/index.html | 6 +++--- api_log/tests/test_api_log.py | 2 ++ api_log/views/api_log_views.xml | 23 ++++++++++------------- 8 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 api_log/pyproject.toml diff --git a/api_log/README.rst b/api_log/README.rst index 9cc98c41c..525cfa846 100644 --- a/api_log/README.rst +++ b/api_log/README.rst @@ -17,13 +17,13 @@ API Log :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github - :target: https://github.com/OCA/rest-framework/tree/16.0/api_log + :target: https://github.com/OCA/rest-framework/tree/18.0/api_log :alt: OCA/rest-framework .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-api_log + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-api_log :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -45,7 +45,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -88,6 +88,6 @@ Current `maintainer `__: |maintainer-paradoxxxzero| -This module is part of the `OCA/rest-framework `_ project on GitHub. +This module is part of the `OCA/rest-framework `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/api_log/__manifest__.py b/api_log/__manifest__.py index 84193a908..3b9436aa9 100644 --- a/api_log/__manifest__.py +++ b/api_log/__manifest__.py @@ -4,7 +4,7 @@ { "name": "API Log", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "author": "Akretion, Odoo Community Association (OCA)", "license": "AGPL-3", "summary": "Log API requests in database", diff --git a/api_log/models/api_log_collection.py b/api_log/models/api_log_collection.py index a094b6e0e..1c212c7f9 100644 --- a/api_log/models/api_log_collection.py +++ b/api_log/models/api_log_collection.py @@ -48,7 +48,7 @@ def action_logs(self): "res_model": "api.log", "name": "Logs", "view_type": "form", - "view_mode": "tree,form", + "view_mode": "list,form", "target": "current", "domain": self._get_logs_domain(), "context": dict(self.env.context), diff --git a/api_log/pyproject.toml b/api_log/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/api_log/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/api_log/security/res_groups.xml b/api_log/security/res_groups.xml index 8b9ddf38b..2c3334989 100644 --- a/api_log/security/res_groups.xml +++ b/api_log/security/res_groups.xml @@ -5,7 +5,6 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - API Log Access - diff --git a/api_log/static/description/index.html b/api_log/static/description/index.html index 60378ff49..9bec0ab1c 100644 --- a/api_log/static/description/index.html +++ b/api_log/static/description/index.html @@ -369,7 +369,7 @@

    API Log

    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

    Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

    +

    Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

    This module allows to store request and response logs for any API.

    When a response is logged, the header API_LOG_ENTRY_ID is injected in the response header. This header stores the identifier of the log @@ -391,7 +391,7 @@

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

    +feedback.

    Do not contact contributors directly about support or help with technical issues.

@@ -425,7 +425,7 @@

Maintainers

promote its widespread use.

Current maintainer:

paradoxxxzero

-

This module is part of the OCA/rest-framework project on GitHub.

+

This module is part of the OCA/rest-framework project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/api_log/tests/test_api_log.py b/api_log/tests/test_api_log.py index 45ddd5958..dc64a60c4 100644 --- a/api_log/tests/test_api_log.py +++ b/api_log/tests/test_api_log.py @@ -25,6 +25,8 @@ def test_log_request(self): url=base_url, method="GET", ) + # Fix missing now required attribute + httprequest.remote_addr = "127.0.0.1" request = Request(httprequest) log = self.log_model.log_request(request) diff --git a/api_log/views/api_log_views.xml b/api_log/views/api_log_views.xml index 4e8ba689f..30d07ef96 100644 --- a/api_log/views/api_log_views.xml +++ b/api_log/views/api_log_views.xml @@ -1,4 +1,4 @@ - + -

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

This module allows to create an activity when an exception is logged in an API logs collection.

Table of contents

@@ -395,7 +395,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -426,7 +426,7 @@

Maintainers

promote its widespread use.

Current maintainer:

SirPyTech

-

This module is part of the OCA/rest-framework project on GitHub.

+

This module is part of the OCA/rest-framework project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

From 6447d9ba54fefaf7fb60745cc64bf6314d6d28c9 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Wed, 14 Jan 2026 17:32:20 +0100 Subject: [PATCH 16/17] [MIG] fastapi_log --- fastapi_log/README.rst | 10 +++--- fastapi_log/__manifest__.py | 2 +- fastapi_log/fastapi_dispatcher.py | 4 +-- .../migrations/16.0.1.1.0/post-migration.py | 32 ------------------- .../migrations/16.0.1.1.0/pre-migration.py | 20 ------------ fastapi_log/models/api_log.py | 5 +-- fastapi_log/pyproject.toml | 3 ++ fastapi_log/static/description/index.html | 6 ++-- fastapi_log/tests/common.py | 6 ++-- fastapi_log/tests/test_fastapi_log.py | 15 +++++++-- fastapi_log/views/fastapi_endpoint_views.xml | 13 ++------ fastapi_log/views/fastapi_log_views.xml | 2 +- 12 files changed, 34 insertions(+), 84 deletions(-) delete mode 100644 fastapi_log/migrations/16.0.1.1.0/post-migration.py delete mode 100644 fastapi_log/migrations/16.0.1.1.0/pre-migration.py create mode 100644 fastapi_log/pyproject.toml diff --git a/fastapi_log/README.rst b/fastapi_log/README.rst index fdb54e937..0c672bd01 100644 --- a/fastapi_log/README.rst +++ b/fastapi_log/README.rst @@ -17,13 +17,13 @@ Fastapi Log :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github - :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi_log + :target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_log :alt: OCA/rest-framework .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi_log + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_log :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -56,7 +56,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -97,6 +97,6 @@ Current `maintainer `__: |maintainer-paradoxxxzero| -This module is part of the `OCA/rest-framework `_ project on GitHub. +This module is part of the `OCA/rest-framework `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_log/__manifest__.py b/fastapi_log/__manifest__.py index 27938d00e..e7cd72731 100644 --- a/fastapi_log/__manifest__.py +++ b/fastapi_log/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Fastapi Log", - "version": "16.0.1.1.0", + "version": "18.0.1.0.0", "author": "Akretion, Odoo Community Association (OCA)", "summary": "Log Fastapi requests in database", "category": "Tools", diff --git a/fastapi_log/fastapi_dispatcher.py b/fastapi_log/fastapi_dispatcher.py index a6978bc57..f5d3b7ef5 100644 --- a/fastapi_log/fastapi_dispatcher.py +++ b/fastapi_log/fastapi_dispatcher.py @@ -6,8 +6,8 @@ import logging from contextlib import contextmanager -from odoo import registry from odoo.http import _dispatchers +from odoo.modules.registry import Registry from odoo.addons.fastapi.fastapi_dispatcher import ( FastApiDispatcher as BaseFastApiDispatcher, @@ -30,7 +30,7 @@ def _create_log_env(self, request_env): else: # Create an independent cursor # so the logs are committed despite any endpoint's exceptions - cr = registry(request_registry.db_name).cursor() + cr = Registry(request_registry.db_name).cursor() try: yield request_env(cr=cr, su=True) diff --git a/fastapi_log/migrations/16.0.1.1.0/post-migration.py b/fastapi_log/migrations/16.0.1.1.0/post-migration.py deleted file mode 100644 index b95831de3..000000000 --- a/fastapi_log/migrations/16.0.1.1.0/post-migration.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from openupgradelib import openupgrade - - -@openupgrade.migrate() -def migrate(env, version): - endpoint_id_column = openupgrade.get_legacy_name("fastapi_endpoint_id") - openupgrade.logged_query( - env.cr, - """ - UPDATE api_log SET - collection_id=%(endpoint_id_column)s, - collection_model='fastapi.endpoint', - collection_ref='fastapi.endpoint,' || %(endpoint_id_column)s - WHERE %(endpoint_id_column)s IS NOT NULL - """ - % { - "endpoint_id_column": endpoint_id_column, - }, - ) - openupgrade.logged_query( - env.cr, - """ - ALTER TABLE api_log - DROP COLUMN %(endpoint_id_column)s - """ - % { - "endpoint_id_column": endpoint_id_column, - }, - ) diff --git a/fastapi_log/migrations/16.0.1.1.0/pre-migration.py b/fastapi_log/migrations/16.0.1.1.0/pre-migration.py deleted file mode 100644 index 9cdaa9143..000000000 --- a/fastapi_log/migrations/16.0.1.1.0/pre-migration.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from openupgradelib import openupgrade - - -@openupgrade.migrate() -def migrate(env, version): - openupgrade.copy_columns( - env.cr, - { - "api_log": [ - ( - "fastapi_endpoint_id", - None, - None, - ), - ], - }, - ) diff --git a/fastapi_log/models/api_log.py b/fastapi_log/models/api_log.py index 086e656b4..af7d16c4f 100644 --- a/fastapi_log/models/api_log.py +++ b/fastapi_log/models/api_log.py @@ -45,10 +45,7 @@ def _prepare_log_request(self, request): .sudo() ._get_endpoint(environ["PATH_INFO"]) ) - log_request_values["collection_ref"] = "%s,%s" % ( - endpoint._name, - endpoint.id, - ) + log_request_values["collection_ref"] = f"{endpoint._name},{endpoint.id}" return log_request_values diff --git a/fastapi_log/pyproject.toml b/fastapi_log/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_log/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_log/static/description/index.html b/fastapi_log/static/description/index.html index b0b206a30..118c0190d 100644 --- a/fastapi_log/static/description/index.html +++ b/fastapi_log/static/description/index.html @@ -369,7 +369,7 @@

Fastapi Log

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

This module allows an endpoint to activate full request logging in a database model.

It is useful to debug production issues or to monitor the usage of a @@ -401,7 +401,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -433,7 +433,7 @@

Maintainers

promote its widespread use.

Current maintainer:

paradoxxxzero

-

This module is part of the OCA/rest-framework project on GitHub.

+

This module is part of the OCA/rest-framework project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/fastapi_log/tests/common.py b/fastapi_log/tests/common.py index 013dba216..7dd7016a3 100644 --- a/fastapi_log/tests/common.py +++ b/fastapi_log/tests/common.py @@ -32,12 +32,14 @@ def setUp(self): # Use a side test cursor to be able to get exception logs reg = self.env.registry reg.test_log_lock = threading.RLock() - reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock) + reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock, False) def tearDown(self): reg = self.env.registry reg.test_log_cr.rollback() reg.test_log_cr.close() + # Also close the real cursor to avoid unclosed errors + reg.test_log_cr._cursor.close() reg.test_log_cr = None reg.test_log_lock = None super().tearDown() @@ -55,6 +57,6 @@ def log_capturer(self): log_env = self._get_log_env() with RecordCapturer( log_env[self.log_model._name], - [("collection_ref", "=", "%s,%s" % (app._name, app.id))], + [("collection_ref", "=", f"{app._name},{app.id}")], ) as capturer: yield capturer diff --git a/fastapi_log/tests/test_fastapi_log.py b/fastapi_log/tests/test_fastapi_log.py index c5d7bf169..6c2f2a045 100644 --- a/fastapi_log/tests/test_fastapi_log.py +++ b/fastapi_log/tests/test_fastapi_log.py @@ -6,6 +6,8 @@ import os import unittest +from odoo.tools import mute_logger + from odoo.addons.fastapi.schemas import DemoExceptionType from odoo.addons.fastapi_log.tests.common import Common @@ -14,6 +16,7 @@ @unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "TestFastapiLog skipped") class TestFastapiLog(Common): + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_no_log_if_disabled(self): self.fastapi_demo_app.write({"log_requests": False}) @@ -23,6 +26,7 @@ def test_no_log_if_disabled(self): self.assertFalse(capturer.records) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_log_simple(self): with self.log_capturer() as capturer: response = self.url_open("/fastapi_demo/test/demo", timeout=200) @@ -35,6 +39,7 @@ def test_log_simple(self): self.assertEqual(log.response_status_code, 200) self.assertTrue(log.time > 0) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_log_exception(self): with self.log_capturer() as capturer: route = ( @@ -57,6 +62,7 @@ def test_log_exception(self): self.assertIn(b"User Error", log.response_body) self.assertIn("odoo.exceptions.UserError: User Error\n", log.stack_trace) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_log_bare_exception(self): with self.log_capturer() as capturer: route = ( @@ -79,6 +85,7 @@ def test_log_bare_exception(self): self.assertIn(b"Internal Server Error", log.response_body) self.assertIn("NotImplementedError: Internal Server Error\n", log.stack_trace) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_log_retrying_post(self): with self.log_capturer() as capturer: nbr_retries = 2 @@ -92,7 +99,7 @@ def test_log_retrying_post(self): ) self.assertEqual(len(capturer.records), 3) - for log in capturer.records[1:]: + for log in capturer.records[:-1]: self.assertIn("/fastapi_demo/test/demo/retrying", log.request_url) self.assertEqual(log.request_method, "POST") self.assertEqual(log.response_status_code, 500) @@ -100,11 +107,12 @@ def test_log_retrying_post(self): self.assertTrue(log.response_body) self.assertIn(b"fake error", log.response_body) self.assertIn( - "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError: fake error", + "odoo.addons.fastapi.routers.demo_router.FakeConcurrentUpdateError:" + " fake error", log.stack_trace, ) - log = capturer.records[0] + log = capturer.records[-1] self.assertIn("/fastapi_demo/test/demo/retrying", log.request_url) self.assertEqual(log.request_method, "POST") self.assertEqual(log.response_status_code, 200) @@ -114,6 +122,7 @@ def test_log_retrying_post(self): self.assertIn(b'"file":"test"', log.response_body) self.assertFalse(log.stack_trace) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_collection_ref(self): """The created log holds a reference to its endpoint and viceversa.""" # Arrange diff --git a/fastapi_log/views/fastapi_endpoint_views.xml b/fastapi_log/views/fastapi_endpoint_views.xml index ac50560e8..292d9fe7b 100644 --- a/fastapi_log/views/fastapi_endpoint_views.xml +++ b/fastapi_log/views/fastapi_endpoint_views.xml @@ -1,4 +1,4 @@ - + - fastapi.endpoint -
-
-
-
diff --git a/fastapi_log/views/fastapi_log_views.xml b/fastapi_log/views/fastapi_log_views.xml index 3c19ba737..68b3a126d 100644 --- a/fastapi_log/views/fastapi_log_views.xml +++ b/fastapi_log/views/fastapi_log_views.xml @@ -12,7 +12,7 @@ [ ("collection_model", "=", "fastapi.endpoint"), ] - tree,form + list,form
Date: Wed, 14 Jan 2026 17:32:36 +0100 Subject: [PATCH 17/17] [MIG] fastapi_log_mail --- fastapi_log_mail/README.rst | 10 +++--- fastapi_log_mail/__manifest__.py | 2 +- .../migrations/16.0.1.1.0/post-migration.py | 32 ------------------- .../migrations/16.0.1.1.0/pre-migration.py | 20 ------------ fastapi_log_mail/pyproject.toml | 3 ++ .../static/description/index.html | 6 ++-- .../tests/test_fastapi_log_mail.py | 4 +++ .../views/fastapi_endpoint_views.xml | 14 ++------ 8 files changed, 19 insertions(+), 72 deletions(-) delete mode 100644 fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py delete mode 100644 fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py create mode 100644 fastapi_log_mail/pyproject.toml diff --git a/fastapi_log_mail/README.rst b/fastapi_log_mail/README.rst index af99233ca..975392db4 100644 --- a/fastapi_log_mail/README.rst +++ b/fastapi_log_mail/README.rst @@ -17,13 +17,13 @@ FastAPI Log notification :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github - :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi_log_mail + :target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_log_mail :alt: OCA/rest-framework .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi_log_mail + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_log_mail :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -47,7 +47,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -87,6 +87,6 @@ Current `maintainer `__: |maintainer-SirPyTech| -This module is part of the `OCA/rest-framework `_ project on GitHub. +This module is part of the `OCA/rest-framework `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_log_mail/__manifest__.py b/fastapi_log_mail/__manifest__.py index 8bcbc0621..91f02a2c5 100644 --- a/fastapi_log_mail/__manifest__.py +++ b/fastapi_log_mail/__manifest__.py @@ -3,7 +3,7 @@ { "name": "FastAPI Log notification", - "version": "16.0.1.1.0", + "version": "18.0.1.0.0", "license": "AGPL-3", "author": "PyTech, Odoo Community Association (OCA)", "maintainers": [ diff --git a/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py b/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py deleted file mode 100644 index 1903d6e19..000000000 --- a/fastapi_log_mail/migrations/16.0.1.1.0/post-migration.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from openupgradelib import openupgrade - - -@openupgrade.migrate() -def migrate(env, version): - template_id_column = openupgrade.get_legacy_name( - "fastapi_log_mail_template_id", - ) - openupgrade.logged_query( - env.cr, - """ - UPDATE fastapi_endpoint SET - api_log_mail_exception_template_id=%(template_id_column)s - WHERE %(template_id_column)s IS NOT NULL - """ - % { - "template_id_column": template_id_column, - }, - ) - openupgrade.logged_query( - env.cr, - """ - ALTER TABLE fastapi_endpoint - DROP COLUMN %(template_id_column)s - """ - % { - "template_id_column": template_id_column, - }, - ) diff --git a/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py b/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py deleted file mode 100644 index 71f919a98..000000000 --- a/fastapi_log_mail/migrations/16.0.1.1.0/pre-migration.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2025 Simone Rubino - PyTech -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from openupgradelib import openupgrade - - -@openupgrade.migrate() -def migrate(env, version): - openupgrade.copy_columns( - env.cr, - { - "fastapi_endpoint": [ - ( - "fastapi_log_mail_template_id", - None, - None, - ), - ], - }, - ) diff --git a/fastapi_log_mail/pyproject.toml b/fastapi_log_mail/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_log_mail/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_log_mail/static/description/index.html b/fastapi_log_mail/static/description/index.html index 026bfe3b8..654252533 100644 --- a/fastapi_log_mail/static/description/index.html +++ b/fastapi_log_mail/static/description/index.html @@ -369,7 +369,7 @@

FastAPI Log notification

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

This module allows to create an activity when an exception is logged in a fastapi endpoint.

Table of contents

@@ -394,7 +394,7 @@

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -425,7 +425,7 @@

Maintainers

promote its widespread use.

Current maintainer:

SirPyTech

-

This module is part of the OCA/rest-framework project on GitHub.

+

This module is part of the OCA/rest-framework project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/fastapi_log_mail/tests/test_fastapi_log_mail.py b/fastapi_log_mail/tests/test_fastapi_log_mail.py index c4e83cb02..cbc69c704 100644 --- a/fastapi_log_mail/tests/test_fastapi_log_mail.py +++ b/fastapi_log_mail/tests/test_fastapi_log_mail.py @@ -6,6 +6,8 @@ import os import unittest +from odoo.tools import mute_logger + from odoo.addons.fastapi.schemas import DemoExceptionType from odoo.addons.fastapi_log.tests.common import Common from odoo.addons.mail.tests.common import MailCase @@ -33,6 +35,7 @@ def _set_mail_exception_template(self, app): } ) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_endpoint_exception_create_activity(self): """If an endpoint has an activity type, when an exception occurs an activity of the configured type is created. @@ -59,6 +62,7 @@ def test_endpoint_exception_create_activity(self): self.assertEqual(len(log), 1) self.assertTrue(log.activity_ids) + @mute_logger("odoo.http", "odoo.addons.base.models.assetsbundle") def test_endpoint_exception_send_email(self): """If an endpoint has an email template, when an exception occurs an email is sent using the configured template. diff --git a/fastapi_log_mail/views/fastapi_endpoint_views.xml b/fastapi_log_mail/views/fastapi_endpoint_views.xml index 6e1d27886..b5a620ffc 100644 --- a/fastapi_log_mail/views/fastapi_endpoint_views.xml +++ b/fastapi_log_mail/views/fastapi_endpoint_views.xml @@ -1,4 +1,4 @@ - +